diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..c1b4a7c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,125 @@ +# GW2Bot Architecture (Target) + +This document proposes a structure that keeps a single exported Cog class for discord.py while removing the tight mixin coupling. The key ideas: + +- Separate layers: + - `adapters/discord`: Cogs, command handlers, interaction views + - `core/api`: GW2 HTTP API client and domain models + - `core/services`: stateless or light state application logic (formerly mixin methods) + - `data`: persistence, caching, migrations + - `shared`: utilities, constants, DTOs, errors +- Use dependency injection: a light-weight service container wires services together and hands them to the Cog. +- Provide a Facade `GuildWars2` Cog that composes services instead of inheriting mixins. +- Keep public behavior and command signatures identical while migrating incrementally feature-by-feature. + +## Why change? + +- Mixins hide dependencies and create fragile diamond inheritance chains. +- Cross-references are hard: IDEs can’t auto-navigate, type hints get muddy. +- Testing is painful because behavior is spread across classes by inheritance. + +Composition via services: + +- Each service exposes a clear contract, injected with its dependencies (api client, db, caches). +- The Cog delegates to services; it becomes thin, focused on Discord concerns. +- Easy to test a service in isolation; easy to reuse logic from other entry points. + +## Proposed layout + +``` +guildwars2/ + adapters/ + discord/ + __init__.py + cog.py # GuildWars2 Cog (the facade) + views.py # UI components + core/ + __init__.py + api_client.py # async GW2 API (httpx/aiohttp) + models.py # pydantic/dataclasses (optional) + services/ + __init__.py + account.py + raids.py + items.py + ... + data/ + __init__.py + repositories.py # Mongo abstractions + cache.py + shared/ + __init__.py + errors.py # your existing exceptions + settings.py + utils/ + chat.py + db.py +``` + +Your existing modules can be migrated gradually: + +- Move read-only helpers to `shared/utils` (keep import shims to avoid breaks). +- Wrap Mongo calls inside `data/repositories.py` and inject those into services. +- Move GW2 API methods from `ApiMixin` to `core/api_client.py` as `GW2ApiClient`. +- For each feature mixin (e.g., `AccountMixin`): + 1) Extract discord-free logic into a service: `AccountService`. + 2) In the Cog, keep the `@app_commands` function but delegate to the service. + +## The Facade Cog + +The Cog maintains the module contract for discord.py (still a single class exported), but composition replaces inheritance: + +- Create `ServiceContainer` that builds and caches service singletons. +- `GuildWars2` Cog receives the container and pulls services it needs. +- Cog methods stay in one class, but the logic lives in services. + +## Incremental migration plan + +1. Introduce scaffolding (no behavior change): + - `core/api_client.py` (wrap existing `call_api`, `call_multiple`, `cache_result`). + - `data/repositories.py` with thin wrappers over `self.db` usage. + - `adapters/discord/cog.py` new Cog that composes services but exports the same setup entry point, or keep current `setup` and use container internally. +2. Migrate one feature as a slice ("walking skeleton"): + - Extract a small command, e.g., `/account` summary, into `AccountService`. + - The Cog calls `account_service.get_summary(user)` and formats the embed. +3. Repeat per feature: + - When a feature is migrated, delete the mixin inheritance for that part. +4. Remove mixins entirely when the last feature moves. + +## Dependency conventions + +- Services depend only on `api_client`, `repositories`, and `time/clock` helpers. +- Services should not import Discord. Return plain data objects or dicts; Cog renders embeds. +- Views and long-running tasks (tasks.loop) live in adapters/discord; they call services. + +## Example contracts + +- GW2ApiClient + - `async get(endpoint, *, user=None, key=None, schema_version=None) -> dict|list` + - Raises domain errors from `shared/errors.py` (your existing exceptions) +- AccountService + - `async get_account(user) -> Account` (or dict) + - `async get_killproof(user) -> KillProofSummary` +- ItemsService + - `async find_items(user, ids) -> Dict[str,int]` + +## Testing + +- Unit test services with fake api_client and fake repositories. +- Keep a couple of integration tests that spin up the Cog with a real container and a mock Discord interaction. + +## Typing and tooling + +- Add typing to the service interfaces; enable mypy (optional) for clarity. +- Keep tenacity retry in the API client; keep httpx or aiohttp session management in the container. + +## Backwards compatibility + +- Keep `async def setup(bot)` exporting the same Cog class name so your external loader doesn’t change. +- Provide import shims for `guildwars2.utils.chat` and `utils.db` while moving code. + +## FAQ + +- Why not multiple cogs? You can split into multiple cogs later. Keeping one facade Cog first avoids breaking the plugin consumer if it expects a single class. +- Long-running tasks? Put them in the Cog but move business logic to services; only the task loop and scheduling remain in the Cog. +- Where to store shared state? Prefer repositories and caches; avoid global singletons. The container can hold process-wide clients (http, db). diff --git a/guildwars2/__init__.py b/guildwars2/__init__.py index 310ce64..0866f2e 100644 --- a/guildwars2/__init__.py +++ b/guildwars2/__init__.py @@ -1,15 +1,18 @@ import datetime import json import logging +from types import SimpleNamespace import discord +from discord.ext import commands from PIL import ImageFont import httpx from .account import AccountMixin from .achievements import AchievementsMixin from .api import ApiMixin -from .characters import CharactersMixin + +# from .characters import CharactersMixin from .commerce import CommerceMixin from .daily import DailyMixin from .database import DatabaseMixin @@ -28,14 +31,16 @@ from .wallet import WalletMixin from .worldsync import WorldsyncMixin from .wvw import WvwMixin +from .core.container import ServiceContainer +from .adapters.discord.commands import register_all as register_commands class GuildWars2( - discord.ext.commands.Cog, + commands.Cog, AccountMixin, AchievementsMixin, ApiMixin, - CharactersMixin, + # CharactersMixin, CommerceMixin, DailyMixin, DatabaseMixin, @@ -55,8 +60,12 @@ class GuildWars2( ): """Guild Wars 2 commands""" + # Hint editors that this attribute exists and its type + container: ServiceContainer + def __init__(self, bot): self.bot = bot + self.container = ServiceContainer(bot) self.db = self.bot.database.db.gw2 with open("cogs/guildwars2/gamedata.json", encoding="utf-8", mode="r") as f: self.gamedata = json.load(f) @@ -103,6 +112,16 @@ def __init__(self, bot): async def cog_load(self): self.bot.add_view(EventTimerReminderUnsubscribeView(self)) self.bot.add_view(GuildSyncPromptUserConfirmView(self)) + # Auto-discover and register commands in adapters/discord/commands + register_commands(self) + # Sync command tree so newly registered slash commands are available + # Fast-path: copy global commands to each guild for immediate availability + for guild in getattr(self.bot, "guilds", []) or []: + try: + self.bot.tree.copy_global_to(guild=guild) + await self.bot.tree.sync(guild=guild) + except Exception: + continue async def cog_unload(self): for task in self.tasks: @@ -144,6 +163,40 @@ def tell_off( ): self.bot.loop.create_task(component_context.send(message, ephemeral=True)) + # Command methods are auto-registered via adapters; keep Cog thin + + # Backward-compatible shims for migrated helpers + def get_crafting(self, character): + """Shim: delegate to CharactersService.get_crafting.""" + try: + return self.container.characters_service.get_crafting(character) + except Exception: + # Fallback to simple formatting to avoid breaking + out = [] + for c in character.get("crafting", []) or []: + r = c.get("rating") + d = c.get("discipline") + if r is not None and d: + out.append(f"Level {r} {d}") + return out + + async def get_profession(self, profession, specializations): + """Shim: delegate to CharactersService.get_profession. + + specializations is a list of specialization documents (from DB). + """ + data = await self.container.characters_service.get_profession( + profession, specializations, self.gamedata + ) + # Preserve attribute access and Color instance expected by legacy code + if isinstance(data.get("color"), int): + color = discord.Color(data["color"]) # type: ignore[index] + else: + color = data.get("color") + return SimpleNamespace( + name=data.get("name"), icon=data.get("icon"), color=color + ) + async def setup(bot): cog = GuildWars2(bot) diff --git a/guildwars2/account.py b/guildwars2/account.py index 964d8b7..d926955 100644 --- a/guildwars2/account.py +++ b/guildwars2/account.py @@ -1,715 +1,116 @@ -import asyncio -import collections -import datetime -import re -from collections import OrderedDict, defaultdict -from itertools import chain +from collections import defaultdict -import discord -import pymongo -from discord import app_commands -from discord.app_commands import Choice - -from .exceptions import APIError, APINotFound -from .utils.chat import embed_list_lines -from .utils.db import prepare_search +from .exceptions import APIError class AccountMixin: + async def find_items_in_account( + self, + user, + item_ids, + *, + doc=None, + flatten: bool = False, + search: bool = False, + results=None, + ): + from typing import Any, Dict, DefaultDict, List, cast - @app_commands.command() - async def account(self, interaction: discord.Interaction): - """General information about your account - - Required permissions: account - """ - await interaction.response.defer() - user = interaction.user - doc = await self.fetch_key(user, ["account"]) - results = await self.call_api("account", user) - accountname = doc["account_name"] - created = results["created"].split("T", 1)[0] - hascommander = "Yes" if results["commander"] else "No" - embed = discord.Embed(colour=await self.get_embed_color(interaction)) - embed.add_field(name="Created account on", value=created) - # Add world name to account info - wid = results["world"] - world = await self.get_world_name(wid) - embed.add_field(name="WvW Server", value=world) - if "progression" in doc["permissions"]: - endpoints = ["account/achievements", "account"] - achievements, account = await self.call_multiple( - endpoints, user, ["progression"]) - possible_ap = await self.total_possible_ap() - user_ap = await self.calculate_user_ap(achievements, account) - embed.add_field(name="Achievement Points", - value="{} earned out of {} possible".format( - user_ap, possible_ap), - inline=False) - embed.add_field(name="Commander tag", value=hascommander, inline=False) - if "fractal_level" in results: - fractallevel = results["fractal_level"] - embed.add_field(name="Fractal level", value=fractallevel) - if "wvw_rank" in results: - wvwrank = results["wvw_rank"] - embed.add_field(name="WvW rank", value=wvwrank) - if "pvp" in doc["permissions"]: - pvp = await self.call_api("pvp/stats", user) - pvprank = pvp["pvp_rank"] + pvp["pvp_rank_rollovers"] - embed.add_field(name="PVP rank", value=pvprank) - if "characters" in doc["permissions"]: - characters = await self.get_all_characters(user) - total_played = 0 - for character in characters: - total_played += character.age - embed.add_field(name="Total time played", - value=self.format_age(total_played), - inline=False) - if "access" in results: - access = results["access"] - if len(access) > 1: - to_delete = ["PlayForFree", "GuildWars2"] - for d in to_delete: - if d in access: - access.remove(d) - - # Having PathOfFire access implies HeartOfThorns access. - if "PathOfFire" in access and "HeartOfThorns" not in access: - access += "HeartOfThorns" - - def format_name(name): - return " ".join(re.findall(r'[A-Z\d][^A-Z\d]*', name)) - - access = "\n".join([format_name(e) for e in access]) - if access: - embed.add_field(name="Expansion access", value=access) - embed.set_author(name=accountname, icon_url=user.display_avatar.url) - embed.set_footer(text=self.bot.user.name, - icon_url=self.bot.user.display_avatar.url) - await interaction.followup.send(embed=embed) - - @app_commands.command() - async def li(self, interaction: discord.Interaction): - """Shows how many Legendary Insights you have earned""" - await interaction.response.defer() - user = interaction.user - scopes = ["inventories", "characters", "wallet"] - trophies = self.gamedata["raid_trophies"] - ids = [] - for trophy in trophies: - for items in trophy["items"]: - ids += items["items"] - doc = await self.fetch_key(user, scopes) - search_results = await self.find_items_in_account(user, ids, doc=doc) - wallet = await self.call_api("account/wallet", key=doc["key"]) - embed = discord.Embed(color=0x4C139D) - total = 0 - crafted_total = 0 - for trophy in trophies: - trophy_total = 0 - breakdown = [] - if trophy["wallet"]: - for currency_result in wallet: - if currency_result["id"] == trophy["wallet"]: - trophy_total += currency_result["value"] - breakdown.append( - f"Wallet - **{currency_result['value']}**") - break - for group in trophy["items"]: - if "reduced_worth" in group: - upgraded_sum = 0 - if "upgrades_to" in group: - upgrade_dict = next( - item for item in trophy["items"] - if item.get("name") == group["upgrades_to"]) - for item in upgrade_dict["items"]: - upgraded_sum += sum(search_results[item].values()) - amount = 0 - for item in group["items"]: - amount += sum(search_results[item].values()) - reduced_amount = group["reduced_amount"] - reduced_amount = max(reduced_amount - upgraded_sum, 0) - sum_set = min( - amount, reduced_amount) * group["reduced_worth"] + max( - amount - reduced_amount, 0) * group["worth"] - if sum_set: - trophy_total += sum_set - breakdown.append( - f"{amount} {group['name']} - **{sum_set}**") - if group["crafted"]: - crafted_total += sum_set - continue - for item in group["items"]: - amount = sum(search_results[item].values()) - sum_item = amount * group["worth"] - if sum_item: - trophy_total += sum_item - if group["crafted"]: - crafted_total += sum_item - item_doc = await self.fetch_item(item) - line = f"{item_doc['name']} - **{sum_item}**" - if group["worth"] != 1: - line = f"{amount} " + line - breakdown.append(line) - if trophy_total: - name = f"{trophy_total} Legendary {trophy['name']} earned" - embed.add_field(name=name, - value="\n".join(breakdown), - inline=False) - total += trophy_total - embed.title = f"{total} Raid trophies earned".format(total) - embed.description = "{} on hand, {} used in crafting".format( - total - crafted_total, crafted_total) - embed.set_author(name=doc["account_name"], - icon_url=user.display_avatar.url) - embed.set_thumbnail( - url="https://wiki.guildwars2.com/images/5/5e/Legendary_Insight.png" - ) - embed.set_footer(text=self.bot.user.name, - icon_url=self.bot.user.display_avatar.url) - await interaction.followup.send(embed=embed) - - @app_commands.command() - async def kp(self, interaction: discord.Interaction): - """Shows completed raids, fractals, and strikes, as well as important challenges""" - await interaction.response.defer() - user = interaction.user - scopes = ["progression"] - areas = self.gamedata["killproofs"]["areas"] - # Create a list of lists of all achievement ids we need to check. - achievement_ids = [ - [x["id"]] if x["type"] == "single_achievement" - or x["type"] == "progressed_achievement" else x["ids"] - for x in chain.from_iterable( - [area["encounters"] for area in areas]) - ] - # Flatten it. - achievement_ids = [ - str(x) for x in chain.from_iterable(achievement_ids) - ] - - try: - doc = await self.fetch_key(user, scopes) - endpoint = "account/achievements?ids=" + ",".join(achievement_ids) - results = await self.call_api(endpoint, key=doc["key"]) - except APINotFound: - # Not Found is returned by the API when none of the searched - # achievements have been completed yet. - results = [] - except APIError: - raise - - def is_completed(encounter): - # One achievement has to be completed - if encounter["type"] == "single_achievement": - _id = encounter["id"] - for achievement in results: - if achievement["id"] == _id and achievement["done"]: - return "+✔" - # The achievement is not in the list or isn't done - return "-✖" - # All achievements have to be completed - if encounter["type"] == "all_achievements": - for _id in encounter["ids"]: - # The results do not contain achievements with no progress - if not any(a["id"] == _id and a["done"] for a in results): - return "-✖" - return "+✔" - # Progress toward one achievement has to be reached - if encounter["type"] == "progressed_achievement": - _id = encounter["id"] - _progress = encounter["progress"] - for achievement in results: - if achievement["id"] == _id and achievement[ - "current"] >= _progress: - return "+✔" - # The achievement is not in the list or player hasn't - # progressed far enough - return "-✖" - - embed = discord.Embed(title="Kill Proof", - color=await self.get_embed_color(interaction)) - embed.set_author(name=doc["account_name"], - icon_url=user.display_avatar.url) - for area in areas: - value = ["```diff"] - encounters = area["encounters"] - for encounter in encounters: - value.append(is_completed(encounter) + encounter["name"]) - value.append("```") - embed.add_field(name=area["name"], value="\n".join(value)) - - embed.description = ("Achievements were checked to find " - "completed encounters.") - embed.set_footer(text="Green (+) means completed. Red (-) means not. " - "CM stands for Challenge Mode.") - - await interaction.followup.send(embed=embed) - - @app_commands.command() - async def bosses(self, interaction: discord.Interaction): - """Shows your raid progression for the week""" - await interaction.response.defer() - user = interaction.user - scopes = ["progression"] - endpoints = ["account/raids", "account"] - doc = await self.fetch_key(user, scopes) - schema = datetime.datetime(2019, 2, 21) - results, account = await self.call_multiple(endpoints, - key=doc["key"], - schema_version=schema) - last_modified = datetime.datetime.strptime(account["last_modified"], - "%Y-%m-%dT%H:%M:%Sz") - raids = await self.get_raids() - embed = await self.boss_embed(interaction, raids, results, - doc["account_name"], last_modified) - embed.set_author(name=doc["account_name"], - icon_url=user.display_avatar.url) - await interaction.followup.send(embed=embed) - - async def item_autocomplete(self, interaction: discord.Interaction, - current: str): - if not current: - return [] - - def consolidate_duplicates(items): - unique_items = collections.OrderedDict() - for item in items: - item_tuple = item["name"], item["rarity"], item["type"] - if item_tuple not in unique_items: - unique_items[item_tuple] = [] - unique_items[item_tuple].append(item["_id"]) - unique_list = [] - for k, v in unique_items.items(): - ids = " ".join(str(i) for i in v) - if len(ids) > 100: - continue - unique_list.append({ - "name": k[0], - "rarity": k[1], - "ids": ids, - "type": k[2] - }) - return unique_list - - query = prepare_search(current) - query = { - "name": query, - } - items = await self.db.items.find(query).to_list(25) - items = sorted(consolidate_duplicates(items), key=lambda c: c["name"]) - return [ - Choice(name=f"{it['name']} - {it['rarity']}", value=it["ids"]) - for it in items - ] - - @app_commands.command() - @app_commands.describe(item="Specify the name of an item to search for. " - "Select an item from the list.") - @app_commands.autocomplete(item=item_autocomplete) - async def search(self, interaction: discord.Interaction, item: str): - """Find items on your account""" - await interaction.response.defer() - try: - ids = [int(it) for it in item.split(" ")] - except ValueError: - try: - choices = await self.item_autocomplete(interaction, item) - ids = [int(it) for it in choices[0].value.split(" ")] - except (ValueError, IndexError): - return await interaction.followup.send( - "Could not find any items with that name.") - item_doc = await self.fetch_item(ids[0]) - - async def generate_results_embed(results): - seq = [k for k, v in results.items() if v] - if not seq: - return None - longest = len(max(seq, key=len)) - if longest < 8: - longest = 8 - if 'is_upgrade' in item_doc and item_doc['is_upgrade']: - output = [ - "LOCATION{}INV / GEAR".format(" " * (longest - 5)), - "--------{}|-----".format("-" * (longest - 6)) - ] - align = 110 - else: - output = [ - "LOCATION{}COUNT".format(" " * (longest - 5)), - "--------{}|-----".format("-" * (longest - 6)) - ] - align = 80 - total = 0 - storage_counts = OrderedDict( - sorted(results.items(), key=lambda kv: kv[1], reverse=True)) - characters = await self.get_all_characters(user) - char_names = [] - for character in characters: - char_names.append(character.name) - for k, v in storage_counts.items(): - if v: - if 'is_upgrade' in item_doc and item_doc['is_upgrade']: - total += v[0] - total += v[1] - if k in char_names: - slotted_upg = v[1] - if slotted_upg == 0: - inf = "" - else: - inf = "/ {} ".format(slotted_upg) - output.append("{} {} | {} {}".format( - k.upper(), " " * (longest - len(k)), v[0], - inf)) - else: - output.append("{} {} | {}".format( - k.upper(), " " * (longest - len(k)), v[0])) - else: - total += v[0] - total += v[1] - output.append("{} {} | {}".format( - k.upper(), " " * (longest - len(k)), v[0] + v[1])) - output.append("--------{}------".format("-" * (longest - 5))) - output.append("TOTAL:{}{}".format(" " * (longest - 2), total)) - color = int( - self.gamedata["items"]["rarity_colors"][item_doc["rarity"]], - 16) - icon_url = item_doc["icon"] - data = discord.Embed(description="Search results" + " " * align + - u'\u200b', - color=color) - value = "\n".join(output) - - if len(value) > 1014: - value = "" - values = [] - for line in output: - if len(value) + len(line) > 1013: - values.append(value) - value = "" - value += line + "\n" - if value: - values.append(value) - data.add_field(name=item_doc["name"], - value="```ml\n{}```".format(values[0]), - inline=False) - for v in values[1:]: - data.add_field( - name=u'\u200b', # Zero width space - value="```ml\n{}```".format(v), - inline=False) - else: - data.add_field(name=item_doc["name"], - value="```ml\n{}\n```".format(value)) - data.set_author(name=doc["account_name"], - icon_url=user.display_avatar.url) - if 'is_upgrade' in item_doc and item_doc['is_upgrade']: - data.set_footer(text="Amount in inventory / Amount in gear", - icon_url=self.bot.user.display_avatar.url) - else: - data.set_footer(text=self.bot.user.name, - icon_url=self.bot.user.display_avatar.url) - data.set_thumbnail(url=icon_url) - return data - - user = interaction.user - doc = await self.fetch_key(user, ["inventories", "characters"]) - endpoints = [ - "account/bank", "account/inventory", "account/materials", - "characters?page=0&page_size=200" - ] - task = asyncio.create_task( - self.call_multiple(endpoints, - key=doc["key"], - schema_string="2021-07-15T13:00:00.000Z")) - storage = None - if (not task.done()): - storage = await task - if exc := task.exception(): - raise exc - search_results = await self.find_items_in_account(interaction.user, - ids, - flatten=True, - search=True, - results=storage) - embed = await generate_results_embed(search_results) - if not embed: - return await interaction.followup.send( - content=f"`{item_doc['name']}`: Not found on your account.") - return await interaction.followup.send(embed=embed) - - @app_commands.command() - async def cats(self, interaction: discord.Interaction): - """Displays the cats you haven't unlocked yet""" - await interaction.response.defer() - user = interaction.user - endpoint = "account/home/cats" - doc = await self.fetch_key(user, ["progression"]) - results = await self.call_api(endpoint, key=doc["key"]) - owned_cats = [cat["id"] for cat in results] - lines = [] - for cat in self.gamedata["cats"]: - if cat["id"] not in owned_cats: - lines.append(cat["guide"]) - if not lines: - return await interaction.followup.send( - "You have collected all the " - "cats! Congratulations! :cat2:") - embed = discord.Embed(color=await self.get_embed_color(interaction)) - embed = embed_list_lines(embed, lines, - "Cats you haven't collected yet") - embed.set_author(name=doc["account_name"], - icon_url=user.display_avatar.url) - embed.set_footer(text=self.bot.user.name, - icon_url=self.bot.user.display_avatar.url) - await interaction.followup.send(embed=embed) - - @app_commands.command() - async def nodes(self, interaction: discord.Interaction): - """Displays the home instance nodes you have not yet unlocked.""" - await interaction.response.defer() - user = interaction.user - endpoint = "account/home/nodes" - doc = await self.fetch_key(user, ["progression"]) - results = await self.call_api(endpoint, key=doc["key"]) - owned_nodes = results - lines = [] - for nodes in self.gamedata["nodes"]: - if nodes["id"] not in owned_nodes: - lines.append(nodes["guide"]) - if not lines: - return await interaction.followup.send( - "You've collected all home instance nodes! Congratulations!") - embed = discord.Embed(color=await self.get_embed_color(interaction)) - embed = embed_list_lines(embed, lines, - "Nodes you haven't collected yet:") - embed.set_author(name=doc["account_name"], - icon_url=user.display_avatar.url) - embed.set_footer(text=self.bot.user.name, - icon_url=self.bot.user.display_avatar.url) - await interaction.followup.send(embed=embed) - - async def boss_embed(self, ctx, raids, results, account_name, - last_modified): - boss_to_id = defaultdict(list) - for boss_id, boss in self.gamedata["bosses"].items(): - if "api_name" in boss: - boss_to_id[boss["api_name"]].append(int(boss_id)) - - def is_killed(boss): - return ":white_check_mark:" if boss["id"] in results else ":x:" - - def readable_id(_id): - _id = _id.split("_") - dont_capitalize = ("of", "the", "in") - title = " ".join([ - x.capitalize() if x not in dont_capitalize else x for x in _id - ]) - return title[0].upper() + title[1:] - - monday = last_modified - datetime.timedelta( - days=last_modified.weekday()) - if not last_modified.weekday(): - if last_modified < last_modified.replace(hour=7, - minute=30, - second=0, - microsecond=0): - monday = last_modified - datetime.timedelta(weeks=1) - reset_time = datetime.datetime(monday.year, - monday.month, - monday.day, - hour=7, - minute=30) - next_reset_time = reset_time + datetime.timedelta(weeks=1) - - async def get_dps_reports(boss_id): - boss_id = boss_to_id[boss_id] - cursor = self.db.encounters.find({ - "boss_id": { - "$in": boss_id - }, - "players": account_name, - "date": { - "$gte": reset_time, - "$lt": next_reset_time - }, - "success": boss["id"] in results - }).sort("date", pymongo.DESCENDING).limit(5) - return await cursor.to_list(None) - - not_completed = [] - embed = discord.Embed(title="Bosses", - color=await self.get_embed_color(ctx)) - wings = [wing for raid in raids for wing in raid["wings"]] - cotm = self.get_emoji(ctx, "call_of_the_mists") - emboldened = self.get_emoji(ctx, "emboldened") - start_date = datetime.date(year=2022, month=6, day=20) - current = datetime.datetime.utcnow().date() - monday_1 = (start_date - datetime.timedelta(days=start_date.weekday())) - monday_2 = (current - datetime.timedelta(days=current.weekday())) - weeks = (monday_2 - monday_1).days // 7 - cotm_index = weeks % len(wings) - emboldened_index = (weeks - 1) % len(wings) - for index, wing in enumerate(wings): - wing_done = True - value = [] - for boss in wing["events"]: - if boss["id"] not in results: - wing_done = False - not_completed.append(boss) - reports = await get_dps_reports(boss["id"]) - if reports: - boss_name = (f"[{readable_id(boss['id'])}]" - f"({reports[0]['permalink']})") - if len(reports) > 1: - links = [] - for i, report in enumerate(reports[1:], 2): - links.append(f"[{i}]({report['permalink']})") - boss_name += f" ({', '.join(links)})" - else: - boss_name = readable_id(boss["id"]) - value.append("> " + is_killed(boss) + boss_name) - name = readable_id(wing["id"]) - if index == cotm_index: - name = f"{cotm}{name}" - elif index == emboldened_index: - name = f"{emboldened}{name}" - if wing_done: - name += " :white_check_mark:" - else: - name += " :x:" - embed.add_field(name=f"**{name}**", value="\n".join(value)) - if len(not_completed) == 0: - description = "Everything completed this week :star:" - else: - bosses = list(filter(lambda b: b["type"] == "Boss", not_completed)) - events = list( - filter(lambda b: b["type"] == "Checkpoint", not_completed)) - if bosses: - suffix = "" - if len(bosses) > 1: - suffix = "es" - bosses = "{} boss{}".format(len(bosses), suffix) - if events: - suffix = "" - if len(events) > 1: - suffix = "s" - events = "{} event{}".format(len(events), suffix) - description = (", ".join(filter(None, [bosses, events])) + - " not completed this week") - if datetime.datetime.utcnow() > next_reset_time: - description = description.replace("this", "that") - description += ("\n❗Warning❗\n Data outdated for this week. Log " - "into GW2 in order to update.") - embed.description = description - embed.set_footer(text="Logs uploaded via evtc will " - "appear here with links - they don't have to be " - "uploaded by you", - icon_url=self.bot.user.display_avatar.url) - return embed - - async def find_items_in_account(self, - user, - item_ids, - *, - doc=None, - flatten=False, - search=False, - results=None): if not doc: - doc = await self.fetch_key(user, ["inventories", "characters"]) + doc = await self.fetch_key( # type: ignore[attr-defined] + user, ["inventories", "characters"] + ) + endpoints = [ - "account/bank", "account/inventory", "account/materials", - "characters?page=0&page_size=200" + "account/bank", + "account/inventory", + "account/materials", + "characters?page=0&page_size=200", ] if not results: - results = await self.call_multiple( - endpoints, - key=doc["key"], - schema_string="2021-07-15T13:00:00.000Z") + results = await self.call_multiple( # type: ignore[attr-defined] + endpoints, key=doc["key"], schema_string="2021-07-15T13:00:00.000Z" + ) + bank, shared, materials, characters = results - spaces = { - "bank": bank, - "shared": shared, - "material storage": materials - } + spaces = {"bank": bank, "shared": shared, "material storage": materials} legendary_armory_item_ids = set() + + counts: Dict[int, DefaultDict[str, Any]] if search: - counts = { - item_id: defaultdict(lambda: [0, 0]) - for item_id in item_ids - } + counts = {item_id: defaultdict(lambda: [0, 0]) for item_id in item_ids} else: counts = {item_id: defaultdict(int) for item_id in item_ids} - def amounts_in_space(space, name, geared): - space_name = name - for s in space: - if geared and s["location"].endswith("LegendaryArmory"): - if s["id"] in legendary_armory_item_ids: - continue - legendary_armory_item_ids.add(s["id"]) - space_name = "legendary armory" - for item_id in item_ids: - amt = get_amount(s, item_id) - if amt: - if search: - if geared: - # Tuple of (inventory, geared) - counts[item_id][space_name][1] += amt - else: - counts[item_id][space_name][0] += amt - else: - counts[item_id][space_name] += amt - def get_amount(slot, item_id): - def count_upgrades(slots): return sum(1 for i in slots if i == item_id) if not slot: return 0 - if slot["id"] == item_id: - if "count" in slot: - return slot["count"] - return 1 - + if slot.get("id") == item_id: + return int(slot.get("count", 1)) if "infusions" in slot: - infusions_sum = count_upgrades(slot["infusions"]) + infusions_sum = count_upgrades(slot["infusions"]) # type: ignore[index] if infusions_sum: return infusions_sum if "upgrades" in slot: - upgrades_sum = count_upgrades(slot["upgrades"]) + upgrades_sum = count_upgrades(slot["upgrades"]) # type: ignore[index] if upgrades_sum: return upgrades_sum return 0 + def amounts_in_space(space, name, geared): + space_name = name + for s in space or []: + if geared and str(s.get("location", "")).endswith("LegendaryArmory"): + if s.get("id") in legendary_armory_item_ids: + continue + legendary_armory_item_ids.add(s.get("id")) + space_name = "legendary armory" + for item_id in item_ids: + amt = get_amount(s, item_id) + if amt: + if search: + lst = cast(List[int], counts[item_id][space_name]) + if geared: + lst[1] += amt + else: + lst[0] += amt + else: + prev = cast(int, counts[item_id][space_name]) + counts[item_id][space_name] = prev + amt + for name, space in spaces.items(): amounts_in_space(space, name, False) for character in characters: - amounts_in_space(character["bags"], character["name"], False) + amounts_in_space(character.get("bags", []), character.get("name"), False) bags = [ - bag["inventory"] for bag in filter(None, character["bags"]) + bag.get("inventory") for bag in filter(None, character.get("bags", [])) ] for bag in bags: - amounts_in_space(bag, character["name"], False) - for tab in character["equipment_tabs"]: - amounts_in_space(tab["equipment"], character["name"], True) + amounts_in_space(bag, character.get("name"), False) + for tab in character.get("equipment_tabs", []): + amounts_in_space(tab.get("equipment", []), character.get("name"), True) + try: - if "tradingpost" in doc["permissions"]: - result = await self.call_api("commerce/delivery", - key=doc["key"]) + if "tradingpost" in doc.get("permissions", []): + result = await self.call_api( # type: ignore[attr-defined] + "commerce/delivery", key=doc["key"] + ) delivery = result.get("items", []) amounts_in_space(delivery, "TP delivery", False) except APIError: pass + if flatten: if search: - flattened = defaultdict(lambda: []) + flattened: Dict[str, Any] = defaultdict(lambda: []) else: - flattened = defaultdict(int) + flattened = defaultdict(int) # type: ignore[assignment] for count_dict in counts.values(): for k, v in count_dict.items(): flattened[k] += v return flattened + return counts diff --git a/guildwars2/adapters/__init__.py b/guildwars2/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/guildwars2/adapters/discord/__init__.py b/guildwars2/adapters/discord/__init__.py new file mode 100644 index 0000000..2da6cd2 --- /dev/null +++ b/guildwars2/adapters/discord/__init__.py @@ -0,0 +1 @@ +"""Discord adapter: Cog(s) and UI components.""" diff --git a/guildwars2/adapters/discord/cog.py b/guildwars2/adapters/discord/cog.py new file mode 100644 index 0000000..98456ba --- /dev/null +++ b/guildwars2/adapters/discord/cog.py @@ -0,0 +1,49 @@ +import logging + +import discord +from discord import app_commands + +from ...core.api_client import GW2ApiClient +from ...core.services.account import AccountService +from ...data.repositories import Gw2Repository + + +class ServiceContainer: + def __init__(self, bot): + self.bot = bot + self.log = logging.getLogger(__name__) + self.api = GW2ApiClient(session=bot.session, logger=self.log) + # Reuse existing db handle from your current Cog + self.repo = Gw2Repository(bot.database.db.gw2) + # utilities shim; you can inject specific helpers if needed + self.utils = {} + self._account_service = None + + @property + def account_service(self) -> AccountService: + if not self._account_service: + self._account_service = AccountService(self.api, self.repo, self.utils) + return self._account_service + + +class GuildWars2(discord.ext.commands.Cog): + """Facade Cog composed of services. + + For incremental migration: keep the name and setup signature identical + to your existing extension entry point if you want to swap it in later. + """ + + def __init__(self, bot): + self.bot = bot + self.container = ServiceContainer(bot) + self.embed_color = 0xC12D2B + + async def get_embed_color(self, interaction: discord.Interaction) -> int: + if not hasattr(interaction, "user"): + return self.embed_color + doc = await self.bot.database.users.find_one( + {"_id": interaction.user.id}, {"embed_color": 1, "_id": 0} + ) + if doc and doc.get("embed_color"): + return int(doc["embed_color"], 16) + return self.embed_color diff --git a/guildwars2/adapters/discord/commands/__init__.py b/guildwars2/adapters/discord/commands/__init__.py new file mode 100644 index 0000000..c8fdbf5 --- /dev/null +++ b/guildwars2/adapters/discord/commands/__init__.py @@ -0,0 +1,219 @@ +"""Auto-discovery and registration for Discord command handlers. + +Preferred usage options: + +- Define commands with `@discord.app_commands.command` in modules under this + package; the loader will import submodules and add any `app_commands.Command` + or `app_commands.Group` objects it finds to the bot's command tree. + +Legacy mode: modules can also export a `register(cog)` function, which will +be invoked to perform custom registration if needed. +""" + +from __future__ import annotations + +import importlib +import pkgutil +from types import ModuleType +from typing import ( + Callable, + Iterable, + Optional, + TypeVar, + ParamSpec, + Concatenate, + Any, + cast, + Coroutine, +) + +from discord import app_commands, Interaction +import functools +import inspect + + +def _iter_submodules(pkg_name: str) -> Iterable[str]: + pkg = importlib.import_module(pkg_name) + if not hasattr(pkg, "__path__"): + return [] + for mod in pkgutil.walk_packages(pkg.__path__, pkg_name + "."): + yield mod.name + + +def register_all(cog) -> None: + """Find and register all command modules under this package. + + Command modules should expose `register(cog)` or `bind(cog)`. + """ + base = __name__ # e.g., guildwars2.adapters.discord.commands + for name in list(_iter_submodules(base)): + try: + mod: ModuleType = importlib.import_module(name) + except Exception: + continue + registrar: Callable | None = None + for attr in ("register", "bind"): + fn = getattr(mod, attr, None) + if callable(fn): + registrar = fn + break + if registrar: + try: + registrar(cog) + except Exception: + # Ignore failures to avoid breaking other commands + continue + + # Determine target group for this module, if any + group_target = None + group_name = getattr(mod, "__group__", None) + if isinstance(group_name, str) and group_name: + group_desc = getattr(mod, "__group_description__", None) + try: + group_target = ensure_group(cog, group_name, description=group_desc) + except Exception: + group_target = None + else: + # Fallback: if module is part of a package that exposes a 'group', use it + try: + pkg_name = mod.__package__ + if pkg_name: + pkg = importlib.import_module(pkg_name) + pkg_group = getattr(pkg, "group", None) + if isinstance(pkg_group, app_commands.Group): + # Make sure tree has it and cache on cog + try: + cog.bot.tree.add_command(pkg_group) + except Exception: + pass + group_target = pkg_group + except Exception: + group_target = None + + # Direct app_commands discovery: add commands and groups declared in module + for attr_name in dir(mod): + obj = getattr(mod, attr_name) + # Skip the exported package-level group attribute itself + if attr_name == "group" and isinstance(obj, app_commands.Group): + continue + try: + if isinstance(obj, app_commands.Command): + if group_target: + try: + group_target.add_command(obj) + except Exception: + pass + else: + cog.bot.tree.add_command(obj) + elif isinstance(obj, app_commands.Group): + if group_target: + # Avoid adding the package-level group to itself + if obj is group_target: + continue + try: + group_target.add_command(obj) + except Exception: + pass + else: + cog.bot.tree.add_command(obj) + except Exception: + # Ignore duplicates or timing issues + continue + + +def ensure_group( + cog, name: str, description: Optional[str] = None +) -> app_commands.Group: + """Ensure an app_commands.Group exists on the Cog and in the command tree. + + - Reuses an attribute on the cog named `_group` if present and valid + - Otherwise tries to fetch an existing group from the command tree + - If not found, creates and registers a new group and caches it on the cog + """ + attr = f"{name}_group" + existing = getattr(cog, attr, None) + if isinstance(existing, app_commands.Group): + return existing + # Try to find an existing group in the tree + try: + cmd = cog.bot.tree.get_command(name=name) + if isinstance(cmd, app_commands.Group): + setattr(cog, attr, cmd) + return cmd + except Exception: + pass + # Create a new group and add it to the tree + group = app_commands.Group( + name=name, description=description or f"{name.title()} commands" + ) + try: + cog.bot.tree.add_command(group) + except Exception: + # If already exists, fetch and reuse + try: + cmd = cog.bot.tree.get_command(name=name) + if isinstance(cmd, app_commands.Group): + group = cmd + except Exception: + pass + setattr(cog, attr, group) + return group + + +P = ParamSpec("P") +T = TypeVar("T") + + +def with_cog( + cog_name: str = "GuildWars2", +) -> Callable[ + [Callable[Concatenate[Any, Interaction[Any], P], Coroutine[Any, Any, T]]], + Callable[Concatenate[Interaction[Any], P], Coroutine[Any, Any, T]], +]: + """Type-safe decorator to inject the named Cog into an app_command callback. + + Usage: + @app_commands.command(name="foo") + @with_cog() + async def foo(cog, interaction, ...): ... + + This transforms a function of shape (cog, interaction, *args) -> Awaitable[T] + into (interaction, *args) -> Awaitable[T] so app_commands sees the correct + Interaction-first signature. Keep this decorator closest to the function. + """ + + def _decorator( + func: Callable[Concatenate[Any, Interaction[Any], P], Coroutine[Any, Any, T]], + ) -> Callable[Concatenate[Interaction[Any], P], Coroutine[Any, Any, T]]: + @functools.wraps(func) + async def _wrapped( + interaction: Interaction[Any], *args: P.args, **kwargs: P.kwargs + ) -> T: + get = getattr(interaction.client, "get_cog", None) + cog = get(cog_name) if callable(get) else None + if not cog: + try: + await interaction.response.send_message( + f"{cog_name} cog is not loaded.", ephemeral=True + ) + except Exception: + pass + return cast(T, None) + return await func(cog, interaction, *args, **kwargs) + + # Adjust visible signature: drop the first param (cog) + try: + orig_sig = inspect.signature(func) + params = list(orig_sig.parameters.values()) + if params: + params = params[1:] + new_sig = inspect.Signature( + parameters=params, return_annotation=orig_sig.return_annotation + ) + setattr(_wrapped, "__signature__", new_sig) + except Exception: + pass + + return _wrapped + + return _decorator diff --git a/guildwars2/adapters/discord/commands/account/__init__.py b/guildwars2/adapters/discord/commands/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/guildwars2/adapters/discord/commands/account/bosses.py b/guildwars2/adapters/discord/commands/account/bosses.py new file mode 100644 index 0000000..1d5064d --- /dev/null +++ b/guildwars2/adapters/discord/commands/account/bosses.py @@ -0,0 +1,144 @@ +import datetime + +import discord +from discord import app_commands + +from .. import with_cog + + +@app_commands.command( + name="bosses", description="Shows your raid progression for the week" +) +@with_cog() +async def account_bosses(cog, interaction: discord.Interaction): + await interaction.response.defer() + user = interaction.user + scopes = ["progression"] + endpoints = ["account/raids", "account"] + doc = await cog.fetch_key(user, scopes) # type: ignore[attr-defined] + schema = datetime.datetime(2019, 2, 21) + results, account = await cog.call_multiple( # type: ignore[attr-defined] + endpoints, key=doc["key"], schema_version=schema + ) + last_modified = datetime.datetime.strptime( + account["last_modified"], "%Y-%m-%dT%H:%M:%Sz" + ) + raids = await cog.get_raids() # type: ignore[attr-defined] + + # Build the weekly bosses embed (inlined from previous mixin) + from collections import defaultdict + + boss_to_id = defaultdict(list) + for boss_id, boss in cog.gamedata["bosses"].items(): + if "api_name" in boss: + boss_to_id[boss["api_name"]].append(int(boss_id)) + + def is_killed(boss): + return ":white_check_mark:" if boss["id"] in results else ":x:" + + def readable_id(_id: str) -> str: + parts = _id.split("_") + dont_capitalize = ("of", "the", "in") + title = " ".join( + [p.capitalize() if p not in dont_capitalize else p for p in parts] + ) + return title[0].upper() + title[1:] + + monday = last_modified - datetime.timedelta(days=last_modified.weekday()) + if not last_modified.weekday(): + if last_modified < last_modified.replace( + hour=7, minute=30, second=0, microsecond=0 + ): + monday = last_modified - datetime.timedelta(weeks=1) + reset_time = datetime.datetime( + monday.year, monday.month, monday.day, hour=7, minute=30 + ) + next_reset_time = reset_time + datetime.timedelta(weeks=1) + + async def get_dps_reports(boss_id: str): + ids = boss_to_id[boss_id] + cursor = ( + cog.db.encounters.find( # type: ignore[attr-defined] + { + "boss_id": {"$in": ids}, + "players": doc["account_name"], + "date": {"$gte": reset_time, "$lt": next_reset_time}, + "success": boss["id"] in results, # type: ignore[name-defined] + } + ) + .sort("date", -1) + .limit(5) + ) + return await cursor.to_list(None) + + not_completed = [] + embed = discord.Embed(title="Bosses", color=await cog.get_embed_color(interaction)) + wings = [wing for raid in raids for wing in raid["wings"]] + cotm = cog.get_emoji(interaction, "call_of_the_mists") + emboldened = cog.get_emoji(interaction, "emboldened") + start_date = datetime.date(year=2022, month=6, day=20) + current = datetime.datetime.utcnow().date() + monday_1 = start_date - datetime.timedelta(days=start_date.weekday()) + monday_2 = current - datetime.timedelta(days=current.weekday()) + weeks = (monday_2 - monday_1).days // 7 + cotm_index = weeks % len(wings) + emboldened_index = (weeks - 1) % len(wings) + for index, wing in enumerate(wings): + wing_done = True + value = [] + for boss in wing["events"]: + if boss["id"] not in results: + wing_done = False + not_completed.append(boss) + reports = await get_dps_reports(boss["id"]) + if reports: + boss_name = f"[{readable_id(boss['id'])}]({reports[0]['permalink']})" + if len(reports) > 1: + links = [] + for i, report in enumerate(reports[1:], 2): + links.append(f"[{i}]({report['permalink']})") + boss_name += f" ({', '.join(links)})" + else: + boss_name = readable_id(boss["id"]) + value.append("> " + is_killed(boss) + boss_name) + name = readable_id(wing["id"]) + if index == cotm_index: + name = f"{cotm}{name}" + elif index == emboldened_index: + name = f"{emboldened}{name}" + if wing_done: + name += " :white_check_mark:" + else: + name += " :x:" + embed.add_field(name=f"**{name}**", value="\n".join(value)) + if len(not_completed) == 0: + description = "Everything completed this week :star:" + else: + bosses_list = list(filter(lambda b: b["type"] == "Boss", not_completed)) + events_list = list(filter(lambda b: b["type"] == "Checkpoint", not_completed)) + bosses_str = None + events_str = None + if bosses_list: + suffix = "es" if len(bosses_list) > 1 else "" + bosses_str = f"{len(bosses_list)} boss{suffix}" + if events_list: + suffix = "s" if len(events_list) > 1 else "" + events_str = f"{len(events_list)} event{suffix}" + parts = [p for p in [bosses_str, events_str] if p] + description = ", ".join(parts) + " not completed this week" + if datetime.datetime.utcnow() > next_reset_time: + description = description.replace("this", "that") + description += ( + "\n❗Warning❗\n Data outdated for this week. Log into GW2 in order to" + " update." + ) + embed.description = description + embed.set_footer( + text=( + "Logs uploaded via evtc will appear here with links - " + "they don't have to be uploaded by you" + ), + icon_url=cog.bot.user.display_avatar.url, + ) + embed.set_author(name=doc["account_name"], icon_url=user.display_avatar.url) + await interaction.followup.send(embed=embed) diff --git a/guildwars2/adapters/discord/commands/account/cats.py b/guildwars2/adapters/discord/commands/account/cats.py new file mode 100644 index 0000000..cb8b0b1 --- /dev/null +++ b/guildwars2/adapters/discord/commands/account/cats.py @@ -0,0 +1,32 @@ +import discord +from discord import app_commands + +from .. import with_cog + + +@app_commands.command( + name="cats", description="Displays the cats you haven't unlocked yet" +) +@with_cog() +async def account_cats(cog, interaction: discord.Interaction): + await interaction.response.defer() + user = interaction.user + endpoint = "account/home/cats" + doc = await cog.fetch_key(user, ["progression"]) # type: ignore[attr-defined] + results = await cog.call_api(endpoint, key=doc["key"]) # type: ignore[attr-defined] + owned_cats = [cat["id"] for cat in results] + lines = [] + for cat in cog.gamedata["cats"]: + if cat["id"] not in owned_cats: + lines.append(cat["guide"]) + if not lines: + return await interaction.followup.send( + "You have collected all the cats! Congratulations! :cat2:" + ) + embed = discord.Embed(color=await cog.get_embed_color(interaction)) + embed = embed + embed.title = "Cats you haven't collected yet" + embed.description = "\n".join(lines) + embed.set_author(name=doc["account_name"], icon_url=user.display_avatar.url) + embed.set_footer(text=cog.bot.user.name, icon_url=cog.bot.user.display_avatar.url) + await interaction.followup.send(embed=embed) diff --git a/guildwars2/adapters/discord/commands/account/kp.py b/guildwars2/adapters/discord/commands/account/kp.py new file mode 100644 index 0000000..c1fdfd1 --- /dev/null +++ b/guildwars2/adapters/discord/commands/account/kp.py @@ -0,0 +1,82 @@ +from itertools import chain + +import discord +from discord import app_commands + +from .. import with_cog +from .....exceptions import APIError, APINotFound + + +@app_commands.command( + name="kp", + description="Shows completed raids, fractals, strikes, and important challenges", +) +@with_cog() +async def account_kp(cog, interaction: discord.Interaction): + await interaction.response.defer() + user = interaction.user + scopes = ["progression"] + areas = cog.gamedata["killproofs"]["areas"] + achievement_ids = [ + ( + [x["id"]] + if x["type"] in ("single_achievement", "progressed_achievement") + else x["ids"] + ) + for x in chain.from_iterable([area["encounters"] for area in areas]) + ] + achievement_ids = [str(x) for x in chain.from_iterable(achievement_ids)] + try: + doc = await cog.fetch_key(user, scopes) # type: ignore[attr-defined] + endpoint = "account/achievements?ids=" + ",".join(achievement_ids) + results = await cog.call_api( # type: ignore[attr-defined] + endpoint, key=doc["key"] + ) + except APINotFound: + results = [] + doc = None + except APIError: + raise + + def is_completed(encounter): + if encounter["type"] == "single_achievement": + _id = encounter["id"] + for achievement in results: + if achievement["id"] == _id and achievement["done"]: + return "+✔" + return "-✖" + if encounter["type"] == "all_achievements": + for _id in encounter["ids"]: + if not any(a["id"] == _id and a["done"] for a in results): + return "-✖" + return "+✔" + if encounter["type"] == "progressed_achievement": + _id = encounter["id"] + _progress = encounter["progress"] + for achievement in results: + if achievement["id"] == _id and achievement["current"] >= _progress: + return "+✔" + return "-✖" + + embed = discord.Embed( + title="Kill Proof", color=await cog.get_embed_color(interaction) + ) + if doc: + embed.set_author(name=doc["account_name"], icon_url=user.display_avatar.url) + for area in areas: + value = ["```diff"] + encounters = area["encounters"] + for encounter in encounters: + value.append(is_completed(encounter) + encounter["name"]) + value.append("```") + embed.add_field(name=area["name"], value="\n".join(value)) + + embed.description = "Achievements were checked to find completed encounters." + embed.set_footer( + text=( + "Green (+) means completed. Red (-) means not. " + "CM stands for Challenge Mode." + ) + ) + + await interaction.followup.send(embed=embed) diff --git a/guildwars2/adapters/discord/commands/account/li.py b/guildwars2/adapters/discord/commands/account/li.py new file mode 100644 index 0000000..b4054a8 --- /dev/null +++ b/guildwars2/adapters/discord/commands/account/li.py @@ -0,0 +1,90 @@ +import discord +from discord import app_commands + +from .. import with_cog + + +@app_commands.command( + name="li", description="Shows how many Legendary Insights you have earned" +) +@with_cog() +async def account_li(cog, interaction: discord.Interaction): + await interaction.response.defer() + user = interaction.user + scopes = ["inventories", "characters", "wallet"] + trophies = cog.gamedata["raid_trophies"] + ids = [] + for trophy in trophies: + for items in trophy["items"]: + ids += items["items"] + doc = await cog.fetch_key(user, scopes) # type: ignore[attr-defined] + search_results = await cog.container.inventory_service.find_items_in_account( + key=doc["key"], item_ids=ids, doc=doc + ) + wallet = await cog.container.api.get( # type: ignore[attr-defined] + "account/wallet", key=doc["key"] + ) + embed = discord.Embed(color=0x4C139D) + total = 0 + crafted_total = 0 + for trophy in trophies: + trophy_total = 0 + breakdown = [] + if trophy["wallet"]: + for currency_result in wallet: + if currency_result["id"] == trophy["wallet"]: + trophy_total += currency_result["value"] + breakdown.append(f"Wallet - **{currency_result['value']}**") + break + for group in trophy["items"]: + if "reduced_worth" in group: + upgraded_sum = 0 + if "upgrades_to" in group: + upgrade_dict = next( + item + for item in trophy["items"] + if item.get("name") == group["upgrades_to"] + ) + for item in upgrade_dict["items"]: + upgraded_sum += sum(search_results[item].values()) + amount = 0 + for item in group["items"]: + amount += sum(search_results[item].values()) + reduced_amount = group["reduced_amount"] + reduced_amount = max(reduced_amount - upgraded_sum, 0) + sum_set = ( + min(amount, reduced_amount) * group["reduced_worth"] + + max(amount - reduced_amount, 0) * group["worth"] + ) + if sum_set: + trophy_total += sum_set + breakdown.append(f"{amount} {group['name']} - **{sum_set}**") + if group["crafted"]: + crafted_total += sum_set + continue + for item in group["items"]: + amount = sum(search_results[item].values()) + sum_item = amount * group["worth"] + if sum_item: + trophy_total += sum_item + if group["crafted"]: + crafted_total += sum_item + item_doc = await cog.fetch_item(item) # type: ignore[attr-defined] + line = f"{item_doc['name']} - **{sum_item}**" + if group["worth"] != 1: + line = f"{amount} " + line + breakdown.append(line) + if trophy_total: + name = f"{trophy_total} Legendary {trophy['name']} earned" + embed.add_field(name=name, value="\n".join(breakdown), inline=False) + total += trophy_total + embed.title = f"{total} Raid trophies earned".format(total) + embed.description = "{} on hand, {} used in crafting".format( + total - crafted_total, crafted_total + ) + embed.set_author(name=doc["account_name"], icon_url=user.display_avatar.url) + embed.set_thumbnail( + url="https://wiki.guildwars2.com/images/5/5e/Legendary_Insight.png" + ) + embed.set_footer(text=cog.bot.user.name, icon_url=cog.bot.user.display_avatar.url) + await interaction.followup.send(embed=embed) diff --git a/guildwars2/adapters/discord/commands/account/nodes.py b/guildwars2/adapters/discord/commands/account/nodes.py new file mode 100644 index 0000000..58002a7 --- /dev/null +++ b/guildwars2/adapters/discord/commands/account/nodes.py @@ -0,0 +1,32 @@ +import discord +from discord import app_commands + +from .. import with_cog + + +@app_commands.command( + name="nodes", + description="Displays the home instance nodes you have not yet unlocked.", +) +@with_cog() +async def account_nodes(cog, interaction: discord.Interaction): + await interaction.response.defer() + user = interaction.user + endpoint = "account/home/nodes" + doc = await cog.fetch_key(user, ["progression"]) # type: ignore[attr-defined] + results = await cog.call_api(endpoint, key=doc["key"]) # type: ignore[attr-defined] + owned_nodes = results + lines = [] + for nodes in cog.gamedata["nodes"]: + if nodes["id"] not in owned_nodes: + lines.append(nodes["guide"]) + if not lines: + return await interaction.followup.send( + "You've collected all home instance nodes! Congratulations!" + ) + embed = discord.Embed(color=await cog.get_embed_color(interaction)) + embed.title = "Nodes you haven't collected yet:" + embed.description = "\n".join(lines) + embed.set_author(name=doc["account_name"], icon_url=user.display_avatar.url) + embed.set_footer(text=cog.bot.user.name, icon_url=cog.bot.user.display_avatar.url) + await interaction.followup.send(embed=embed) diff --git a/guildwars2/adapters/discord/commands/account/search.py b/guildwars2/adapters/discord/commands/account/search.py new file mode 100644 index 0000000..baaef66 --- /dev/null +++ b/guildwars2/adapters/discord/commands/account/search.py @@ -0,0 +1,195 @@ +import asyncio +from collections import OrderedDict +from typing import Any + +import discord +from discord import app_commands + +from .. import with_cog +from .....utils.db import prepare_search +from .....types import GuildWars2Cog + + +@with_cog() +async def item_autocomplete( + cog: GuildWars2Cog, interaction: discord.Interaction, current: str +): + if not current: + return [] + + def consolidate_duplicates(items): + unique_items = OrderedDict() + for item in items: + item_tuple = item["name"], item["rarity"], item["type"] + if item_tuple not in unique_items: + unique_items[item_tuple] = [] + unique_items[item_tuple].append(item["_id"]) + unique_list = [] + for k, v in unique_items.items(): + ids = " ".join(str(i) for i in v) + if len(ids) > 100: + continue + unique_list.append({"name": k[0], "rarity": k[1], "ids": ids, "type": k[2]}) + return unique_list + + query = prepare_search(current) + query = {"name": query} + items = await cog.db.items.find(query).to_list(25) + items = sorted(consolidate_duplicates(items), key=lambda c: c["name"]) + return [ + app_commands.Choice(name=f"{it['name']} - {it['rarity']}", value=it["ids"]) + for it in items + ] + + +@app_commands.command(name="search", description="Find items on your account") +@app_commands.describe( + item="Specify the name of an item to search for. Select an item from the list." +) +@app_commands.autocomplete(item=item_autocomplete) +@with_cog() +async def account_search( + cog: GuildWars2Cog, interaction: discord.Interaction, item: str +): + await interaction.response.defer() + try: + ids = [int(it) for it in item.split(" ")] + except ValueError: + try: + choices = await item_autocomplete(interaction, item) + ids = [int(it.value.split(" ")[0]) for it in choices] + except (ValueError, IndexError): + return await interaction.followup.send( + "Could not find any items with that name." + ) + item_doc = await cog.fetch_item(ids[0]) # type: ignore[attr-defined] + + async def generate_results_embed(results: dict[str, Any]): + seq = [k for k, v in results.items() if v] + if not seq: + return None + longest = len(max(seq, key=len)) + if longest < 8: + longest = 8 + if "is_upgrade" in item_doc and item_doc["is_upgrade"]: + output = [ + "LOCATION{}INV / GEAR".format(" " * (longest - 5)), + "--------{}|-----".format("-" * (longest - 6)), + ] + align = 110 + else: + output = [ + "LOCATION{}COUNT".format(" " * (longest - 5)), + "--------{}|-----".format("-" * (longest - 6)), + ] + align = 80 + total = 0 + storage_counts = OrderedDict( + sorted(results.items(), key=lambda kv: kv[1], reverse=True) + ) + # Fetch character names via service to avoid relying on Cog methods + try: + doc_local = doc # capture from outer scope + chars = await cog.container.characters_service.get_all_characters( + key=doc_local["key"] # type: ignore[index] + ) + char_names = [c.get("name", "") for c in chars] + except Exception: + char_names = [] + for k, v in storage_counts.items(): + if v: + if "is_upgrade" in item_doc and item_doc["is_upgrade"]: + total += v[0] + total += v[1] + if k in char_names: + slotted_upg = v[1] + inf = "" if slotted_upg == 0 else f"/ {slotted_upg} " + output.append( + f"{k.upper()} {' ' * (longest - len(k))} | {v[0]} {inf}" + ) + else: + output.append( + f"{k.upper()} {' ' * (longest - len(k))} | {v[0]}" + ) + else: + total += v[0] + total += v[1] + output.append( + f"{k.upper()} {' ' * (longest - len(k))} | {v[0] + v[1]}" + ) + output.append("--------{}------".format("-" * (longest - 5))) + output.append("TOTAL:{}{}".format(" " * (longest - 2), total)) + color = int(cog.gamedata["items"]["rarity_colors"][item_doc["rarity"]], 16) + icon_url = item_doc["icon"] + data = discord.Embed( + description="Search results" + "\u00a0" * align + "\u200b", color=color + ) + value = "\n".join(output) + + if len(value) > 1014: + value = "" + values = [] + for line in output: + if len(value) + len(line) > 1013: + values.append(value) + value = "" + value += line + "\n" + if value: + values.append(value) + data.add_field( + name=item_doc["name"], value=f"```ml\n{values[0]}```", inline=False + ) + for v in values[1:]: + data.add_field(name="\u200b", value=f"```ml\n{v}```", inline=False) + else: + data.add_field(name=item_doc["name"], value=f"```ml\n{value}\n```") + data.set_author( + name=doc["account_name"], # type: ignore[name-defined] + icon_url=interaction.user.display_avatar.url, + ) + if "is_upgrade" in item_doc and item_doc["is_upgrade"]: + data.set_footer( + text="Amount in inventory / Amount in gear", + icon_url=cog.bot.user.display_avatar.url, + ) + else: + data.set_footer( + text=cog.bot.user.name, icon_url=cog.bot.user.display_avatar.url + ) + data.set_thumbnail(url=icon_url) + return data + + user = interaction.user + doc = await cog.fetch_key( # type: ignore[attr-defined] + user, ["inventories", "characters"] + ) + endpoints = [ + "account/bank", + "account/inventory", + "account/materials", + "characters?page=0&page_size=200", + ] + task = asyncio.create_task( + cog.container.api.get_many( + endpoints, key=doc["key"], schema_string="2021-07-15T13:00:00.000Z" + ) + ) + storage = None + if not task.done(): + storage = await task + if exc := task.exception(): + raise exc + search_results = await cog.container.inventory_service.find_items_in_account( + key=doc["key"], + item_ids=ids, + doc=doc, + flatten=True, + search=True, + results=storage, + ) + embed = await generate_results_embed(search_results) + if not embed: + return await interaction.followup.send( + content=f"`{item_doc['name']}`: Not found on your account." + ) + return await interaction.followup.send(embed=embed) diff --git a/guildwars2/adapters/discord/commands/account/summary.py b/guildwars2/adapters/discord/commands/account/summary.py new file mode 100644 index 0000000..3801960 --- /dev/null +++ b/guildwars2/adapters/discord/commands/account/summary.py @@ -0,0 +1,109 @@ +import re + +import discord +from discord import app_commands +from .. import with_cog +from .....types import GuildWars2Cog + + +@app_commands.command( + name="account", description="General information about your account" +) +@with_cog() +async def account_summary(cog: GuildWars2Cog, interaction: discord.Interaction): + """Implements the /account summary command; called by the Cog method.""" + await interaction.response.defer() + user = interaction.user + container = cog.container + + # Centralized key retrieval and scope validation + key_doc = await container.keys_service.get_key_for_user( + user, required_scopes=["account"], cog=cog + ) + + data = await container.account_service.get_account(key=key_doc["key"]) + + embed = discord.Embed(colour=await cog.get_embed_color(interaction)) + + # Created date + created_raw = data.get("created") + if created_raw: + created = created_raw.split("T", 1)[0] + embed.add_field(name="Created account on", value=created) + + # WvW server + wid = data.get("world") + if wid: + world = await cog.get_world_name(wid) + if world: + embed.add_field(name="WvW Server", value=world) + + # Commander tag + has_commander = "Yes" if data.get("commander") else "No" + embed.add_field(name="Commander tag", value=has_commander, inline=False) + + # Fractal level and WvW rank + if "fractal_level" in data: + embed.add_field(name="Fractal level", value=data["fractal_level"]) + if "wvw_rank" in data: + embed.add_field(name="WvW rank", value=data["wvw_rank"]) + + # PvP rank if permission present + if "pvp" in key_doc.get("permissions", []): + pvp = await container.account_service.get_pvp_stats(key=key_doc["key"]) + pvprank = pvp.get("pvp_rank", 0) + pvp.get("pvp_rank_rollovers", 0) + embed.add_field(name="PVP rank", value=pvprank) + + # Optional enrichments via permissions + try: + if "progression" in key_doc.get("permissions", []): + ach_res, acc_res = await container.api.get_many( + ["account/achievements", "account"], key=key_doc["key"] + ) + possible_ap = await container.achievements_service.total_possible_ap() + user_ap = await container.achievements_service.calculate_user_ap( + ach_res, acc_res + ) + embed.add_field( + name="Achievement Points", + value=f"{user_ap} earned out of {possible_ap} possible", + inline=False, + ) + + if "characters" in key_doc.get("permissions", []): + chars = await container.characters_service.get_all_characters( + key=key_doc["key"] + ) + total_played = container.characters_service.total_play_time(chars) + embed.add_field( + name="Total time played", + value=cog.format_age(total_played), + inline=False, + ) + except Exception: + pass + + # Expansion access + access = data.get("access") + if access: + access_list = list(access) + if len(access_list) > 1: + for d in ("PlayForFree", "GuildWars2"): + if d in access_list: + access_list.remove(d) + if "PathOfFire" in access_list and "HeartOfThorns" not in access_list: + access_list.append("HeartOfThorns") + + def format_name(name): + return " ".join(re.findall(r"[A-Z\d][^A-Z\d]*", name)) + + access_str = "\n".join(format_name(e) for e in access_list) + if access_str: + embed.add_field(name="Expansion access", value=access_str) + + embed.set_author(name=key_doc["account_name"], icon_url=user.display_avatar.url) + embed.set_footer(text=cog.bot.user.name, icon_url=cog.bot.user.display_avatar.url) + await interaction.followup.send(embed=embed) + + +# No wrapper needed; loader auto-registers via the decorator above diff --git a/guildwars2/adapters/discord/commands/character/__init__.py b/guildwars2/adapters/discord/commands/character/__init__.py new file mode 100644 index 0000000..817504f --- /dev/null +++ b/guildwars2/adapters/discord/commands/character/__init__.py @@ -0,0 +1,6 @@ +from discord import app_commands + +# Shared group for all character-related slash commands +group = app_commands.Group(name="character", description="Character related commands") + +__all__ = ["group"] diff --git a/guildwars2/adapters/discord/commands/character/autocomplete.py b/guildwars2/adapters/discord/commands/character/autocomplete.py new file mode 100644 index 0000000..cf7a24c --- /dev/null +++ b/guildwars2/adapters/discord/commands/character/autocomplete.py @@ -0,0 +1,48 @@ +from typing import Any, cast +import asyncio +import datetime + +import discord +from discord.app_commands import Choice +from discord.ext import commands +from .. import with_cog + + +@with_cog() +async def character_autocomplete(cog, interaction: discord.Interaction, current: str): + """Autocomplete character names using cached list per account.""" + if not cog: + return [] + try: + key_doc = await cog.container.keys_service.get_key_for_user( + interaction.user, required_scopes=["characters"], cog=cog + ) + except Exception: + return [] + + account_key = key_doc["account_name"].replace(".", "_") + + async def cache_characters(): + try: + character_list = await cog.container.api.get( + "characters", key=key_doc["key"] + ) + except Exception: + return {"last_update": datetime.datetime.utcnow(), "characters": []} + c = {"last_update": datetime.datetime.utcnow(), "characters": character_list} + await cog.bot.database.set( + interaction.user, {f"character_cache.{account_key}": c}, cog + ) + return c + + doc = await cog.bot.database.get(interaction.user, cog) + cache = doc.get("character_cache", {}).get(account_key, {}) if doc else {} + if not cache: + cache = await cache_characters() + elif cache["last_update"] < datetime.datetime.utcnow() - datetime.timedelta(days=3): + asyncio.create_task(cache_characters()) + character_list = cache.get("characters", []) + current_l = (current or "").lower() + return [Choice(name=c, value=c) for c in character_list if current_l in c.lower()][ + :25 + ] diff --git a/guildwars2/adapters/discord/commands/character/birthdays.py b/guildwars2/adapters/discord/commands/character/birthdays.py new file mode 100644 index 0000000..51d2827 --- /dev/null +++ b/guildwars2/adapters/discord/commands/character/birthdays.py @@ -0,0 +1,65 @@ +from typing import Dict, List, Tuple + +import datetime +import discord +from discord import app_commands +from .. import with_cog +from .....types import GuildWars2Cog + +from .....utils.chat import embed_list_lines + + +@app_commands.command( + name="birthdays", description="Days until each character's next birthday" +) +@with_cog() +async def character_birthdays(cog: GuildWars2Cog, interaction: discord.Interaction): + await interaction.response.defer() + + def suffix(year: int) -> str: + return {1: "st", 2: "nd", 3: "rd"}.get(year, "th") + + doc = await cog.fetch_key( # type: ignore[attr-defined] + interaction.user, ["characters"] + ) + characters = await cog.container.characters_service.get_all_characters( + key=doc["key"] + ) + fields: Dict[int, List[Tuple[str, int]]] = {} + for character in characters: + created_str = character.get("created", "1970-01-01T00:00:00Z").split("T", 1)[0] + try: + created_dt = datetime.datetime.strptime(created_str, "%Y-%m-%d") + except ValueError: + # Fallback to full timestamp if date-only parse fails + try: + created_dt = datetime.datetime.strptime( + character.get("created", "1970-01-01T00:00:00Z"), + "%Y-%m-%dT%H:%M:%SZ", + ) + except ValueError: + created_dt = datetime.datetime.utcnow() + age = datetime.datetime.utcnow() - created_dt + days = age.days + floor = days // 365 + days_left = 365 - (days - (365 * floor)) + next_bd = floor + 1 + fields.setdefault(next_bd, []) + prof = await cog.container.characters_service.get_profession_by_character( + character, cog.gamedata + ) + label = f"{cog.get_emoji(interaction, prof['name'])} {character.get('name')}" + fields[next_bd].append((label, days_left)) + + embed = discord.Embed( + title="Days until...", colour=await cog.get_embed_color(interaction) + ) + embed.set_author( + name=doc["account_name"], icon_url=interaction.user.display_avatar.url + ) + for k, v in sorted(fields.items(), reverse=True, key=lambda item: item[0]): + lines = [ + f"{name}: **{days}**" for name, days in sorted(v, key=lambda pair: pair[1]) + ] + embed = embed_list_lines(embed, lines, f"{k}{suffix(k)} Birthday") + await interaction.followup.send(embed=embed) diff --git a/guildwars2/adapters/discord/commands/character/crafting.py b/guildwars2/adapters/discord/commands/character/crafting.py new file mode 100644 index 0000000..5b274b1 --- /dev/null +++ b/guildwars2/adapters/discord/commands/character/crafting.py @@ -0,0 +1,35 @@ +import discord +from discord import app_commands +from .. import with_cog +from .....types import GuildWars2Cog + + +@app_commands.command( + name="crafting", description="Displays your characters' crafting levels" +) +@with_cog() +async def character_crafting(cog: GuildWars2Cog, interaction: discord.Interaction): + await interaction.response.defer() + + endpoint = "characters?page=0&page_size=200" + doc = await cog.fetch_key( # type: ignore[attr-defined] + interaction.user, ["characters"] + ) + characters = await cog.call_api( # type: ignore[attr-defined] + endpoint, key=doc["key"] + ) + data = discord.Embed( + description="Crafting overview", colour=await cog.get_embed_color(interaction) + ) + data.set_author( + name=doc["account_name"], icon_url=interaction.user.display_avatar.url + ) + counter = 0 + for character in characters: + if counter == 25: + break + craft_list = cog.container.characters_service.get_crafting(character) + if craft_list: + data.add_field(name=character["name"], value="\n".join(craft_list)) + counter += 1 + await interaction.followup.send(embed=data) diff --git a/guildwars2/adapters/discord/commands/character/fashion.py b/guildwars2/adapters/discord/commands/character/fashion.py new file mode 100644 index 0000000..a832ad5 --- /dev/null +++ b/guildwars2/adapters/discord/commands/character/fashion.py @@ -0,0 +1,100 @@ +import discord +from discord import app_commands +from .. import with_cog + +from .....exceptions import APIError, APINotFound +from .autocomplete import character_autocomplete +from .....types import GuildWars2Cog + + +@app_commands.command( + name="fashion", description="Displays the fashion wars of a character" +) +@app_commands.autocomplete(character=character_autocomplete) +@app_commands.describe(character="Name of your character") +@with_cog() +async def character_fashion( + cog: GuildWars2Cog, interaction: discord.Interaction, character: str +): + await interaction.response.defer() + + # Fetch character via API using the user's key + try: + key_doc = await cog.container.keys_service.get_key_for_user( + interaction.user, required_scopes=["characters"], cog=cog + ) + results = await cog.container.characters_service.get_character_by_key( + name=character, key=key_doc["key"] + ) + except APINotFound: + return await interaction.followup.send("Invalid character name") + except APIError: + raise + + # Build gear info from equipment and DB + eq = [ + x + for x in results.get("equipment", []) + if str(x.get("location", "")).startswith("Equipped") + ] + pieces = [ + "Helm", + "Shoulders", + "Coat", + "Gloves", + "Leggings", + "Boots", + "Backpack", + "WeaponA1", + "WeaponA2", + "WeaponB1", + "WeaponB2", + ] + gear = {piece: {} for piece in pieces} + + profession = await cog.container.characters_service.get_profession_by_character( + results, cog.gamedata + ) + level = results.get("level", 0) + + for item in eq: + slot = item.get("slot") + if slot not in pieces: + continue + dye_ids = item.get("dyes", []) or [] + dyes = [] + for dye in dye_ids: + if dye: + doc = await cog.db.colors.find_one({"_id": dye}) + if doc: + dyes.append(doc.get("name")) + continue + dyes.append(None) + gear[slot]["dyes"] = dyes + skin = item.get("skin") + if skin: + doc = await cog.db.skins.find_one({"_id": skin}) + if doc: + gear[slot]["name"] = doc.get("name") + continue + doc = await cog.db.items.find_one({"_id": item.get("id")}) + if doc: + gear[slot]["name"] = doc.get("name") + + embed = discord.Embed(description="Fashion", colour=profession["color"]) + for piece in pieces: + info = gear.get(piece) or {} + if not info: + continue + value_lines = [] + for i, dye in enumerate(info.get("dyes", []), start=1): + if dye: + value_lines.append(f"Channel {i}: {dye}") + value = "\n".join(value_lines) or "\u200b" + embed.add_field(name=info.get("name", piece), value=value, inline=False) + embed.set_author(name=character) + embed.set_footer( + text=f"A level {level} {profession['name'].lower()}", + icon_url=profession["icon"], + ) + await interaction.followup.send(embed=embed) diff --git a/guildwars2/adapters/discord/commands/character/gear.py b/guildwars2/adapters/discord/commands/character/gear.py new file mode 100644 index 0000000..ae24e46 --- /dev/null +++ b/guildwars2/adapters/discord/commands/character/gear.py @@ -0,0 +1,370 @@ +import collections +import copy +import re +from typing import cast + +import discord +from discord import app_commands +from .. import with_cog + +from .....skills import Build +from .....utils.chat import zero_width_space +from .....exceptions import APINotFound +from .autocomplete import character_autocomplete + + +class CharacterGearDropdown(discord.ui.Select): + + def __init__(self, tabs, tab_type, emojis): + options = [] + self.is_equipment = tab_type == "equipment" + for i, tab in enumerate(tabs): + try: + emoji = emojis[i] + except IndexError: + emoji = None + options.append( + discord.SelectOption( + label=f"{tab_type.title()} Tab {i+1}", + value=str(i), + emoji=emoji, + description=tab.get("name", ""), + ) + ) + + super().__init__( + placeholder=f"Select {tab_type} template", + min_values=1, + max_values=1, + options=options, + ) + + async def callback(self, interaction: discord.Interaction): + view = cast(CharacterGearView, self.view) + if self.is_equipment: + view.active_equipment = int(self.values[0]) + else: + view.active_build = int(self.values[0]) + embed = view.generate_embed() + await interaction.response.edit_message(embed=embed) + + +class CharacterGearView(discord.ui.View): + + def __init__( + self, equipment_options, build_options, character, emojis, emoji_cache, user + ): + super().__init__() + self.value = None + self.emojis = emojis + self.character = character + self.builds = build_options + self.equipments = equipment_options + self.active_build = character.get("active_build_tab", 1) - 1 + self.active_equipment = character.get("active_equipment_tab", 1) - 1 + self.add_item(CharacterGearDropdown(build_options, "build", emojis)) + self.add_item(CharacterGearDropdown(equipment_options, "equipment", emojis)) + self.emojis_cache = emoji_cache + self.user = user + self.response = None + + async def on_timeout(self) -> None: + # Safely remove interactive items to effectively disable the view + for item in list(self.children): + try: + self.remove_item(item) + except Exception: + pass + if self.response: + resp = cast(discord.Message, self.response) + await resp.edit(view=self) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return interaction.user == self.user + + def generate_embed(self): + build = self.builds[self.active_build] + equipment = self.equipments[self.active_equipment] + embed = discord.Embed() + for field in equipment["fields"]: + embed.add_field(name=field[0], value=field[1], inline=field[2]) + profession = build["build"].profession + description = ["Build template:"] + line = "" + for i in range(len(self.builds)): + key = "active" if i == self.active_build else "inactive" + line += str(self.emojis_cache["build"][key][i]) + if build.get("name"): + line += f" *{build['name']}*" + description.append("> " + line) + description.append("Equipment template:") + line = "" + for i in range(len(self.equipments)): + key = "active" if i == self.active_equipment else "inactive" + line += str(self.emojis_cache["equipment"][key][i]) + if equipment.get("name"): + line += f" *{equipment['name']}*" + description.append("> " + line) + description = "\n".join(description) + embed.description = description + embed.color = profession.color + footer_text = "A level {} {} ".format( + self.character["level"], profession.name.lower() + ) + embed.set_footer(text=footer_text) + embed.set_author(name=self.character["name"], icon_url=profession.icon) + embed.add_field(name="Build code", value=build["build"].code, inline=False) + embed.set_image(url=build["url"]) + return embed + + +@app_commands.command(name="gear", description="Displays gear, attributes and build") +@app_commands.describe(character="Name of your character") +@app_commands.autocomplete(character=character_autocomplete) +@with_cog() +async def character_gear(cog, interaction: discord.Interaction, character: str): + """Displays the gear, attributes and build of given character""" + await interaction.response.defer() + # Cog injected by decorator; assumed loaded + if not cog.check_emoji_permission(interaction): + msg = ( + "The default role in this channel needs to have `use external emojis` " + "permission in order to use this command." + ) + return await interaction.followup.send(msg) + + emojis_cache = { + "build": {"inactive": [], "active": []}, + "equipment": {"inactive": [], "active": []}, + } + for i in range(1, 11): + emojis_cache["build"]["inactive"].append( + cog.get_emoji(interaction, f"build_{i}", return_obj=True) + ) + emojis_cache["build"]["active"].append( + cog.get_emoji(interaction, f"active_build_{i}", return_obj=True) + ) + emojis_cache["equipment"]["inactive"].append( + cog.get_emoji(interaction, f"build_{i}", return_obj=True) + ) + emojis_cache["equipment"]["active"].append( + cog.get_emoji(interaction, f"active_build_{i}", return_obj=True) + ) + + async def get_equipment_fields(tab, eq, results): + fields = [] + runes = collections.defaultdict(int) + bonuses = collections.defaultdict(int) + armor_lines = [] + trinket_lines = [] + weapon_sets = {"A": [], "B": []} + armors = ["Helm", "Shoulders", "Coat", "Gloves", "Leggings", "Boots"] + trinkets = [ + "Ring1", + "Ring2", + "Amulet", + "Accessory1", + "Accessory2", + "Backpack", + ] + weapons = ["WeaponA1", "WeaponA2", "WeaponB1", "WeaponB2"] + pieces = armors + trinkets + weapons + for piece in pieces: + piece_name = piece[:-1] if piece[-1:].isdigit() else piece + line = "" + stat_name = "" + upgrades_to_display = [] + for item in eq: + if item["slot"] == piece: + svc = cog.container.characters_service + item_doc = await svc.fetch_item(item["id"]) or {} + line = cog.get_emoji( + interaction, f"{item_doc['rarity']}_{piece_name}" + ) + for upgrade_type in ("infusions", "upgrades"): + if upgrade_type in item: + for upgrade in item[upgrade_type]: + cs = cog.container.characters_service + upgrade_doc = await cs.fetch_item(upgrade) + if not upgrade_doc: + upgrades_to_display.append("Unknown upgrade") + continue + details = upgrade_doc["details"] + if details["type"] == "Rune": + runes[upgrade_doc["name"]] += 1 + if details["type"] == "Sigil": + upgrades_to_display.append(upgrade_doc["name"]) + if ( + "infix_upgrade" in details + and "attributes" in details["infix_upgrade"] + ): + for attribute in details["infix_upgrade"][ + "attributes" + ]: + name = attribute["attribute"] + bonuses[name] += attribute["modifier"] + if "stats" in item: + stat_name = await svc.fetch_stat_name(item["stats"]["id"]) + else: + try: + stat_id = item_doc["details"]["infix_upgrade"]["id"] + stat_name = await svc.fetch_stat_name(stat_id) + except KeyError: + pass + line += stat_name + if piece.startswith("Weapon"): + line += " " + svc.readable_attribute( + item_doc["details"]["type"] + ) + if upgrades_to_display: + line += "\n*{}*".format("\n".join(upgrades_to_display)) + if not line and not piece.startswith("Weapon"): + line = cog.get_emoji(interaction, f"basic_{piece_name}") + "NONE" + if piece in armors: + armor_lines.append(line) + elif piece in weapons: + if line: + weapon_sets[piece[-2]].append(line) + elif piece in trinkets: + trinket_lines.append(line) + lines = [] + lines.append("\n".join(armor_lines)) + if runes: + for rune, count in runes.items(): + lines.append(f"*{rune}* ({count}/6)") + fields.append(("> **ARMOR**", "\n".join(lines), True)) + if any(weapon_sets["A"]): + fields.append(("> **WEAPON SET #1**", "\n".join(weapon_sets["A"]), True)) + if any(weapon_sets["B"]): + fields.append(("> **WEAPON SET #2**", "\n".join(weapon_sets["B"]), True)) + fields.append(("> **TRINKETS**", "\n".join(trinket_lines), False)) + upgrade_lines = [] + for bonus, count in bonuses.items(): + bonus_r = cog.container.characters_service.readable_attribute(bonus) + emoji = cog.get_emoji(interaction, f"attribute_{bonus_r}") + upgrade_lines.append(f"{emoji}**{bonus_r}**: {count}") + if not upgrade_lines: + upgrade_lines = ["None found"] + fields.append(("> **BONUSES FROM UPGRADES**", "\n".join(upgrade_lines), False)) + attributes = await cog.container.characters_service.calculate_attributes( + results, eq + ) + column_1 = [] + column_2 = [zero_width_space, zero_width_space] + for index, (name, value) in enumerate(attributes.items()): + line = cog.get_emoji(interaction, f"attribute_{name}") + line += f"**{name}**: {value}" + if index < 9: + column_1.append(line) + else: + column_2.append(line) + fields.append(("> **ATTRIBUTES**", "\n".join(column_1), True)) + fields.append((zero_width_space, "\n".join(column_2), True)) + return fields + + cog_doc = await cog.bot.database.get_cog_config(cog) + if not cog_doc: + return await interaction.followup.send("Eror reading configuration") + image_channel = cog.bot.get_channel(cog_doc.get("image_channel")) + if not image_channel: + return await interaction.followup.send( + "The owner must set the image channel using $imagechannel command." + ) + + # Get key with required scopes for gear/builds + try: + key_doc = await cog.container.keys_service.get_key_for_user( + interaction.user, required_scopes=["characters", "builds"], cog=cog + ) + except Exception: + return await interaction.followup.send( + "No API key with 'characters,builds' found. Add a key with /key add." + ) + try: + results = await cog.container.characters_service.get_character_by_key( + name=character, key=key_doc["key"] + ) + except APINotFound: + # fallback to public if available + pub = await cog.container.repo.get_public_character(character.title()) + if not pub: + return await interaction.followup.send("Invalid character name") + try: + owner = await cog.bot.fetch_user(pub["owner"]) # type: ignore[index] + owner_key = await cog.container.keys_service.get_key_for_user( + owner, required_scopes=["characters", "builds"], cog=cog + ) + results = await cog.container.characters_service.get_character_by_key( + name=character, key=owner_key["key"] + ) + except Exception: + return await interaction.followup.send("Invalid character name") + + build_tabs = results.get("build_tabs", []) + equipment_tabs = results.get("equipment_tabs", []) + + builds = [] + for tab in build_tabs: + build = await Build.from_build_tab(cog, tab) + if not build: + continue + file = await build.render(filename=f"build_{tab['tab']}.png") + is_active = tab.get("is_active") + name = tab.get("build", {}).get("name") + builds.append( + { + "tab": tab["tab"], + "file": file, + "name": name, + "is_active": is_active, + "build": build, + "url": "", + } + ) + + equipments = [] + for tab in equipment_tabs: + eq = [] + for item_1 in tab.get("equipment", []): + if "stats" in item_1: + eq.append(item_1) + continue + for item_2 in results.get("equipment", []): + if item_1["id"] != item_2["id"]: + continue + if tab["tab"] in item_2.get("tabs", []): + item_copy = copy.copy(item_2) + item_copy["slot"] = item_1["slot"] + eq.append(item_copy) + break + equipments.append( + { + "fields": await get_equipment_fields(tab, eq, results), + "name": tab.get("name"), + } + ) + + # Send build images to the configured image channel and capture URLs + if builds: + images_msg = await image_channel.send( + files=[b["file"] for b in builds if b["file"]] + ) + urls = [attachment.url for attachment in images_msg.attachments] + for url in urls: + m = re.search(r"build_\d*\.png", url) + if not m: + continue + file_name = m.group(0) + tab_id = int("".join(c for c in file_name if c.isdigit())) + for t in builds: + if t["tab"] == tab_id: + t["url"] = url + break + + numbers = emojis_cache["build"]["active"][: len(builds)] if builds else [] + view = CharacterGearView( + equipments, builds, results, numbers, emojis_cache, interaction.user + ) + embed = view.generate_embed() + out = await interaction.followup.send(embed=embed, view=view) + view.response = out diff --git a/guildwars2/adapters/discord/commands/character/info.py b/guildwars2/adapters/discord/commands/character/info.py new file mode 100644 index 0000000..310961f --- /dev/null +++ b/guildwars2/adapters/discord/commands/character/info.py @@ -0,0 +1,83 @@ +import discord +from discord import app_commands +from .. import with_cog + +from .....exceptions import APIError, APINotFound +from .autocomplete import character_autocomplete +from .....types import GuildWars2Cog + + +# Group attachment handled by package-level group discovery +@app_commands.command(name="info", description="Info about the given character") +@app_commands.autocomplete(character=character_autocomplete) +@app_commands.describe(character="Name of your character") +@with_cog() +async def character_info( + cog: GuildWars2Cog, interaction: discord.Interaction, character: str +): + """Info about the given character""" + await interaction.response.defer() + # Acquire key with minimal required scope + try: + key_doc = await cog.container.keys_service.get_key_for_user( + interaction.user, required_scopes=["characters"], cog=cog + ) + except Exception: + return await interaction.followup.send( + "No API key with 'characters' permission found. Add a key with /key add." + ) + # Fetch character via service; fallback to public if needed + try: + results = await cog.container.characters_service.get_character_by_key( + name=character, key=key_doc["key"] + ) + except APINotFound: + # Check if character is public and fetch with owner's key + pub = await cog.container.repo.get_public_character(character.title()) + if not pub: + return await interaction.followup.send("Invalid character name") + try: + owner = await cog.bot.fetch_user(pub["owner"]) # type: ignore[index] + owner_key = await cog.container.keys_service.get_key_for_user( + owner, required_scopes=["characters"], cog=cog + ) + results = await cog.container.characters_service.get_character_by_key( + name=character, key=owner_key["key"] + ) + except Exception: + return await interaction.followup.send("Invalid character name") + except APIError: + raise + age = cog.container.characters_service.format_age(results["age"]) + created = results["created"].split("T", 1)[0] + deaths = results["deaths"] + deathsperhour = round(deaths / (results["age"] / 3600), 1) + if "title" in results: + title = await cog.container.characters_service.get_title(results["title"]) + else: + title = None + profession = await cog.container.characters_service.get_profession_by_character( + results, cog.gamedata + ) + gender = results["gender"] + race = results["race"].lower() + guild = results.get("guild") + + embed = discord.Embed(description=title, colour=profession["color"]) + embed.set_thumbnail(url=profession["icon"]) + embed.add_field(name="Created at", value=created) + embed.add_field(name="Played for", value=age) + if guild is not None: + endpoint = f"guild/{results['guild']}" + guild_doc = await cog.call_api(endpoint) + gname = guild_doc["name"] + gtag = guild_doc["tag"] + embed.add_field(name="Guild", value=f"[{gtag}] {gname}") + embed.add_field(name="Deaths", value=deaths) + embed.add_field(name="Deaths per hour", value=str(deathsperhour), inline=False) + craft_list = cog.container.characters_service.get_crafting(results) + if craft_list: + embed.add_field(name="Crafting", value="\n".join(craft_list)) + embed.set_author(name=character) + embed.set_footer(text=f"A {gender.lower()} {race} {profession['name'].lower()}") + await interaction.followup.send(embed=embed) diff --git a/guildwars2/adapters/discord/commands/character/list_cmd.py b/guildwars2/adapters/discord/commands/character/list_cmd.py new file mode 100644 index 0000000..aae6c7a --- /dev/null +++ b/guildwars2/adapters/discord/commands/character/list_cmd.py @@ -0,0 +1,106 @@ +from typing import List + +import datetime +import discord +from discord import app_commands +from .. import with_cog +from .....types import GuildWars2Cog + +from .....utils.chat import embed_list_lines + + +@app_commands.command(name="list", description="Lists all your characters") +@app_commands.describe(info="Additional info and sort key") +@app_commands.choices( + info=[ + app_commands.Choice(name="Time played", value="age"), + app_commands.Choice(name="Age", value="created"), + app_commands.Choice(name="Profession", value="profession"), + ] +) +@with_cog() +async def character_list( + cog: GuildWars2Cog, interaction: discord.Interaction, info: str = "name" +): + await interaction.response.defer() + + def get_sort_key(): + if info == "profession": + return lambda k: (k.profession, k.name) + if info == "age": + return lambda k: (-k.age, k.name) + if info == "created": + return lambda k: ( + -(datetime.datetime.utcnow() - k.created).total_seconds(), + k.name, + ) + return lambda k: k.name + + def extra_info(char): + if info == "age": + return ": " + cog.container.characters_service.format_age( + char.age, short=True + ) + if info == "created": + return f": " + is_80 = char.level == 80 + return "" + (f" (Level {char.level})" if not is_80 else "") + + user = interaction.user + # Use service to fetch characters for this user's key + doc = await cog.fetch_key(user, ["characters"]) # type: ignore[attr-defined] + characters = await cog.container.characters_service.get_all_characters( + key=doc["key"] + ) + embed = discord.Embed( + title="Your characters", colour=await cog.get_embed_color(interaction) + ) + embed.set_author(name=doc["account_name"], icon_url=user.display_avatar.url) + output: List[str] = [] + + def sort_key_dict(ch: dict): + if info == "profession": + return (ch.get("profession", ""), ch.get("name", "")) + if info == "age": + return (-(ch.get("age", 0)), ch.get("name", "")) + if info == "created": + try: + created_dt = datetime.datetime.strptime( + ch.get("created", "1970-01-01T00:00:00Z"), "%Y-%m-%dT%H:%M:%SZ" + ) + seconds = (datetime.datetime.utcnow() - created_dt).total_seconds() + except Exception: + seconds = 0 + return (-seconds, ch.get("name", "")) + return ch.get("name", "") + + for character in sorted(characters, key=sort_key_dict): + prof = await cog.container.characters_service.get_profession_by_character( + character, cog.gamedata + ) + emoji = cog.get_emoji( + interaction, prof["name"], fallback=True, fallback_fmt="**({})** " + ) + + def extra_info_dict(ch: dict): + if info == "age": + return ": " + cog.container.characters_service.format_age( + ch.get("age", 0), short=True + ) + if info == "created": + try: + created_dt = datetime.datetime.strptime( + ch.get("created", "1970-01-01T00:00:00Z"), "%Y-%m-%dT%H:%M:%SZ" + ) + return f": " + except Exception: + return "" + is_80 = ch.get("level", 0) == 80 + lvl = ch.get("level", 0) + return "" + (f" (Level {lvl})" if not is_80 else "") + + output.append(f"{emoji}**{character.get('name')}**{extra_info_dict(character)}") + info_label = {"created": "date of creation", "age": "time played"}.get(info, info) + embed = embed_list_lines(embed, output, "List") + embed.description = "Sorted by " + info_label + await interaction.followup.send(embed=embed) diff --git a/guildwars2/adapters/discord/commands/character/togglepublic.py b/guildwars2/adapters/discord/commands/character/togglepublic.py new file mode 100644 index 0000000..7780a50 --- /dev/null +++ b/guildwars2/adapters/discord/commands/character/togglepublic.py @@ -0,0 +1,55 @@ +from typing import List + +import discord +from discord import app_commands +from .. import with_cog +from .....types import GuildWars2Cog + + +@app_commands.command( + name="togglepublic", + description="Toggle your character's (or all) public status", +) +@app_commands.describe(character_or_all="Character name or 'All'") +@with_cog() +async def character_togglepublic( + cog: GuildWars2Cog, interaction: discord.Interaction, *, character_or_all: str +): + """Toggle your character's (or all of them) status to public + + Public characters can have their gear and build checked by anyone. + The rest is still private. + """ + await interaction.response.defer() + character = character_or_all.title() + key = await cog.fetch_key( # type: ignore[attr-defined] + interaction.user, ["characters"] + ) + results = await cog.call_api( # type: ignore[attr-defined] + "characters", key=key["key"] + ) + if character not in results and character != "All": + return await interaction.followup.send("Invalid character name") + characters: List[str] = [character] if character != "All" else results + output: List[str] = [] + for char in characters: + doc = await cog.db.characters.find_one({"name": char}) + if doc: + await cog.db.characters.delete_one({"name": char}) + output.append(char + " is now private") + else: + await cog.db.characters.insert_one( + { + "name": char, + "owner": interaction.user.id, + "owner_acc_name": key["account_name"], + } + ) + output.append(char + " is now public") + await interaction.followup.send( + "Character status successfully changed. Anyone can check public characters " + "gear and build - the rest is still private. To make a character private " + "again, run the command again." + ) + if character == "All": + await interaction.user.send("\n".join(output)) diff --git a/guildwars2/adapters/discord/commands/sab/__init__.py b/guildwars2/adapters/discord/commands/sab/__init__.py new file mode 100644 index 0000000..2cf20fb --- /dev/null +++ b/guildwars2/adapters/discord/commands/sab/__init__.py @@ -0,0 +1,4 @@ +from discord import app_commands + +# Package-level group for SAB-related commands +group = app_commands.Group(name="sab", description="Super Adventure Box commands") diff --git a/guildwars2/adapters/discord/commands/sab/unlocks.py b/guildwars2/adapters/discord/commands/sab/unlocks.py new file mode 100644 index 0000000..198664b --- /dev/null +++ b/guildwars2/adapters/discord/commands/sab/unlocks.py @@ -0,0 +1,43 @@ +import discord +from discord import app_commands +from .. import with_cog +from .....types import GuildWars2Cog + +from .....exceptions import APIError, APINotFound +from ..character.autocomplete import character_autocomplete + + +@app_commands.command(name="unlocks", description="Missing SAB unlocks for a character") +@app_commands.autocomplete(character=character_autocomplete) +@with_cog() +async def sab_unlocks( + cog: GuildWars2Cog, interaction: discord.Interaction, character: str +): + await interaction.response.defer() + + def readable(_id: str) -> str: + return _id.replace("_", " ").title() + + scopes = ["characters", "progression"] + character_q = character.title().replace(" ", "%20") + endpoint = f"characters/{character_q}/sab" + try: + results = await cog.call_api(endpoint, interaction.user, scopes) + except APINotFound: + return await interaction.followup.send("Invalid character name") + except APIError: + raise + unlocked = [u["name"] for u in results.get("unlocks", [])] + missing = [ + readable(u) + for u in cog.gamedata.get("sab", {}).get("unlocks", []) + if u not in unlocked + ] + if missing: + return await interaction.followup.send( + "This character is missing the following SAB upgrades:\n" + + "```fix\n{}\n```".format("\n".join(missing)) + ) + await interaction.followup.send( + "You have unlocked all the upgrades on this character! Congratulations!" + ) diff --git a/guildwars2/adapters/discord/commands/sab/zones.py b/guildwars2/adapters/discord/commands/sab/zones.py new file mode 100644 index 0000000..9cbaea1 --- /dev/null +++ b/guildwars2/adapters/discord/commands/sab/zones.py @@ -0,0 +1,51 @@ +from typing import Dict, List + +import discord +from discord import app_commands +from .. import with_cog +from .....types import GuildWars2Cog + +from .....exceptions import APIError, APINotFound +from ..character.autocomplete import character_autocomplete + + +@app_commands.command(name="zones", description="Missing SAB zones for a character") +@app_commands.autocomplete(character=character_autocomplete) +@with_cog() +async def sab_zones( + cog: GuildWars2Cog, interaction: discord.Interaction, character: str +): + await interaction.response.defer() + + def missing_zones(zones: List[Dict[str, str]]): + modes = ["infantile", "normal", "tribulation"] + worlds = (1, 2) + number_of_zones = 3 + [z.pop("id", None) for z in zones] + missing: List[str] = [] + for world in worlds: + for mode in modes: + for zone in range(1, number_of_zones + 1): + zone_dict = {"world": world, "zone": zone, "mode": mode} + if zone_dict not in zones: + missing.append(f"W{world}Z{zone} {mode.title()} mode") + return missing + + scopes = ["characters", "progression"] + character_q = character.title().replace(" ", "%20") + endpoint = f"characters/{character_q}/sab" + try: + results = await cog.call_api(endpoint, interaction.user, scopes) + except APINotFound: + return await interaction.followup.send("Invalid character name") + except APIError: + raise + missing = missing_zones(results.get("zones", [])) + if missing: + return await interaction.followup.send( + "This character is missing the following SAB zones:\n" + + "```fix\n{}\n```".format("\n".join(missing)) + ) + await interaction.followup.send( + "You have unlocked all zones on this character! Congratulations!" + ) diff --git a/guildwars2/characters.py b/guildwars2/characters.py index 54c381e..ccf4b49 100644 --- a/guildwars2/characters.py +++ b/guildwars2/characters.py @@ -1,6 +1,5 @@ import asyncio import collections -import copy import datetime import re @@ -12,112 +11,11 @@ from cogs.guildwars2 import guild from .exceptions import APIError, APINotFound -from .skills import Build from .utils.chat import embed_list_lines, zero_width_space LETTERS = ["🇦", "🇧", "🇨", "🇩", "🇪", "🇫", "🇬", "🇭", "🇮", "🇯"] -class CharacterGearDropdown(discord.ui.Select): - - def __init__(self, tabs, tab_type, emojis): - options = [] - self.is_equipment = tab_type == "equipment" - for i, tab in enumerate(tabs): - try: - emoji = emojis[i] - except IndexError: - emoji = None - options.append( - discord.SelectOption(label=f"{tab_type.title()} Tab {i+1}", - value=i, - emoji=emoji, - description=tab["name"])) - - super().__init__(placeholder=f"Select {tab_type} template", - min_values=1, - max_values=1, - options=options) - - async def callback(self, interaction: discord.Interaction): - if self.is_equipment: - self.view.active_equipment = int(self.values[0]) - else: - self.view.active_build = int(self.values[0]) - embed = self.view.generate_embed() - await interaction.response.edit_message(embed=embed) - - -class CharacterGearView(discord.ui.View): - - def __init__(self, equipment_options, build_options, character, emojis, - emoji_cache, user): - super().__init__() - self.value = None - self.emojis = emojis - self.character = character - self.builds = build_options - self.equipments = equipment_options - self.active_build = character["active_build_tab"] - 1 - self.active_equipment = character["active_equipment_tab"] - 1 - self.add_item(CharacterGearDropdown(build_options, "build", emojis)) - self.add_item( - CharacterGearDropdown(equipment_options, "equipment", emojis)) - self.emojis_cache = emoji_cache - self.user = user - self.response = None - - async def on_timeout(self) -> None: - for child in self.children: - child.disabled = True - await self.response.edit(view=self) - - async def interaction_check(self, - interaction: discord.Interaction) -> bool: - return interaction.user == self.user - - def generate_embed(self): - build = self.builds[self.active_build] - equipment = self.equipments[self.active_equipment] - embed = discord.Embed() - for field in equipment["fields"]: - embed.add_field(name=field[0], value=field[1], inline=field[2]) - profession = build["build"].profession - description = ["Build template:"] - line = "" - for i in range(len(self.builds)): - if i == self.active_build: - key = "active" - else: - key = "inactive" - line += str(self.emojis_cache["build"][key][i]) - if build["name"]: - line += f" *{build['name']}*" - description.append("> " + line) - description.append("Equipment template:") - line = "" - for i in range(len(self.equipments)): - if i == self.active_equipment: - key = "active" - else: - key = "inactive" - line += str(self.emojis_cache["equipment"][key][i]) - if equipment["name"]: - line += f" *{equipment['name']}*" - description.append("> " + line) - description = "\n".join(description) - embed.description = description - embed.color = profession.color - embed.set_footer(text="A level {} {} ".format(self.character["level"], - profession.name.lower())) - embed.set_author(name=self.character["name"], icon_url=profession.icon) - embed.add_field(name="Build code", - value=build["build"].code, - inline=False) - embed.set_image(url=build["url"]) - return embed - - class Character: def __init__(self, cog, data): @@ -132,10 +30,9 @@ def __init__(self, cog, data): self.active_build_tab = data.get("active_build_tab") self.build_tabs = data.get("build_tabs", []) self.color = discord.Color( - int(self.cog.gamedata["professions"][self.profession]["color"], - 16)) - self.created = datetime.datetime.strptime(data["created"], - "%Y-%m-%dT%H:%M:%Sz") + int(self.cog.gamedata["professions"][self.profession]["color"], 16) + ) + self.created = datetime.datetime.strptime(data["created"], "%Y-%m-%dT%H:%M:%Sz") self.age = data["age"] self.spec_cache = {} @@ -147,19 +44,18 @@ async def get_elite_spec(): if not self.active_build_tab: spec = self.specializations[mode][2] else: - spec = self.build_tabs[self.active_build_tab - - 1]["build"]["specializations"][2] + spec = self.build_tabs[self.active_build_tab - 1]["build"][ + "specializations" + ][2] if spec: - spec = await self.cog.db.specializations.find_one( - {"_id": spec["id"]}) + spec = await self.cog.db.specializations.find_one({"_id": spec["id"]}) if spec is None or not spec["elite"]: return self.profession.title() return spec["name"] return self.profession.title() def get_icon_url(prof_name): - base_url = ("https://resources.gw2bot.info/" - "professions/{}_icon.png") + base_url = "https://resources.gw2bot.info/" "professions/{}_icon.png" return base_url.format(prof_name.replace(" ", "_").lower()) name = await get_elite_spec() @@ -172,9 +68,9 @@ def get_icon_url(prof_name): class CharactersMixin: character_group = app_commands.Group( - name="character", description="Character related commands") - sab_group = app_commands.Group(name="sab", - description="Character related commands") + name="character", description="Character related commands" + ) + sab_group = app_commands.Group(name="sab", description="Character related commands") @staticmethod def format_age(age, *, short=False): @@ -186,8 +82,9 @@ def format_age(age, *, short=False): return "{}{} {}{}".format(hours, h_str, minutes, m_str) return "{}{}".format(minutes, m_str) - async def character_autocomplete(self, interaction: discord.Interaction, - current: str): + async def character_autocomplete( + self, interaction: discord.Interaction, current: str + ): doc = await self.bot.database.get(interaction.user, self) key = doc.get("key", {}) if not key: @@ -197,37 +94,36 @@ async def character_autocomplete(self, interaction: discord.Interaction, async def cache_characters(): try: - character_list = await self.call_api("characters", - key=key["key"], - scopes=["characters"]) + character_list = await self.call_api( + "characters", key=key["key"], scopes=["characters"] + ) except APIError: return [] c = { "last_update": datetime.datetime.utcnow(), - "characters": character_list + "characters": character_list, } - await self.bot.database.set(interaction.user, - {f"character_cache.{account_key}": c}, - self) + await self.bot.database.set( + interaction.user, {f"character_cache.{account_key}": c}, self + ) return c cache = doc.get("character_cache", {}).get(account_key, {}) if not cache: cache = await cache_characters() - elif cache["last_update"] < datetime.datetime.utcnow( - ) - datetime.timedelta(days=3): + elif cache["last_update"] < datetime.datetime.utcnow() - datetime.timedelta( + days=3 + ): asyncio.create_task(cache_characters()) character_list = cache["characters"] current = current.lower() return [ - Choice(name=c, value=c) for c in character_list - if current in c.lower() + Choice(name=c, value=c) for c in character_list if current in c.lower() ][:25] @character_group.command(name="fashion") @app_commands.autocomplete(character=character_autocomplete) - async def character_fashion(self, interaction: discord.Interaction, - character: str): + async def character_fashion(self, interaction: discord.Interaction, character: str): """Displays the fashion wars of given character""" await interaction.response.defer() try: @@ -236,14 +132,20 @@ async def character_fashion(self, interaction: discord.Interaction, return await interaction.followup.send("Invalid character name") except APIError: raise - eq = [ - x for x in results["equipment"] - if x["location"].startswith("Equipped") - ] + eq = [x for x in results["equipment"] if x["location"].startswith("Equipped")] gear = {} pieces = [ - "Helm", "Shoulders", "Coat", "Gloves", "Leggings", "Boots", - "Backpack", "WeaponA1", "WeaponA2", "WeaponB1", "WeaponB2" + "Helm", + "Shoulders", + "Coat", + "Gloves", + "Leggings", + "Boots", + "Backpack", + "WeaponA1", + "WeaponA2", + "WeaponB1", + "WeaponB2", ] gear = {piece: {} for piece in pieces} profession = await self.get_profession_by_character(results) @@ -285,69 +187,28 @@ async def character_fashion(self, interaction: discord.Interaction, value = zero_width_space embed.add_field(name=info["name"], value=value, inline=False) embed.set_author(name=character) - embed.set_footer(text="A level {} {} ".format(level, - profession.name.lower()), - icon_url=profession.icon) + embed.set_footer( + text="A level {} {} ".format(level, profession.name.lower()), + icon_url=profession.icon, + ) await interaction.followup.send(embed=embed) - # TODO elite spec icons - @character_group.command(name="info") - @app_commands.autocomplete(character=character_autocomplete) - async def character_info(self, interaction: discord.Interaction, - character: str): - """Info about the given character""" - await interaction.response.defer() - try: - results = await self.get_character(interaction, character) - except APINotFound: - return await interaction.followup.send("Invalid character name") - except APIError: - raise - age = self.format_age(results["age"]) - created = results["created"].split("T", 1)[0] - deaths = results["deaths"] - deathsperhour = round(deaths / (results["age"] / 3600), 1) - if "title" in results: - title = await self.get_title(results["title"]) - else: - title = None - profession = await self.get_profession_by_character(results) - gender = results["gender"] - race = results["race"].lower() - guild = results["guild"] - embed = discord.Embed(description=title, colour=profession.color) - embed.set_thumbnail(url=profession.icon) - embed.add_field(name="Created at", value=created) - embed.add_field(name="Played for", value=age) - if guild is not None: - endpoint = "guild/{0}".format(results["guild"]) - guild = await self.call_api(endpoint) - gname = guild["name"] - gtag = guild["tag"] - embed.add_field(name="Guild", value="[{}] {}".format(gtag, gname)) - embed.add_field(name="Deaths", value=deaths) - embed.add_field(name="Deaths per hour", - value=str(deathsperhour), - inline=False) - craft_list = self.get_crafting(results) - if craft_list: - embed.add_field(name="Crafting", value="\n".join(craft_list)) - embed.set_author(name=character) - embed.set_footer(text="A {} {} {}".format(gender.lower(), race, - profession.name.lower())) - await interaction.followup.send(embed=embed) + # character info command moved to adapters/discord/commands/character/info.py @character_group.command(name="list") @app_commands.describe( - info="Select additional information to display, and to sort by") - @app_commands.choices(info=[ - Choice(name="Time played", value="age"), - Choice(name="Age", value="created"), - Choice(name="Profession", value="profession") - ]) - async def character_list(self, - interaction: discord.Interaction, - info: str = "name"): + info="Select additional information to display, and to sort by" + ) + @app_commands.choices( + info=[ + Choice(name="Time played", value="age"), + Choice(name="Age", value="created"), + Choice(name="Profession", value="profession"), + ] + ) + async def character_list( + self, interaction: discord.Interaction, info: str = "name" + ): """Lists all your characters.""" await interaction.response.defer() @@ -357,8 +218,10 @@ def get_sort_key(): if info == "age": return lambda k: (-k.age, k.name) if info == "created": - return lambda k: (-(datetime.datetime.utcnow() - k.created). - total_seconds(), k.name) + return lambda k: ( + -(datetime.datetime.utcnow() - k.created).total_seconds(), + k.name, + ) return lambda k: k.name def extra_info(char): @@ -373,248 +236,31 @@ def extra_info(char): scopes = ["characters", "builds"] doc = await self.fetch_key(user, scopes) characters = await self.get_all_characters(user) - embed = discord.Embed(title="Your characters", - colour=await self.get_embed_color(interaction)) - embed.set_author(name=doc["account_name"], - icon_url=user.display_avatar.url) + embed = discord.Embed( + title="Your characters", colour=await self.get_embed_color(interaction) + ) + embed.set_author(name=doc["account_name"], icon_url=user.display_avatar.url) output = [] for character in sorted(characters, key=get_sort_key()): spec = await character.get_spec_info() - output.append("{}**{}**{}".format( - self.get_emoji(interaction, - spec["name"], - fallback=True, - fallback_fmt="**({})** "), character.name, - extra_info(character))) - info = { - "created": "date of creation", - "age": "time played" - }.get(info, info) + output.append( + "{}**{}**{}".format( + self.get_emoji( + interaction, + spec["name"], + fallback=True, + fallback_fmt="**({})** ", + ), + character.name, + extra_info(character), + ) + ) + info = {"created": "date of creation", "age": "time played"}.get(info, info) embed = embed_list_lines(embed, output, "List") embed.description = "Sorted by " + info await interaction.followup.send(embed=embed) - @character_group.command(name="gear") - @app_commands.autocomplete(character=character_autocomplete) - # @app_commands.checks.has_permissions(embed_links=True, - # external_emojis=True) - async def character_gear(self, interaction: discord.Interaction, - character: str): - """Displays the gear, attributes and build of given character""" - await interaction.response.defer() - if not self.check_emoji_permission(interaction): - return await interaction.followup.send( - "The default role in this channel needs to " - "have `use external emojis` permission in order to use this command." - ) - # TODO move this to a check - - numbers = [] - - async def get_equipment_fields(tab, eq): - fields = [] - runes = collections.defaultdict(int) - bonuses = collections.defaultdict(int) - armor_lines = [] - trinket_lines = [] - weapon_sets = {"A": [], "B": []} - armors = [ - "Helm", "Shoulders", "Coat", "Gloves", "Leggings", "Boots" - ] - trinkets = [ - "Ring1", "Ring2", "Amulet", "Accessory1", "Accessory2", - "Backpack" - ] - weapons = ["WeaponA1", "WeaponA2", "WeaponB1", "WeaponB2"] - pieces = armors + trinkets + weapons - for piece in pieces: - piece_name = piece - if piece[-1].isdigit(): - piece_name = piece[:-1] - line = "" - stat_name = "" - upgrades_to_display = [] - for item in eq: - if item["slot"] == piece: - item_doc = await self.fetch_item(item["id"]) - line = self.get_emoji( - interaction, f"{item_doc['rarity']}_{piece_name}") - for upgrade_type in "infusions", "upgrades": - if upgrade_type in item: - for upgrade in item[upgrade_type]: - upgrade_doc = await self.fetch_item(upgrade - ) - if not upgrade_doc: - upgrades_to_display.append( - "Unknown upgrade") - continue - details = upgrade_doc["details"] - if details["type"] == "Rune": - runes[upgrade_doc["name"]] += 1 - if details["type"] == "Sigil": - upgrades_to_display.append( - upgrade_doc["name"]) - if "infix_upgrade" in details: - if "attributes" in details[ - "infix_upgrade"]: - for attribute in details[ - "infix_upgrade"][ - "attributes"]: - bonuses[attribute[ - "attribute"]] += attribute[ - "modifier"] - if "stats" in item: - stat_name = await self.fetch_statname( - item["stats"]["id"]) - else: - try: - stat_id = item_doc["details"]["infix_upgrade"][ - "id"] - stat_name = await self.fetch_statname(stat_id) - except KeyError: - pass - line += stat_name - if piece.startswith("Weapon"): - line += " " + self.readable_attribute( - item_doc["details"]["type"]) - if upgrades_to_display: - line += "\n*{}*".format( - "\n".join(upgrades_to_display)) - if not line and not piece.startswith("Weapon"): - line = self.get_emoji(interaction, - f"basic_{piece_name}") + "NONE" - if piece in armors: - armor_lines.append(line) - elif piece in weapons: - if line: - weapon_sets[piece[-2]].append(line) - elif piece in trinkets: - trinket_lines.append(line) - lines = [] - lines.append("\n".join(armor_lines)) - if runes: - for rune, count in runes.items(): - lines.append(f"*{rune}* ({count}/6)") - fields.append(("> **ARMOR**", "\n".join(lines), True)) - if any(weapon_sets["A"]): - fields.append( - ("> **WEAPON SET #1**", "\n".join(weapon_sets["A"]), True)) - if any(weapon_sets["B"]): - fields.append( - ("> **WEAPON SET #2**", "\n".join(weapon_sets["B"]), True)) - fields.append(("> **TRINKETS**", "\n".join(trinket_lines), False)) - upgrade_lines = [] - for bonus, count in bonuses.items(): - bonus = self.readable_attribute(bonus) - emoji = self.get_emoji(interaction, f"attribute_{bonus}") - upgrade_lines.append(f"{emoji}**{bonus}**: {count}") - if not upgrade_lines: - upgrade_lines = ["None found"] - fields.append(("> **BONUSES FROM UPGRADES**", - "\n".join(upgrade_lines), False)) - attributes = await self.calculate_character_attributes(results, eq) - column_1 = [] - column_2 = [zero_width_space, - zero_width_space] # cause power is in a different row - for index, (name, value) in enumerate(attributes.items()): - line = self.get_emoji(interaction, f"attribute_{name}") - line += f"**{name}**: {value}" - if index < 9: - column_1.append(line) - else: - column_2.append(line) - fields.append(("> **ATTRIBUTES**", "\n".join(column_1), True)) - fields.append((zero_width_space, "\n".join(column_2), True)) - return fields - - emojis_cache = { - "build": { - "inactive": [], - "active": [] - }, - "equipment": { - "inactive": [], - "active": [] - } - } - for i in range(1, 11): - emojis_cache["build"]["inactive"].append( - self.get_emoji(interaction, f"build_{i}", return_obj=True)) - emojis_cache["build"]["active"].append( - self.get_emoji(interaction, - f"active_build_{i}", - return_obj=True)) - emojis_cache["equipment"]["inactive"].append( - self.get_emoji(interaction, f"build_{i}", return_obj=True)) - emojis_cache["equipment"]["active"].append( - self.get_emoji(interaction, - f"active_build_{i}", - return_obj=True)) - cog_doc = await self.bot.database.get_cog_config(self) - if not cog_doc: - return await interaction.followup.send("Eror reading configuration" - ) - image_channel = self.bot.get_channel(cog_doc.get("image_channel")) - if not image_channel: - return await interaction.followup.send( - "The owner must set the image" - " channel using $imagechannel command.") - try: - results = await self.get_character(interaction, character) - except APINotFound: - return await interaction.followup.send("Invalid character name") - build_tabs = results["build_tabs"] - equipment_tabs = results["equipment_tabs"] - builds = [] - for tab in build_tabs: - build = await Build.from_build_tab(self, tab) - file = await build.render(filename=f"build_{tab['tab']}.png") - is_active = tab["is_active"] - name = tab["build"]["name"] - builds.append({ - "tab": tab["tab"], - "file": file, - "name": name, - "is_active": is_active, - "build": build, - "url": "" - }) - equipments = [] - for tab in equipment_tabs: - eq = [] - for item_1 in tab["equipment"]: - if "stats" in item_1: - eq.append(item_1) - continue - for item_2 in results["equipment"]: - if item_1["id"] != item_2["id"]: - continue - if tab["tab"] in item_2["tabs"]: - item_copy = copy.copy(item_2) - item_copy["slot"] = item_1["slot"] - eq.append(item_copy) - break - equipments.append({ - "fields": await get_equipment_fields(tab, eq), - "name": tab["name"] - }) - numbers = emojis_cache["build"]["active"][:len(builds)] - images_msg = await image_channel.send( - files=[b["file"] for b in builds if b["file"]]) - urls = [attachment.url for attachment in images_msg.attachments] - - for url in urls: - file_name = re.search(r"build_\d*\.png", url).group(0) - tab_id = int("".join(c for c in file_name if c.isdigit())) - for tab in builds: - if tab["tab"] == tab_id: - tab["url"] = url - break - view = CharacterGearView(equipments, builds, results, numbers, - emojis_cache, interaction.user) - embed = view.generate_embed() - out = await interaction.followup.send(embed=embed, view=view) - view.response = out + # gear command moved to adapters/discord/commands/character/gear.py @character_group.command(name="birthdays") async def character_birthdays(self, interaction: discord.Interaction): @@ -622,11 +268,11 @@ async def character_birthdays(self, interaction: discord.Interaction): def suffix(year): if year == 1: - return 'st' + return "st" if year == 2: - return 'nd' + return "nd" if year == 3: - return 'rd' + return "rd" return "th" await interaction.response.defer() @@ -643,26 +289,31 @@ def suffix(year): fields.setdefault(next_bd, []) spec = await character.get_spec_info() fields[next_bd].append( - ("{} {}".format(self.get_emoji(interaction, spec["name"]), - character.name), days_left)) - embed = discord.Embed(title="Days until...", - colour=await self.get_embed_color(interaction)) - embed.set_author(name=doc["account_name"], - icon_url=interaction.user.display_avatar.url) + ( + "{} {}".format( + self.get_emoji(interaction, spec["name"]), character.name + ), + days_left, + ) + ) + embed = discord.Embed( + title="Days until...", colour=await self.get_embed_color(interaction) + ) + embed.set_author( + name=doc["account_name"], icon_url=interaction.user.display_avatar.url + ) for k, v in sorted(fields.items(), reverse=True, key=lambda k: k[0]): lines = [ - "{}: **{}**".format(*line) - for line in sorted(v, key=lambda l: l[1]) + "{}: **{}**".format(*line) for line in sorted(v, key=lambda l: l[1]) ] - embed = embed_list_lines(embed, lines, - "{}{} Birthday".format(k, suffix(k))) + embed = embed_list_lines(embed, lines, "{}{} Birthday".format(k, suffix(k))) await interaction.followup.send(embed=embed) def readable_attribute(self, attribute_name): attribute_sub = re.sub(r"(\w)([A-Z])", r"\1 \2", attribute_name) - attribute_sub = re.sub('Crit ', 'Critical ', attribute_sub) - attribute_sub = re.sub('Healing', 'Healing Power', attribute_sub) - attribute_sub = re.sub('defense', 'Armor', attribute_sub) + attribute_sub = re.sub("Crit ", "Critical ", attribute_sub) + attribute_sub = re.sub("Healing", "Healing Power", attribute_sub) + attribute_sub = re.sub("defense", "Armor", attribute_sub) return attribute_sub async def calculate_character_attributes(self, character, eq): @@ -701,20 +352,33 @@ def calc_base_health(level: int, acc_baselvl: int, health_dict): # Parse to int because of possible floats return int(acc_baselvl) else: - new_acc = acc_baselvl + search_lvl_to_health( - level, health_dict) + new_acc = acc_baselvl + search_lvl_to_health(level, health_dict) new_lvl = level - 1 return calc_base_health(new_lvl, new_acc, health_dict) attr_list = [ - 'defense', 'Power', 'Vitality', 'Precision', 'Toughness', - 'Critical Chance', 'Health', 'Concentration', 'Expertise', - 'BoonDuration', 'ConditionDamage', 'Ferocity', 'CritDamage', - 'Healing', 'ConditionDuration', 'AgonyResistance' + "defense", + "Power", + "Vitality", + "Precision", + "Toughness", + "Critical Chance", + "Health", + "Concentration", + "Expertise", + "BoonDuration", + "ConditionDamage", + "Ferocity", + "CritDamage", + "Healing", + "ConditionDuration", + "AgonyResistance", ] percentage_list = [ - 'Critical Chance', 'CritDamage', 'ConditionDuration', - 'BoonDuration' + "Critical Chance", + "CritDamage", + "ConditionDuration", + "BoonDuration", ] lvl_dict = { 7: [2, 10], @@ -732,28 +396,28 @@ def calc_base_health(level: int, acc_baselvl: int, health_dict): 36: [67, 70], 44: [71, 74], 45: [75, 76], - 46: [77, 80] + 46: [77, 80], } health_group1 = { 28: [1, 19], 70: [20, 39], 140: [40, 59], 210: [60, 79], - 280: [80, 80] + 280: [80, 80], } health_group2 = { 18: [1, 19], 45: [20, 39], 90: [40, 59], 135: [60, 79], - 180: [80, 80] + 180: [80, 80], } health_group3 = { 5: [1, 19], 12.5: [20, 39], 25: [40, 59], 37.5: [60, 79], - 50: [80, 80] + 50: [80, 80], } profession_group = { @@ -765,12 +429,18 @@ def calc_base_health(level: int, acc_baselvl: int, health_dict): "mesmer": health_group2, "guardian": health_group3, "thief": health_group3, - "elementalist": health_group3 + "elementalist": health_group3, } ignore_list = [ - 'HelmAquatic', 'WeaponAquaticA', 'WeaponAquaticB', 'WeaponB1', - 'WeaponB2', "Sickle", "Axe", "Pick" + "HelmAquatic", + "WeaponAquaticA", + "WeaponAquaticB", + "WeaponB1", + "WeaponB2", + "Sickle", + "Axe", + "Pick", ] attr_dict = {key: 0 for (key) in attr_list} runes = {} @@ -787,15 +457,13 @@ def calc_base_health(level: int, acc_baselvl: int, health_dict): elif "charges" not in piece: if piece["slot"] not in ignore_list: if "infix_upgrade" in item["details"]: - attributes = item["details"]["infix_upgrade"][ - "attributes"] + attributes = item["details"]["infix_upgrade"]["attributes"] for attribute in attributes: - attr_dict[attribute["attribute"]] += attribute[ - "modifier"] + attr_dict[attribute["attribute"]] += attribute["modifier"] # Get armor rating if "defense" in item["details"]: if piece["slot"] not in ignore_list: - attr_dict['defense'] += item["details"]["defense"] + attr_dict["defense"] += item["details"]["defense"] # Mapping for old attribute names attr_dict["Concentration"] += attr_dict["BoonDuration"] attr_dict["Ferocity"] += attr_dict["CritDamage"] @@ -818,11 +486,13 @@ def calc_base_health(level: int, acc_baselvl: int, health_dict): if not item_upgrade: continue if "infix_upgrade" in item_upgrade["details"]: - attributes = item_upgrade["details"][ - "infix_upgrade"]["attributes"] + attributes = item_upgrade["details"]["infix_upgrade"][ + "attributes" + ] for attribute in attributes: attr_dict[attribute["attribute"]] += attribute[ - "modifier"] + "modifier" + ] # Runes if item_upgrade["details"]["type"] == "Rune": # Rune counter @@ -832,25 +502,28 @@ def calc_base_health(level: int, acc_baselvl: int, health_dict): runes[upgrade] = 1 elif item_upgrade["details"]["type"] == "Sigil": pattern_percentage = re.compile("^\+\d{1,}% ") - bonus = item_upgrade["details"]["infix_upgrade"][ - "buff"]["description"] + bonus = item_upgrade["details"]["infix_upgrade"]["buff"][ + "description" + ] if pattern_percentage.match(bonus): - modifier = re.sub(' .*$', '', bonus) - modifier = re.sub('\+', '', modifier) - modifier = re.sub('%', '', modifier) + modifier = re.sub(" .*$", "", bonus) + modifier = re.sub("\+", "", modifier) + modifier = re.sub("%", "", modifier) attribute_name = bonus.title() attribute_name = re.sub( - ' Duration', 'Duration', attribute_name) - attribute_name = re.sub( - 'Duration.*', 'Duration', attribute_name) + " Duration", "Duration", attribute_name + ) attribute_name = re.sub( - ' Chance', 'Chance', attribute_name) + "Duration.*", "Duration", attribute_name + ) attribute_name = re.sub( - 'Chance.*', 'Chance', attribute_name) + " Chance", "Chance", attribute_name + ) attribute_name = re.sub( - '^.* ', '', attribute_name) - attribute_name = re.sub( - '\.', '', attribute_name) + "Chance.*", "Chance", attribute_name + ) + attribute_name = re.sub("^.* ", "", attribute_name) + attribute_name = re.sub("\.", "", attribute_name) if attribute_name in attr_dict: attr_dict[attribute_name] += int(modifier) # Infusions @@ -862,16 +535,17 @@ def calc_base_health(level: int, acc_baselvl: int, health_dict): if not item_infusion: continue if "infix_upgrade" in item_infusion["details"]: - attributes = item_infusion["details"][ - "infix_upgrade"]["attributes"] + attributes = item_infusion["details"]["infix_upgrade"][ + "attributes" + ] for attribute in attributes: if attribute["attribute"] == "BoonDuration": attribute["attribute"] = "Concentration" - if attribute[ - "attribute"] == "ConditionDuration": + if attribute["attribute"] == "ConditionDuration": attribute["attribute"] = "Expertise" attr_dict[attribute["attribute"]] += attribute[ - "modifier"] + "modifier" + ] for rune, runecount in runes.items(): rune_item = await self.fetch_item(rune) @@ -886,8 +560,8 @@ def calc_base_health(level: int, acc_baselvl: int, health_dict): pattern_percentage = re.compile("^\+\d{1,}% ") # Regex deciding if it's a stat if pattern_all_stats.match(bonus): - modifier = re.sub(' .*$', '', bonus) - modifier = re.sub('\+', '', modifier) + modifier = re.sub(" .*$", "", bonus) + modifier = re.sub("\+", "", modifier) attr_dict["Power"] += int(modifier) attr_dict["Vitality"] += int(modifier) attr_dict["Toughness"] += int(modifier) @@ -897,24 +571,23 @@ def calc_base_health(level: int, acc_baselvl: int, health_dict): attr_dict["ConditionDamage"] += int(modifier) elif pattern_single.match(bonus): # Regex deciding the attribute name + modifier - modifier = re.sub(' .*$', '', bonus) - modifier = re.sub('\+', '', modifier) - attribute_name = re.sub(' Damage', 'Damage', bonus) - attribute_name = re.sub('Damage.*', 'Damage', - attribute_name) - attribute_name = re.sub('\+\d{1,} ', '', - attribute_name) - attribute_name = re.sub(';.*', '', attribute_name) + modifier = re.sub(" .*$", "", bonus) + modifier = re.sub("\+", "", modifier) + attribute_name = re.sub(" Damage", "Damage", bonus) + attribute_name = re.sub("Damage.*", "Damage", attribute_name) + attribute_name = re.sub("\+\d{1,} ", "", attribute_name) + attribute_name = re.sub(";.*", "", attribute_name) if attribute_name in attr_dict: attr_dict[attribute_name] += int(modifier) elif pattern_percentage.match(bonus): - modifier = re.sub(' .*$', '', bonus) - modifier = re.sub('\+', '', modifier) - modifier = re.sub('%', '', modifier) - attribute_name = re.sub(' Duration', 'Duration', bonus) - attribute_name = re.sub('Duration.*', 'Duration', - attribute_name) - attribute_name = re.sub('^.* ', '', attribute_name) + modifier = re.sub(" .*$", "", bonus) + modifier = re.sub("\+", "", modifier) + modifier = re.sub("%", "", modifier) + attribute_name = re.sub(" Duration", "Duration", bonus) + attribute_name = re.sub( + "Duration.*", "Duration", attribute_name + ) + attribute_name = re.sub("^.* ", "", attribute_name) if attribute_name in attr_dict: attr_dict[attribute_name] += int(modifier) # Amount of runes equipped @@ -936,8 +609,7 @@ def calc_base_health(level: int, acc_baselvl: int, health_dict): attr_dict["BoonDuration"] = int(attr_dict["BoonDuration"]) attr_dict["ConditionDuration"] += round(attr_dict["Expertise"] / 15, 2) if attr_dict["ConditionDuration"] == 0: - attr_dict["ConditionDuration"] = int( - attr_dict["ConditionDuration"]) + attr_dict["ConditionDuration"] = int(attr_dict["ConditionDuration"]) # Base value of 1000 on lvl 80 doesn't get calculated, # if below lvl 80 dont subtract it if attr_dict["Precision"] < 1000: @@ -945,32 +617,47 @@ def calc_base_health(level: int, acc_baselvl: int, health_dict): else: base_prec = 1000 attr_dict["Critical Chance"] = round( - 4 + ((attr_dict["Precision"] - base_prec) / 21), 2) + 4 + ((attr_dict["Precision"] - base_prec) / 21), 2 + ) attr_dict["defense"] += attr_dict["Toughness"] # Calculate base health attr_dict["Health"] = calc_base_health( - level, 0, profession_group[character["profession"].lower()]) + level, 0, profession_group[character["profession"].lower()] + ) attr_dict["Health"] += attr_dict["Vitality"] * 10 - ordered_list = ('Power', 'Toughness', 'Vitality', 'Precision', - 'Ferocity', 'ConditionDamage', 'Expertise', - 'Concentration', 'AgonyResistance', 'defense', - 'Health', 'Critical Chance', 'CritDamage', 'Healing', - 'ConditionDuration', 'BoonDuration') + ordered_list = ( + "Power", + "Toughness", + "Vitality", + "Precision", + "Ferocity", + "ConditionDamage", + "Expertise", + "Concentration", + "AgonyResistance", + "defense", + "Health", + "Critical Chance", + "CritDamage", + "Healing", + "ConditionDuration", + "BoonDuration", + ) # First one is not inline for layout purpose output = {} for attribute in ordered_list: if attribute in percentage_list: - attr_dict[attribute] = '{0}%'.format( - round(attr_dict[attribute]), 2) + attr_dict[attribute] = "{0}%".format(round(attr_dict[attribute]), 2) attribute_sub = self.readable_attribute(attribute) output[attribute_sub.title()] = attr_dict[attribute] return output @character_group.command(name="togglepublic") - async def character_togglepublic(self, interaction: discord.Interaction, *, - character_or_all: str): + async def character_togglepublic( + self, interaction: discord.Interaction, *, character_or_all: str + ): """Toggle your character's (or all of them) status to public Public characters can have their gear and build checked by anyone. @@ -993,20 +680,20 @@ async def character_togglepublic(self, interaction: discord.Interaction, *, await self.db.characters.delete_one({"name": char}) output.append(char + " is now private") else: - await self.db.characters.insert_one({ - "name": - char, - "owner": - user.id, - "owner_acc_name": - key["account_name"] - }) + await self.db.characters.insert_one( + { + "name": char, + "owner": user.id, + "owner_acc_name": key["account_name"], + } + ) output.append(char + " is now public") await interaction.followup.send( "Character status successfully changed. Anyone can " "check public characters gear and build - the rest is " "still private. To make character private " - "again, type the same command.") + "again, type the same command." + ) if character == "All": await user.send("\n".join(output)) @@ -1017,25 +704,26 @@ async def character_crafting(self, interaction: discord.Interaction): await interaction.response.defer() doc = await self.fetch_key(interaction.user, ["characters"]) characters = await self.call_api(endpoint, key=doc["key"]) - data = discord.Embed(description='Crafting overview', - colour=await self.get_embed_color(interaction)) - data.set_author(name=doc["account_name"], - icon_url=interaction.user.display_avatar.url) + data = discord.Embed( + description="Crafting overview", + colour=await self.get_embed_color(interaction), + ) + data.set_author( + name=doc["account_name"], icon_url=interaction.user.display_avatar.url + ) counter = 0 for character in characters: if counter == 25: break craft_list = self.get_crafting(character) if craft_list: - data.add_field(name=character["name"], - value="\n".join(craft_list)) + data.add_field(name=character["name"], value="\n".join(craft_list)) counter += 1 await interaction.followup.send(embed=data) @sab_group.command(name="unlocks") @app_commands.autocomplete(character=character_autocomplete) - async def sab_unlocks(self, interaction: discord.Interaction, - character: str): + async def sab_unlocks(self, interaction: discord.Interaction, character: str): """Displays missing SAB unlocks for specified character""" await interaction.response.defer() @@ -1053,21 +741,20 @@ def readable(_id): raise unlocked = [u["name"] for u in results["unlocks"]] missing = [ - readable(u) for u in self.gamedata["sab"]["unlocks"] - if u not in unlocked + readable(u) for u in self.gamedata["sab"]["unlocks"] if u not in unlocked ] if missing: return await interaction.followup.send( "This character is missing the following SAB " - "upgrades:\n```fix\n{}\n```".format("\n".join(missing))) + "upgrades:\n```fix\n{}\n```".format("\n".join(missing)) + ) await interaction.followup.send( - "You have unlocked all the upgrades on " - "this character! Congratulations!") + "You have unlocked all the upgrades on " "this character! Congratulations!" + ) @sab_group.command(name="zones") @app_commands.autocomplete(character=character_autocomplete) - async def sab_zones(self, interaction: discord.Interaction, - character: str): + async def sab_zones(self, interaction: discord.Interaction, character: str): """Displays missing SAB zones for specified character""" await interaction.response.defer() @@ -1080,14 +767,11 @@ def missing_zones(zones): for world in worlds: for mode in modes: for zone in range(1, number_of_zones + 1): - zone_dict = { - "world": world, - "zone": zone, - "mode": mode - } + zone_dict = {"world": world, "zone": zone, "mode": mode} if zone_dict not in zones: - missing.append("W{}Z{} {} mode".format( - world, zone, mode.title())) + missing.append( + "W{}Z{} {} mode".format(world, zone, mode.title()) + ) return missing scopes = ["characters", "progression"] @@ -1103,25 +787,25 @@ def missing_zones(zones): if missing: return await interaction.followup.send( "This character is missing the following SAB " - "zones:\n```fix\n{}\n```".format("\n".join(missing))) - await interaction.followup.send("You have unlocked all zones on " - "this character! Congratulations!") + "zones:\n```fix\n{}\n```".format("\n".join(missing)) + ) + await interaction.followup.send( + "You have unlocked all zones on " "this character! Congratulations!" + ) @commands.command(name="imagechannel") @commands.guild_only() @commands.is_owner() async def set_image_channel(self, ctx, channel: discord.TextChannel): """Set image channel for build template switcher""" - await self.bot.database.set_cog_config(self, - {"image_channel": channel.id}) + await self.bot.database.set_cog_config(self, {"image_channel": channel.id}) await ctx.send("Succesfully set") async def get_all_characters(self, user, scopes=None): endpoint = "characters?page=0&page_size=200" - results = await self.call_api(endpoint, - user, - scopes, - schema_string="2021-07-15T13:00:00.000Z") + results = await self.call_api( + endpoint, user, scopes, schema_string="2021-07-15T13:00:00.000Z" + ) return [Character(self, c) for c in results] async def get_character(self, interaction: discord.Interaction, character): @@ -1130,8 +814,10 @@ async def get_character(self, interaction: discord.Interaction, character): try: results = await self.call_api( endpoint, - interaction.user, ["characters", "builds"], - schema_string="2021-07-15T13:00:00.000Z") + interaction.user, + ["characters", "builds"], + schema_string="2021-07-15T13:00:00.000Z", + ) if results: return results raise APINotFound @@ -1140,7 +826,8 @@ async def get_character(self, interaction: discord.Interaction, character): if doc: user = await self.bot.fetch_user(doc["owner"]) results = await self.call_api( - endpoint, user, schema_string="2021-07-15T13:00:00.000Z") + endpoint, user, schema_string="2021-07-15T13:00:00.000Z" + ) if not results: raise APINotFound return results @@ -1165,8 +852,7 @@ async def get_profession_by_character(self, character): for spec in specs: spec_doc = await self.db.specializations.find_one({"_id": spec}) specializations.append(spec_doc) - return await self.get_profession(character["profession"], - specializations) + return await self.get_profession(character["profession"], specializations) async def get_profession(self, profession, specializations): @@ -1182,18 +868,17 @@ async def get_elite_spec(): return None def get_icon_url(prof_name): - base_url = ("https://resources.gw2bot.info/" - "professions/{}_icon.png") + base_url = "https://resources.gw2bot.info/" "professions/{}_icon.png" return base_url.format(prof_name.replace(" ", "_").lower()) - Profession = collections.namedtuple("Profession", - ["name", "icon", "color"]) + Profession = collections.namedtuple("Profession", ["name", "icon", "color"]) color = discord.Color( - int(self.gamedata["professions"][profession.lower()]["color"], 0)) + int(self.gamedata["professions"][profession.lower()]["color"], 0) + ) name = await get_elite_spec() or profession icon = get_icon_url(name) return Profession(name.title(), icon, color) def get_profession_icon(self, prof_name): - url = ("https://resources.gw2bot.info/professions/{}_icon.png") + url = "https://resources.gw2bot.info/professions/{}_icon.png" return url.format(prof_name.replace(" ", "_").lower()) diff --git a/guildwars2/core/__init__.py b/guildwars2/core/__init__.py new file mode 100644 index 0000000..b913201 --- /dev/null +++ b/guildwars2/core/__init__.py @@ -0,0 +1,4 @@ +"""Core layer: API clients, domain models, and services. + +This makes `guildwars2.core` a Python package. +""" diff --git a/guildwars2/core/api_client.py b/guildwars2/core/api_client.py new file mode 100644 index 0000000..3e6463c --- /dev/null +++ b/guildwars2/core/api_client.py @@ -0,0 +1,141 @@ +import asyncio +from typing import Any, Iterable, Optional + +try: + from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_chain, + wait_fixed, + ) +except Exception: # pragma: no cover - provide no-op fallbacks + + def retry(*args, **kwargs): + def _decorator(func): + return func + + return _decorator + + def retry_if_exception_type(*args, **kwargs): + return None + + def stop_after_attempt(*args, **kwargs): + return None + + def wait_chain(*args, **kwargs): + return None + + def wait_fixed(*args, **kwargs): + return None + + +from ..exceptions import ( + APIBadRequest, + APIConnectionError, + APIForbidden, + APIInactiveError, + APIInvalidKey, + APINotFound, + APIRateLimited, + APIUnavailable, +) + + +class GW2ApiClient: + """Thin async client over the GW2 REST API. + + This lifts logic out of the mixin and makes it reusable by services. + """ + + def __init__(self, session, logger): + self.session = session + self.log = logger + + async def get_many( + self, + endpoints: Iterable[str], + *, + user=None, + scopes=None, + key: Optional[str] = None, + **kwargs, + ): + if key is None and user: + # delegate to caller to supply key; services can fetch via repository + # kept for compatibility if a user is passed + pass + tasks = [ + self.get(e, user=user, scopes=scopes, key=key, **kwargs) for e in endpoints + ] + return await asyncio.gather(*tasks) + + async def _post_fetch_hook( + self, endpoint: str, data: Any, used_key: Optional[str], user + ): + # Services can override/extend via composition; for now, no-op. + return + + @retry( + retry=retry_if_exception_type(APIBadRequest), + reraise=True, + stop=stop_after_attempt(4), + wait=wait_chain(wait_fixed(2), wait_fixed(4), wait_fixed(8)), + ) + async def get( + self, + endpoint: str, + *, + user=None, + scopes=None, + key: Optional[str] = None, + schema_version=None, + schema_string: Optional[str] = None, + ) -> Any: + headers = {"User-Agent": "GW2Bot - a Discord bot", "Accept": "application/json"} + params = [] + use_headers = False + if key: + if use_headers: + headers.update({"Authorization": "Bearer " + key}) + else: + params.append(("access_token", key)) + # Caller can embed schema versions + if schema_version: + schema = schema_version.replace(microsecond=0).isoformat() + "Z" + headers.update({"X-Schema-Version": schema}) + if schema_string: + headers.update({"X-Schema-Version": schema_string}) + apiserv = "https://api.guildwars2.com/v2/" + url = apiserv + endpoint + async with self.session.get(url, headers=headers, params=params) as r: + if r.status not in (200, 206): + try: + err = await r.json() + err_msg = err.get("text", "") + except Exception: + err_msg = "" + if r.status == 400: + if err_msg == "invalid key": + raise APIInvalidKey("Invalid key") + raise APIBadRequest("Bad request") + if r.status == 404: + raise APINotFound("Not found") + if r.status == 403: + if err_msg == "invalid key": + raise APIInvalidKey("Invalid key") + raise APIForbidden("Access denied") + if r.status == 503 and err_msg == "API not active": + raise APIInactiveError("API is dead") + if r.status == 429: + self.log.error("API Call limit saturated") + raise APIRateLimited( + "Requests limit has been saturated. Try again later." + ) + if r.status == 503: + raise APIUnavailable("ArenaNet has disabled the API.") + else: + raise APIConnectionError(f"{r.status} {err_msg}") + data = await r.json() + asyncio.create_task(self._post_fetch_hook(endpoint, data, key, user)) + return data diff --git a/guildwars2/core/container.py b/guildwars2/core/container.py new file mode 100644 index 0000000..131ecc4 --- /dev/null +++ b/guildwars2/core/container.py @@ -0,0 +1,59 @@ +import logging + +from .api_client import GW2ApiClient +from .services.account import AccountService +from .services.achievements import AchievementsService +from .services.characters import CharactersService +from .services.keys import KeyService +from .services.inventory import InventoryService +from ..data.repositories import Gw2Repository + + +class ServiceContainer: + """Constructs and caches service singletons. + + Pass in the bot to reuse its event loop, session, and database handles. + """ + + def __init__(self, bot): + self.bot = bot + self.log = logging.getLogger(__name__) + self.api = GW2ApiClient(session=bot.session, logger=self.log) + self.repo = Gw2Repository(bot.database.db.gw2) + self.utils = {} + self._account_service = None + self._achievements_service = None + self._characters_service = None + self._keys_service = None + self._inventory_service = None + + @property + def account_service(self) -> AccountService: + if not self._account_service: + self._account_service = AccountService(self.api, self.repo, self.utils) + return self._account_service + + @property + def achievements_service(self) -> AchievementsService: + if not self._achievements_service: + self._achievements_service = AchievementsService(self.api, self.repo) + return self._achievements_service + + @property + def characters_service(self) -> CharactersService: + if not self._characters_service: + self._characters_service = CharactersService(self.api, self.repo) + return self._characters_service + + @property + def keys_service(self) -> KeyService: + if not self._keys_service: + # Pass the top-level database handle for compatibility + self._keys_service = KeyService(self.bot.database) + return self._keys_service + + @property + def inventory_service(self) -> InventoryService: + if not self._inventory_service: + self._inventory_service = InventoryService(self.api) + return self._inventory_service diff --git a/guildwars2/core/services/__init__.py b/guildwars2/core/services/__init__.py new file mode 100644 index 0000000..33225c7 --- /dev/null +++ b/guildwars2/core/services/__init__.py @@ -0,0 +1,4 @@ +"""Service layer: application logic extracted from mixins. + +This makes `guildwars2.core.services` a Python package. +""" diff --git a/guildwars2/core/services/account.py b/guildwars2/core/services/account.py new file mode 100644 index 0000000..6f484d0 --- /dev/null +++ b/guildwars2/core/services/account.py @@ -0,0 +1,22 @@ +from typing import Any, Dict + + +class AccountService: + """Discord-free account logic. + + The Cog should call into this and render embeds. + """ + + def __init__(self, api_client, repo, utils): + self.api = api_client + self.repo = repo + self.utils = utils + + async def get_account(self, *, key: str) -> Dict[str, Any]: + return await self.api.get("account", key=key) + + async def get_pvp_stats(self, *, key: str) -> Dict[str, Any]: + return await self.api.get("pvp/stats", key=key) + + async def get_achievements(self, *, key: str): + return await self.api.get("account/achievements", key=key) diff --git a/guildwars2/core/services/achievements.py b/guildwars2/core/services/achievements.py new file mode 100644 index 0000000..1942f45 --- /dev/null +++ b/guildwars2/core/services/achievements.py @@ -0,0 +1,42 @@ +class AchievementsService: + def __init__(self, api_client, repo): + self.api = api_client + self.repo = repo + + async def total_possible_ap(self) -> int: + total = 15000 + cursor = self.repo.db.achievements.find() + async for ach in cursor: + flags = ach.get("flags", []) + if "Repeatable" in flags: + total += ach.get("point_cap", 0) + else: + total += sum(t.get("points", 0) for t in ach.get("tiers", [])) + return total + + def _max_ap(self, ach, repeatable=False) -> int: + if not ach: + return 0 + if repeatable: + return ach.get("point_cap", 0) + return sum(t.get("points", 0) for t in ach.get("tiers", [])) + + def _earned_ap(self, ach, res) -> int: + if not res: + return 0 + repeats = res.get("repeated", 0) + max_possible = self._max_ap(ach, repeats) + earned = 0 + for tier in ach.get("tiers", []): + if res.get("current", 0) >= tier.get("count", 0): + earned += tier.get("points", 0) + earned += self._max_ap(ach) * repeats + return min(earned, max_possible) + + async def calculate_user_ap(self, account_achievements, account_doc) -> int: + total = account_doc.get("daily_ap", 0) + account_doc.get("monthly_ap", 0) + for ach in account_achievements: + doc = await self.repo.db.achievements.find_one({"_id": ach["id"]}) + if doc is not None: + total += self._earned_ap(doc, ach) + return total diff --git a/guildwars2/core/services/characters.py b/guildwars2/core/services/characters.py new file mode 100644 index 0000000..fef7776 --- /dev/null +++ b/guildwars2/core/services/characters.py @@ -0,0 +1,440 @@ +import re +from typing import Any, Dict, List + + +class CharactersService: + def __init__(self, api_client, repo=None): + self.api = api_client + self.repo = repo + + async def get_all_characters(self, *, key: str) -> List[Dict[str, Any]]: + endpoint = "characters?page=0&page_size=200" + return await self.api.get( + endpoint, key=key, schema_string="2021-07-15T13:00:00.000Z" + ) + + @staticmethod + def total_play_time(characters: List[Dict[str, Any]]) -> int: + return sum(c.get("age", 0) for c in characters) + + # Thin repo wrappers + async def fetch_item(self, item_id: int) -> Dict[str, Any] | None: + if not self.repo: + return None + return await self.repo.get_item(item_id) + + async def fetch_stat_name(self, stat_id: int) -> str: + if not self.repo: + return "" + return await self.repo.get_stat_name(stat_id) + + # General helpers + @staticmethod + def format_age(age: int, *, short: bool = False) -> str: + hours, seconds = divmod(age, 3600) + minutes = round(seconds / 60) + h_str = "h" if short else " hours" + m_str = "m" if short else " minutes" + if hours: + return f"{hours}{h_str} {minutes}{m_str}" + return f"{minutes}{m_str}" + + @staticmethod + def get_crafting(character: Dict[str, Any]) -> List[str]: + out: List[str] = [] + for c in character.get("crafting", []) or []: + rating = c.get("rating") + discipline = c.get("discipline") + if rating is not None and discipline: + out.append(f"Level {rating} {discipline}") + return out + + async def get_title(self, title_id: int) -> str: + if not self.repo: + return "" + return await self.repo.get_title(title_id) + + async def get_profession_by_character( + self, character: Dict[str, Any], gamedata + ) -> Dict[str, Any]: + # Determine active tab + active_tab = None + for tab in character.get("build_tabs", []) or []: + if tab.get("is_active"): + active_tab = tab + break + specs = (active_tab or {}).get("build", {}).get("specializations", []) + specializations = [] + if self.repo: + for spec in specs: + spec_id = spec if isinstance(spec, int) else spec.get("id") + spec_doc = await self.repo.get_specialization(spec_id) + if spec_doc: + specializations.append(spec_doc) + + # Choose display name: elite spec (last) if elite, else base profession + def elite_spec_name(): + try: + spec = specializations[-1] + except IndexError: + return None + if spec and spec.get("elite"): + return spec.get("name") + return None + + prof_key = (character.get("profession") or "").lower() + display_name = elite_spec_name() or character.get("profession", "") + # Icon URL pattern + icon_name = (display_name or "").replace(" ", "_").lower() + icon = f"https://resources.gw2bot.info/professions/{icon_name}_icon.png" + # Color from gamedata + try: + color_hex = gamedata["professions"][prof_key]["color"] + color = int(color_hex, 16) + except Exception: + color = 0x000000 + return {"name": str(display_name).title(), "icon": icon, "color": color} + + async def get_profession( + self, + profession: str, + specializations: List[Dict[str, Any]], + gamedata, + ) -> Dict[str, Any]: + """Return profession presentation from base profession and specialization docs. + + Inputs: + - profession: Base profession name (e.g., "Guardian"). + - specializations: List of specialization documents; last may be elite. + - gamedata: Mapping that includes professions color data. + + Output dict keys: name (title-cased), icon (URL), color (int RGB). + """ + # Choose display name: elite spec (last) if elite, else base profession + name = profession + try: + last_spec = specializations[-1] + if last_spec and last_spec.get("elite"): + name = last_spec.get("name", profession) + except IndexError: + pass + + # Icon URL pattern + icon_name = (name or "").replace(" ", "_").lower() + icon = f"https://resources.gw2bot.info/professions/{icon_name}_icon.png" + + # Color from gamedata + try: + prof_key = (profession or "").lower() + color_hex = gamedata["professions"][prof_key]["color"] + color = int(color_hex, 16) + except Exception: + color = 0x000000 + + return {"name": str(name).title(), "icon": icon, "color": color} + + async def get_character_by_key(self, *, name: str, key: str) -> Dict[str, Any]: + safe = name.title().replace(" ", "%20") + endpoint = f"characters/{safe}" + return await self.api.get( + endpoint, key=key, schema_string="2021-07-15T13:00:00.000Z" + ) + + # Helpers extracted from mixin + @staticmethod + def readable_attribute(attribute_name: str) -> str: + attribute_sub = re.sub(r"(\w)([A-Z])", r"\1 \2", attribute_name) + attribute_sub = re.sub("Crit ", "Critical ", attribute_sub) + attribute_sub = re.sub("Healing", "Healing Power", attribute_sub) + attribute_sub = re.sub("defense", "Armor", attribute_sub) + return attribute_sub + + async def calculate_attributes( + self, character: Dict[str, Any], eq: List[Dict[str, Any]] + ): + # Adapted from CharactersMixin.calculate_character_attributes + def search_lvl_to_increase(level: int, lvl_dict: Dict[int, List[int]]): + for increase, rng in lvl_dict.items(): + if rng[0] <= level <= rng[1]: + if level < 11: + return increase + return increase if level % 2 == 0 else 0 + + def calc_base_lvl(level: int, acc: int, lvl_dict: Dict[int, List[int]]): + if level == 1: + return acc + 37 + inc = search_lvl_to_increase(level, lvl_dict) or 0 + return calc_base_lvl(level - 1, acc + inc, lvl_dict) + + def search_lvl_to_health(level: int, health_dict: Dict[int, List[int]]): + for increase, rng in health_dict.items(): + if rng[0] <= level <= rng[1]: + return increase + + def calc_base_health(level: int, acc: int, health_dict: Dict[int, List[int]]): + if level == 1: + inc = search_lvl_to_health(level, health_dict) or 0 + return int(acc + inc) + inc = search_lvl_to_health(level, health_dict) or 0 + return calc_base_health(level - 1, acc + inc, health_dict) + + attr_list = [ + "defense", + "Power", + "Vitality", + "Precision", + "Toughness", + "Critical Chance", + "Health", + "Concentration", + "Expertise", + "BoonDuration", + "ConditionDamage", + "Ferocity", + "CritDamage", + "Healing", + "ConditionDuration", + "AgonyResistance", + ] + percentage_list = [ + "Critical Chance", + "CritDamage", + "ConditionDuration", + "BoonDuration", + ] + lvl_dict = { + 7: [2, 10], + 10: [11, 20], + 14: [21, 24], + 15: [25, 26], + 16: [27, 30], + 20: [31, 40], + 24: [41, 44], + 25: [45, 46], + 26: [47, 50], + 30: [51, 60], + 34: [61, 64], + 35: [65, 66], + 36: [67, 70], + 44: [71, 74], + 45: [75, 76], + 46: [77, 80], + } + health_group1 = { + 28: [1, 19], + 70: [20, 39], + 140: [40, 59], + 210: [60, 79], + 280: [80, 80], + } + health_group2 = { + 18: [1, 19], + 45: [20, 39], + 90: [40, 59], + 135: [60, 79], + 180: [80, 80], + } + health_group3 = { + 5: [1, 19], + 12.5: [20, 39], + 25: [40, 59], + 37.5: [60, 79], + 50: [80, 80], + } + + profession_group = { + "warrior": health_group1, + "necromancer": health_group1, + "revenant": health_group2, + "engineer": health_group2, + "ranger": health_group2, + "mesmer": health_group2, + "guardian": health_group3, + "thief": health_group3, + "elementalist": health_group3, + } + + ignore_list = [ + "HelmAquatic", + "WeaponAquaticA", + "WeaponAquaticB", + "WeaponB1", + "WeaponB2", + "Sickle", + "Axe", + "Pick", + ] + attr_dict = {key: 0 for (key) in attr_list} + runes = {} + level = character["level"] + for piece in eq: + item = await self.fetch_item(piece["id"]) or {} + if "stats" in piece: + if piece["slot"] not in ignore_list: + attributes = piece["stats"].get("attributes", {}) + for attribute in attributes: + attr_dict[attribute] += attributes[attribute] + elif "charges" not in piece: + if piece["slot"] not in ignore_list: + details = item.get("details", {}) + if "infix_upgrade" in details: + attributes = details["infix_upgrade"].get("attributes", []) + for attribute in attributes: + k = attribute["attribute"] + attr_dict[k] += int(attribute["modifier"]) + if "details" in item and "defense" in item["details"]: + if piece["slot"] not in ignore_list: + attr_dict["defense"] += int(item["details"]["defense"]) + # map old names + attr_dict["Concentration"] += attr_dict["BoonDuration"] + attr_dict["Ferocity"] += attr_dict["CritDamage"] + attr_dict["Expertise"] += attr_dict["ConditionDuration"] + attr_dict["BoonDuration"] = 0 + attr_dict["CritDamage"] = 0 + attr_dict["ConditionDuration"] = 0 + + for piece in eq: + if "upgrades" in piece: + if piece["slot"] not in ignore_list: + upgrades = piece["upgrades"] + for upgrade in upgrades: + item_upgrade = await self.fetch_item(upgrade) or {} + details = item_upgrade.get("details", {}) + if "infix_upgrade" in details: + attributes = details["infix_upgrade"].get("attributes", []) + for attribute in attributes: + k = attribute["attribute"] + attr_dict[k] += int(attribute["modifier"]) + if details.get("type") == "Rune": + runes[upgrade] = runes.get(upgrade, 0) + 1 + elif details.get("type") == "Sigil": + pattern_percentage = re.compile(r"^\+\d{1,}% ") + buff = details.get("infix_upgrade", {}).get("buff", {}) + bonus = buff.get("description", "") + if pattern_percentage.match(bonus): + modifier = re.sub(r" .*$", "", bonus) + modifier = re.sub(r"\+", "", modifier) + modifier = re.sub(r"%", "", modifier) + attribute_name = bonus.title() + attribute_name = re.sub( + " Duration", "Duration", attribute_name + ) + attribute_name = re.sub( + "Duration.*", "Duration", attribute_name + ) + attribute_name = re.sub( + " Chance", "Chance", attribute_name + ) + attribute_name = re.sub( + "Chance.*", "Chance", attribute_name + ) + attribute_name = re.sub(r"^.* ", "", attribute_name) + attribute_name = re.sub(r"\.", "", attribute_name) + if attribute_name in attr_dict: + attr_dict[attribute_name] += int(modifier) + if "infusions" in piece: + if piece["slot"] not in ignore_list: + infusions = piece["infusions"] + for infusion in infusions: + item_infusion = await self.fetch_item(infusion) or {} + details = item_infusion.get("details", {}) + if "infix_upgrade" in details: + attributes = details["infix_upgrade"].get("attributes", []) + for attribute in attributes: + if attribute["attribute"] == "BoonDuration": + attribute["attribute"] = "Concentration" + if attribute["attribute"] == "ConditionDuration": + attribute["attribute"] = "Expertise" + k2 = attribute["attribute"] + attr_dict[k2] += int(attribute["modifier"]) + + for rune, runecount in runes.items(): + rune_item = await self.fetch_item(rune) or {} + details = rune_item.get("details", {}) + bonuses = details.get("bonuses", []) + count = 0 + # patterns for rune bonus parsing + pattern_single = re.compile(r"^\+\d{1,} ") + pattern_all_stats = re.compile(r".* [s,S]tats$") + pattern_percentage = re.compile(r"^\+\d{1,}% ") + for bonus in bonuses: + if count < runecount: + if pattern_all_stats.match(bonus): + modifier = re.sub(r" .*$", "", bonus) + modifier = re.sub(r"\+", "", modifier) + for k in [ + "Power", + "Vitality", + "Toughness", + "Precision", + "Ferocity", + "Healing", + "ConditionDamage", + ]: + attr_dict[k] += int(modifier) + elif pattern_single.match(bonus): + modifier = re.sub(r" .*$", "", bonus) + modifier = re.sub(r"\+", "", modifier) + attribute_name = re.sub(" Damage", "Damage", bonus) + attribute_name = re.sub("Damage.*", "Damage", attribute_name) + attribute_name = re.sub(r"\+\d{1,} ", "", attribute_name) + attribute_name = re.sub(";.*", "", attribute_name) + if attribute_name in attr_dict: + attr_dict[attribute_name] += int(modifier) + elif pattern_percentage.match(bonus): + modifier = re.sub(r" .*$", "", bonus) + modifier = re.sub(r"\+", "", modifier) + modifier = re.sub(r"%", "", modifier) + attribute_name = re.sub(" Duration", "Duration", bonus) + attribute_name = re.sub( + "Duration.*", "Duration", attribute_name + ) + attribute_name = re.sub(r"^.* ", "", attribute_name) + if attribute_name in attr_dict: + attr_dict[attribute_name] += int(modifier) + count += 1 + + basevalue = calc_base_lvl(level, 0, lvl_dict) + attr_dict["Power"] += basevalue + attr_dict["Vitality"] += basevalue + attr_dict["Toughness"] += basevalue + attr_dict["Precision"] += basevalue + attr_dict["CritDamage"] += int(round(150 + attr_dict["Ferocity"] / 15, 2)) + attr_dict["BoonDuration"] += int(round(attr_dict["Concentration"] / 15, 2)) + attr_dict["ConditionDuration"] += int(round(attr_dict["Expertise"] / 15, 2)) + base_prec = 0 if attr_dict["Precision"] < 1000 else 1000 + crit = int(round(4 + ((attr_dict["Precision"] - base_prec) / 21), 2)) + attr_dict["Critical Chance"] = crit + attr_dict["defense"] += attr_dict["Toughness"] + + prof = character["profession"].lower() + attr_dict["Health"] = calc_base_health(level, 0, profession_group[prof]) + attr_dict["Health"] += attr_dict["Vitality"] * 10 + + ordered_list = ( + "Power", + "Toughness", + "Vitality", + "Precision", + "Ferocity", + "ConditionDamage", + "Expertise", + "Concentration", + "AgonyResistance", + "defense", + "Health", + "Critical Chance", + "CritDamage", + "Healing", + "ConditionDuration", + "BoonDuration", + ) + output = {} + for attribute in ordered_list: + if attribute in percentage_list: + output_name = self.readable_attribute(attribute).title() + output[output_name] = f"{int(round(attr_dict[attribute]))}%" + else: + output_name = self.readable_attribute(attribute).title() + output[output_name] = attr_dict[attribute] + return output diff --git a/guildwars2/core/services/inventory.py b/guildwars2/core/services/inventory.py new file mode 100644 index 0000000..29cfeef --- /dev/null +++ b/guildwars2/core/services/inventory.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any, DefaultDict, Dict, Iterable, List, Tuple, cast + + +class InventoryService: + """Service for inventory and item-location related operations. + + This is a Discord-free port of the old AccountMixin.find_items_in_account + so other layers (adapters, mixins) can reuse it without duplicating logic. + """ + + def __init__(self, api_client): + self.api = api_client + + async def find_items_in_account( + self, + *, + key: str, + item_ids: Iterable[int], + doc: Dict[str, Any] | None = None, + flatten: bool = False, + search: bool = False, + results: Tuple[Any, Any, Any, Any] | None = None, + ): + """Search account storages and characters for specific items. + + Inputs: + - key: API key with `inventories` and `characters` scopes. + - item_ids: Iterable of item ids to search. + - doc: Optional account document (used for permissions for TP delivery check). + - flatten: If True, collapse counts across item ids into a single mapping + of location -> count. Otherwise returns per-item mapping. + - search: If True, track [inventory_count, equipped_count] per location. + - results: Optional tuple (bank, shared, materials, characters) to reuse + pre-fetched API results. + + Output: + - If flatten is False: {item_id: {location: count or [inv, gear]}} + - If flatten is True: {location: total_count or [inv, gear]} + """ + + item_ids = list(item_ids) + + if results is None: + endpoints = ( + "account/bank", + "account/inventory", + "account/materials", + "characters?page=0&page_size=200", + ) + bank, shared, materials, characters = await self.api.get_many( + endpoints, key=key, schema_string="2021-07-15T13:00:00.000Z" + ) + else: + bank, shared, materials, characters = results + + spaces = {"bank": bank, "shared": shared, "material storage": materials} + legendary_armory_item_ids: set[int] = set() + + if search: + counts: Dict[int, DefaultDict[str, Any]] = { + item_id: defaultdict(lambda: [0, 0]) for item_id in item_ids + } + else: + counts = {item_id: defaultdict(int) for item_id in item_ids} + + def get_amount(slot: Dict[str, Any] | None, item_id: int) -> int: + def count_upgrades(slots: List[int] | None) -> int: + return sum(1 for i in (slots or []) if i == item_id) + + if not slot: + return 0 + if slot.get("id") == item_id: + return int(slot.get("count", 1)) + if "infusions" in slot: + infusions_sum = count_upgrades(cast(List[int], slot["infusions"])) + if infusions_sum: + return infusions_sum + if "upgrades" in slot: + upgrades_sum = count_upgrades(cast(List[int], slot["upgrades"])) + if upgrades_sum: + return upgrades_sum + return 0 + + def amounts_in_space(space, name: str, geared: bool): + space_name = name + for s in space or []: + if geared and str(s.get("location", "")).endswith("LegendaryArmory"): + if s.get("id") in legendary_armory_item_ids: + continue + legendary_armory_item_ids.add(s.get("id")) + space_name = "legendary armory" + for item_id in item_ids: + amt = get_amount(s, item_id) + if amt: + if search: + lst = cast(List[int], counts[item_id][space_name]) + if geared: + lst[1] += amt + else: + lst[0] += amt + else: + prev = cast(int, counts[item_id][space_name]) + counts[item_id][space_name] = prev + amt + + for name, space in spaces.items(): + amounts_in_space(space, name, False) + for character in characters: + amounts_in_space(character.get("bags", []), character.get("name"), False) + bags = [ + bag.get("inventory") for bag in filter(None, character.get("bags", [])) + ] + for bag in bags: + amounts_in_space(bag, character.get("name"), False) + for tab in character.get("equipment_tabs", []) or []: + amounts_in_space(tab.get("equipment", []), character.get("name"), True) + + # TP delivery (requires tradingpost permission) + try: + if doc and "tradingpost" in (doc.get("permissions", []) or []): + result = await self.api.get("commerce/delivery", key=key) + delivery = result.get("items", []) + amounts_in_space(delivery, "TP delivery", False) + except Exception: + # Swallow API errors to avoid failing the whole search + pass + + if flatten: + if search: + flattened: Dict[str, Any] = defaultdict(lambda: [0, 0]) + else: + flattened = defaultdict(int) # type: ignore[assignment] + for count_dict in counts.values(): + for k, v in count_dict.items(): + if search and isinstance(v, list): + lst = cast(List[int], flattened[k]) + lst[0] += v[0] + lst[1] += v[1] + else: + flattened[k] += v + return flattened + + return counts diff --git a/guildwars2/core/services/keys.py b/guildwars2/core/services/keys.py new file mode 100644 index 0000000..ac34c50 --- /dev/null +++ b/guildwars2/core/services/keys.py @@ -0,0 +1,46 @@ +from typing import Any, Iterable, Optional + +from ...exceptions import APIKeyError + + +class KeyService: + """Centralized API key retrieval and scope validation. + + Transitional: uses the bot.database handle until a dedicated repository exists. + """ + + def __init__(self, bot_database): + self._db = bot_database + + async def get_key_for_user( + self, + user: Any, + *, + required_scopes: Optional[Iterable[str]] = None, + cog: Any = None, + ) -> dict: + """Return the stored key document for the user or raise APIKeyError. + + Returns the full key document (containing 'key', 'permissions', + 'account_name', etc.). + """ + doc = await self._db.get_user(user, cog) + if not doc or "key" not in doc or not doc["key"]: + raise APIKeyError( + "No API key associated with your account. " + "Add your key using `/key add` command. If you don't know " + "how, the command includes a tutorial." + ) + if required_scopes: + missing = [ + s for s in required_scopes if s not in doc["key"].get("permissions", []) + ] + if missing: + missing_str = ", ".join(missing) + # Keep message semantics similar to existing behavior + raise APIKeyError( + f"{user.mention}, your API key is missing the following " + f"permissions to use this command: `{missing_str}`\n" + "Consider adding a new key with those permissions checked" + ) + return doc["key"] diff --git a/guildwars2/data/__init__.py b/guildwars2/data/__init__.py new file mode 100644 index 0000000..d3ad8ba --- /dev/null +++ b/guildwars2/data/__init__.py @@ -0,0 +1,4 @@ +"""Data layer: repositories and caches. + +This makes `guildwars2.data` a Python package. +""" diff --git a/guildwars2/data/repositories.py b/guildwars2/data/repositories.py new file mode 100644 index 0000000..1125b07 --- /dev/null +++ b/guildwars2/data/repositories.py @@ -0,0 +1,33 @@ +class Gw2Repository: + """Thin wrappers over your Mongo collections. + + Keep it minimal and explicit; services call into here. + """ + + def __init__(self, db): + self.db = db + + async def get_world_name(self, wid: int) -> str | None: + doc = await self.db.worlds.find_one({"_id": wid}) + if not doc: + return None + return doc.get("name") + + async def get_item(self, item_id: int): + return await self.db.items.find_one({"_id": item_id}) + + async def get_stat_name(self, stat_id: int) -> str: + statset = await self.db.itemstats.find_one({"_id": stat_id}) + if not statset: + return "" + return statset.get("name", "") + + async def get_title(self, title_id: int) -> str: + doc = await self.db.titles.find_one({"_id": title_id}) + return doc.get("name", "") if doc else "" + + async def get_specialization(self, spec_id: int): + return await self.db.specializations.find_one({"_id": spec_id}) + + async def get_public_character(self, name: str): + return await self.db.characters.find_one({"name": name}) diff --git a/guildwars2/guild/sync.py b/guildwars2/guild/sync.py index dd4eb0b..9ac0119 100644 --- a/guildwars2/guild/sync.py +++ b/guildwars2/guild/sync.py @@ -579,9 +579,9 @@ def bool_to_on(setting): "the leadership of the guild", sync_type="Select how you want the synced roles to behave.", user_to_prompt="The user to prompt for authorization. Use " - "only if you've selected it as the authentication_method.", + "only if selected as the authentication_method.", api_key="The api key to use for authorization. " - "Use only if you've selected it as the authentication_method.", + "Use only if selected as the authentication_method.", ) async def guildsync_add( self, diff --git a/guildwars2/skills.py b/guildwars2/skills.py index 75df2c1..db1c3b8 100644 --- a/guildwars2/skills.py +++ b/guildwars2/skills.py @@ -267,7 +267,7 @@ def __render(self, filename): for i, skill in enumerate(self.skills, start=0): resp = session.get(skill["icon"]) skill_icon = Image.open(io.BytesIO(resp.content)) - skill_icon = skill_icon.resize((64, 64), Image.ANTIALIAS) + skill_icon = skill_icon.resize((64, 64), Image.Resampling.LANCZOS) skill_icon = skill_icon.crop( ( crop_amount, @@ -301,7 +301,7 @@ def get_trait_image(icon_url, size): resp = session.get(icon_url) image = Image.open(io.BytesIO(resp.content)) image = image.crop((4, 4, image.width - 4, image.height - 4)) - return image.resize((size, size), Image.ANTIALIAS) + return image.resize((size, size), Image.Resampling.LANCZOS) resp = session.get(specialization["background"]) background = Image.open(io.BytesIO(resp.content)) diff --git a/guildwars2/types.py b/guildwars2/types.py new file mode 100644 index 0000000..39e6c15 --- /dev/null +++ b/guildwars2/types.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any, Mapping, Protocol, TYPE_CHECKING + +if TYPE_CHECKING: + from .core.container import ServiceContainer +else: # pragma: no cover - only for type checkers + ServiceContainer = Any # type: ignore + + +class GuildWars2Cog(Protocol): + """Protocol describing the minimal surface we use from the GW2 Cog. + + This enables editor autocomplete in command modules without importing + the actual Cog type (avoids circular imports during runtime). + """ + + container: ServiceContainer + db: Any + gamedata: Mapping[str, Any] + bot: Any + + async def get_embed_color(self, ctx) -> int: ... + + def get_emoji( + self, interaction, name: str, *, fallback: bool = False, fallback_fmt: str = "" + ) -> str: ... + + # Common helpers present on the Cog used throughout commands + async def call_api(self, *args: Any, **kwargs: Any) -> Any: ... + async def fetch_key(self, *args: Any, **kwargs: Any) -> Any: ... + def check_emoji_permission(self, interaction: Any) -> bool: ... + def format_age(self, seconds: int, short: bool = False) -> str: ... + async def get_world_name(self, world_id: int) -> str: ... diff --git a/guildwars2/wallet.py b/guildwars2/wallet.py index d67a0ba..863775f 100644 --- a/guildwars2/wallet.py +++ b/guildwars2/wallet.py @@ -26,18 +26,20 @@ async def get_wallet(self, interaction: discord.Interaction, ids): for i in range(0, len(lines)): if id in ids[i]: try: - cur = next(item for item in results - if item["id"] == id) + cur = next(item for item in results if item["id"] == id) value = cur["value"] except StopIteration: value = 0 if c_doc["name"] == "Coin": - lines[i].append("{} {} {}".format( - emoji, self.gold_to_coins(interaction, value), - c_doc["name"])) + lines[i].append( + "{} {} {}".format( + emoji, + self.gold_to_coins(interaction, value), + c_doc["name"], + ) + ) else: - lines[i].append("{} {} {}".format( - emoji, value, c_doc["name"])) + lines[i].append("{} {} {}".format(emoji, value, c_doc["name"])) return lines # Searches account for items and returns list of strings @@ -46,9 +48,9 @@ async def get_item_currency(self, interaction: discord.Interaction, ids): lines = [] flattened_ids = [y for x in ids for y in x] doc = await self.fetch_key(interaction.user, scopes) - search_results = await self.find_items_in_account(interaction.user, - flattened_ids, - doc=doc) + search_results = await self.find_items_in_account( + interaction.user, flattened_ids, doc=doc + ) for i in range(0, len(ids)): lines.append([]) @@ -56,14 +58,14 @@ async def get_item_currency(self, interaction: discord.Interaction, ids): if k in ids[i]: doc = await self.db.items.find_one({"_id": k}) name = doc["name"] - name = re.sub(r'^\d+ ', '', name) + name = re.sub(r"^\d+ ", "", name) emoji = self.get_emoji(interaction, name) - lines[i].append("{} {} {}".format(emoji, sum(v.values()), - name)) + lines[i].append("{} {} {}".format(emoji, sum(v.values()), name)) return lines - async def currency_autocomplete(self, interaction: discord.Interaction, - current: str): + async def currency_autocomplete( + self, interaction: discord.Interaction, current: str + ): current = current.lower() if not current: return [] @@ -75,33 +77,34 @@ async def currency_autocomplete(self, interaction: discord.Interaction, return [Choice(name=it["name"], value=str(it["_id"])) for it in items] @app_commands.command() - @app_commands.describe(currency="The specific currency to search for. " - "Leave blank for general overview.") + @app_commands.describe( + currency="The specific currency to search for. " + "Leave blank for general overview." + ) @app_commands.autocomplete(currency=currency_autocomplete) - async def wallet(self, - interaction: discord.Interaction, - currency: str = None): + async def wallet(self, interaction: discord.Interaction, currency: str = None): """Shows your wallet""" if currency: try: currency = int(currency) except ValueError: - results = await self.currency_autocomplete( - interaction, currency) + results = await self.currency_autocomplete(interaction, currency) if not results: return await interaction.response.send_message( "No results found for {}".format(currency.title()), - ephemeral=True) + ephemeral=True, + ) currency = int(results[0].value) await interaction.response.defer() doc = await self.fetch_key(interaction.user, ["wallet"]) if currency is not None: results = await self.call_api("account/wallet", key=doc["key"]) choice = await self.db.currencies.find_one({"_id": currency}) - embed = discord.Embed(title=choice["name"].title(), - description=choice["description"], - colour=await - self.get_embed_color(interaction)) + embed = discord.Embed( + title=choice["name"].title(), + description=choice["description"], + colour=await self.get_embed_color(interaction), + ) currency_id = choice["_id"] for item in results: if item["id"] == currency_id == 1: @@ -114,10 +117,12 @@ async def wallet(self, count = 0 embed.add_field(name="Amount in wallet", value=count, inline=False) embed.set_thumbnail(url=choice["icon"]) - embed.set_author(name=doc["account_name"], - icon_url=interaction.user.display_avatar.url) - embed.set_footer(text=self.bot.user.name, - icon_url=self.bot.user.display_avatar.url) + embed.set_author( + name=doc["account_name"], icon_url=interaction.user.display_avatar.url + ) + embed.set_footer( + text=self.bot.user.name, icon_url=self.bot.user.display_avatar.url + ) return await interaction.followup.send(embed=embed) ids_cur = [1, 4, 2, 3, 18, 23, 16, 50, 47] ids_keys = [43, 40, 41, 37, 42, 38, 44, 49, 51] @@ -136,72 +141,81 @@ async def wallet(self, ids_soto_cur = [62, 63, 72, 73, 75] ids_strikes_cur = [53, 55, 57, 54] ids_wallet = [ - ids_cur, ids_keys, ids_maps, ids_token, ids_raid, ids_ibs_cur, - ids_strikes_cur, ids_eod_cur, ids_wvw_cur, ids_pvp_cur, ids_soto_cur + ids_cur, + ids_keys, + ids_maps, + ids_token, + ids_raid, + ids_ibs_cur, + ids_strikes_cur, + ids_eod_cur, + ids_wvw_cur, + ids_pvp_cur, + ids_soto_cur, ] ids_items = [ids_l3, ids_l4, ids_ibs, ids_maps_items, ids_pvp] currencies_wallet = await self.get_wallet(interaction, ids_wallet) currencies_items = await self.get_item_currency(interaction, ids_items) - embed = discord.Embed(description="Wallet", - colour=await self.get_embed_color(interaction)) - embed = embed_list_lines(embed, - currencies_wallet[0], - "> **CURRENCIES**", - inline=True) - embed = embed_list_lines(embed, - currencies_wallet[3], - "> **DUNGEON TOKENS**", - inline=True) - embed = embed_list_lines(embed, - currencies_wallet[1], - "> **KEYS**", - inline=True) - embed = embed_list_lines(embed, - currencies_wallet[2][2:5] + - currencies_wallet[2][5:], - "> **MAP CURRENCIES**", - inline=True) - embed = embed_list_lines(embed, - currencies_items[0] + - [currencies_wallet[2][0]], - "> **LIVING SEASON 3**", - inline=True) - embed = embed_list_lines(embed, - currencies_items[1] + - [currencies_wallet[2][1]], - "> **LIVING SEASON 4**", - inline=True) + embed = discord.Embed( + description="Wallet", colour=await self.get_embed_color(interaction) + ) + embed = embed_list_lines( + embed, currencies_wallet[0], "> **CURRENCIES**", inline=True + ) + embed = embed_list_lines( + embed, currencies_wallet[3], "> **DUNGEON TOKENS**", inline=True + ) + embed = embed_list_lines(embed, currencies_wallet[1], "> **KEYS**", inline=True) + embed = embed_list_lines( + embed, + currencies_wallet[2][2:5] + currencies_wallet[2][5:], + "> **MAP CURRENCIES**", + inline=True, + ) + embed = embed_list_lines( + embed, + currencies_items[0] + [currencies_wallet[2][0]], + "> **LIVING SEASON 3**", + inline=True, + ) + embed = embed_list_lines( + embed, + currencies_items[1] + [currencies_wallet[2][1]], + "> **LIVING SEASON 4**", + inline=True, + ) saga_title = "ICEBROOD SAGA" expansion_content = random.random() >= 0.85 if expansion_content: saga_title = "EXPANSION LEVEL CONTENT" - embed = embed_list_lines(embed, - currencies_items[2] + currencies_wallet[5], - f"> **{saga_title}**", - inline=True) - embed = embed_list_lines(embed, - currencies_wallet[7], - "> **END OF DRAGONS**", - inline=True) - embed = embed_list_lines(embed, - currencies_wallet[10], - "> **SECRETS OF THE OBSCURE**", - inline=True) - embed = embed_list_lines(embed, - currencies_wallet[6], - "> **STRIKE MISSIONS**", - inline=True) - embed = embed_list_lines(embed, - currencies_wallet[8] + currencies_items[4] + - currencies_wallet[9], - "> **COMPETITION**", - inline=True) - embed = embed_list_lines(embed, - currencies_wallet[4], - "> **RAIDS**", - inline=True) - embed.set_author(name=doc["account_name"], - icon_url=interaction.user.display_avatar.url) - embed.set_footer(text=self.bot.user.name, - icon_url=self.bot.user.display_avatar.url) + embed = embed_list_lines( + embed, + currencies_items[2] + currencies_wallet[5], + f"> **{saga_title}**", + inline=True, + ) + embed = embed_list_lines( + embed, currencies_wallet[7], "> **END OF DRAGONS**", inline=True + ) + embed = embed_list_lines( + embed, currencies_wallet[10], "> **SECRETS OF THE OBSCURE**", inline=True + ) + embed = embed_list_lines( + embed, currencies_wallet[6], "> **STRIKE MISSIONS**", inline=True + ) + embed = embed_list_lines( + embed, + currencies_wallet[8] + currencies_items[4] + currencies_wallet[9], + "> **COMPETITION**", + inline=True, + ) + embed = embed_list_lines( + embed, currencies_wallet[4], "> **RAIDS**", inline=True + ) + embed.set_author( + name=doc["account_name"], icon_url=interaction.user.display_avatar.url + ) + embed.set_footer( + text=self.bot.user.name, icon_url=self.bot.user.display_avatar.url + ) await interaction.followup.send(embed=embed) diff --git a/requirements.txt b/requirements.txt index 4586557..143581a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,5 @@ pillow requests tenacity matplotlib -discord-py-interactions httpx html2markdown