diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..d86e497 --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +# ignore = E302 +per-file-ignores = __init__.py:F401, constants.py:E501 +max-line-length = 99 +exclude = .git, .venv, .tox, __pycache__ diff --git a/.github/red_data/cogs/CogManager/settings.json b/.github/red_data/cogs/CogManager/settings.json new file mode 100644 index 0000000..9c68377 --- /dev/null +++ b/.github/red_data/cogs/CogManager/settings.json @@ -0,0 +1,10 @@ +{ + "2938473984732": { + "GLOBAL": { + "paths": [ + "C:\\Projects\\raiden-cogs", + "/home/runner/work/raiden-cogs/raiden-cogs" + ] + } + } +} diff --git a/.github/red_data/core/settings.json b/.github/red_data/core/settings.json new file mode 100644 index 0000000..e0217d2 --- /dev/null +++ b/.github/red_data/core/settings.json @@ -0,0 +1,11 @@ +{ + "0": { + "CUSTOM_GROUPS": {}, + "GLOBAL": { + "prefix": [ + "!" + ], + "schema_version": 2 + } + } +} \ No newline at end of file diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..2a09d39 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,51 @@ +name: Checks + +on: + push: + pull_request: + +# thanks red or wherever you got it from +jobs: + tox: + runs-on: ubuntu-latest + strategy: + matrix: + python_version: + - "3.8" + tox_env: + - style-black + - style-ruff + include: + - tox_env: style-black + friendly_name: Style (black) + - tox_env: style-ruff + friendly_name: Style (ruff) + + fail-fast: false + + name: Tox - ${{ matrix.python_version }} - ${{ matrix.friendly_name }} + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ env.ref }} + - name: Set up Python ${{ matrix.python_version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python_version }} + + # caching cuts down time for tox (for example black) from ~40 secs to 4 + - name: Cache tox + uses: actions/cache@v3 + with: + path: .tox + key: tox-${{ matrix.python_version }}-${{ matrix.tox_env }}-${{ hashFiles('tox.ini') }} + + - name: Install tox + run: | + python -m pip install --upgrade pip + pip install tox + - name: "Run tox: ${{ matrix.friendly_name }}" + env: + TOXENV: ${{ matrix.tox_env }} + run: | + tox diff --git a/.github/workflows/loadcheck.yml b/.github/workflows/loadcheck.yml new file mode 100644 index 0000000..7abe9c5 --- /dev/null +++ b/.github/workflows/loadcheck.yml @@ -0,0 +1,64 @@ +name: "Cog load test" + +on: + push: + +jobs: + loadcheck: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + red-version: + - "git+https://github.com/Cog-Creators/Red-DiscordBot@V3/develop#egg=Red-DiscordBot" + - "Red-DiscordBot" + include: + - red-version: "git+https://github.com/Cog-Creators/Red-DiscordBot@V3/develop#egg=Red-DiscordBot" + friendly-red: "Red (Latest)" + - red-version: "Red-DiscordBot" + friendly-red: "Red (Stable)" + fail-fast: false + + name: Cog load test - Python ${{ matrix.python-version }} & ${{ matrix.friendly-red }} + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache venv + id: cache-venv + uses: actions/cache@v3 + with: + path: .venv + key: ${{ matrix.red-version }}-${{ matrix.python-version }}-${{ hashFiles('dev-requirements.txt') }}-${{ secrets.CACHE_V }} + + - name: Maybe make venv + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + python3 -m venv .venv + source .venv/bin/activate + pip install --upgrade pip + pip install setuptools wheel + pip install ${{ matrix.red-version }} + pip install . + pip install jsonrpc-websocket + - name: Prepare files + run: | + mkdir -p /home/runner/.config/Red-DiscordBot + echo '{"workflow": {"DATA_PATH": "/home/runner/work/raiden-cogs/raiden-cogs/.github/red_data", "COG_PATH_APPEND": "cogs", "CORE_PATH_APPEND": "core", "STORAGE_TYPE": "JSON", "STORAGE_DETAILS": {}}}' > /home/runner/.config/Red-DiscordBot/config.json + - name: Run script loadcheck.py + run: | + source .venv/bin/activate + python .github/workflows/scripts/loadcheck.py + env: + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_TEST_BOT }} + + - name: Save Red output as Artifact + if: always() # still run if prev step failed + uses: actions/upload-artifact@v3 + with: + name: "Red log - Python ${{ matrix.python-version }} & ${{ matrix.friendly-red }}" + path: red.log diff --git a/.github/workflows/scripts/loadcheck.py b/.github/workflows/scripts/loadcheck.py new file mode 100644 index 0000000..7c9c217 --- /dev/null +++ b/.github/workflows/scripts/loadcheck.py @@ -0,0 +1,92 @@ +# https://github.com/Vexed01/Vex-Cogs/blob/master/.github/workflows/scripts/loadcheck.py +import asyncio +import os +import subprocess +import sys +import time +from typing import Any, Dict, Tuple + +from dotenv import load_dotenv +from jsonrpc_websocket import Server +from redbot import __version__ as red_str_ver + +load_dotenv(".env") + +token = os.environ.get("DISCORD_BOT_TOKEN") + +python_version = subprocess.check_output(["python", "-V"]).decode("utf-8") + +print("=== Red's logs are available to view as an Artifact on the main matrix page ===\n") + +print(f"Starting Red {red_str_ver} with {python_version}") + +file = open("red.log", "w") +proc = subprocess.Popen( + f"redbot workflow --no-prompt --token {token} --rpc --debug", + stdout=file, + stderr=subprocess.STDOUT, + shell=True, +) + +# let Red boot up +time.sleep(10) + +cogs = [ + "choose", + "genshinutils", + "longcat", + "throw", +] + + +async def leswebsockets() -> Tuple[Dict[str, Any], Dict[str, Any]]: + print("Connecting to Red via RPC") + + server = Server("ws://localhost:6133") + try: + await server.ws_connect() + + print("Loading cogs") + load_results: Dict[str, Any] = await server.CORE__LOAD(cogs) + await asyncio.sleep(1) + print("Unloading cogs") + unload_results: Dict[str, Any] = await server.CORE__UNLOAD(cogs) + finally: + await server.close() + + return load_results, unload_results + + +load, unload = asyncio.run(leswebsockets()) + +print("Stopping Red") + +proc.terminate() + +exit_code = 0 + +fail_load = [] +for i in ( + "failed_packages", + "invalid_pkg_names", + "notfound_packages", + "alreadyloaded_packages", + "failed_with_reason_packages", +): + fail_load.extend(load[i]) + +if fail_load: + exit_code = 1 + print("\N{CROSS MARK} Failed to load cogs " + ", ".join(fail_load)) + print("See the artifact on the main matrix page for more information") +else: + print("\N{HEAVY CHECK MARK} Loaded all cogs successfully") + +if unload["notloaded_packages"]: + exit_code = 1 + print("\N{CROSS MARK} Failed to unload cogs " + ", ".join(unload["notloaded_packages"])) + print("See the artifact on the main matrix page for more information") +else: + print("\N{HEAVY CHECK MARK} Unloaded all cogs successfully") + +sys.exit(exit_code) diff --git a/.gitignore b/.gitignore index f5aa38f..71ce0f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -.vscode/ Pipfile Pipfile.lock @@ -154,3 +153,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +.ruff_cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3cfc03b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-ast + - id: check-json + - id: pretty-format-json + args: ["--autofix", "--indent", "4"] + - id: end-of-file-fixer + - id: mixed-line-ending + - repo: https://github.com/psf/black + rev: "22.12.0" + hooks: + - id: black + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: 'v0.0.261' + hooks: + - id: ruff diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9ae86e4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "autoDocstring.docstringFormat": "one-line-sphinx", + "autoDocstring.guessTypes": true, + "python.terminal.activateEnvInCurrentTerminal": true, + "python.terminal.activateEnvironment": true +} diff --git a/README.md b/README.md index b0b4df9..fafe013 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ -

+ +


- +

-

A collection of badly written cogs I made for fun in the process of learning Python.

+

A collection of badly written homemade cogs I made for fun in the process of learning Python.

+

Support: Join my Discord server or mention Raiden#5008 in Red Cog Support server.

Installation

@@ -16,6 +18,7 @@ [p]repo add raiden-cogs https://github.com/raidensakura/raiden-cogs/ [p]cog install raiden-cogs ``` +[p] is your prefix

List of Cogs

@@ -42,29 +45,29 @@ choose A cog to replace Red Bot's General cog choose command to something more intuitive. + + + genshinutils + Multipurpose Genshin Impact oriented cog. Able to retrieve game profile data, character data and many more. +

Dev Stuff

Formatting

-

To make sure things stay nice and clean.

+

For manual formatting, this repo uses these:

```py -pip install -U black isort -black . ; isort . +pip install .[dev] +black . ; ruff . ``` -

.vscode/settings.json

-

To make sure the venv always open when I work on cogs.

- -```json -{ - "python.terminal.activateEnvironment": true, - "python.terminal.activateEnvInCurrentTerminal": true, - "python.defaultInterpreterPath": "C:\\Users\\Raiden\\redenv\\Scripts\\python.exe" -} +

Pre-commit hooks

+

Optional but it keeps manual formatting work away from you.

+```py +pre-commit install ```

Credits

@@ -73,6 +76,9 @@ black . ; isort .

+ +

(Back to top)

diff --git a/choose/__init__.py b/choose/__init__.py index f2b247d..7bf1181 100644 --- a/choose/__init__.py +++ b/choose/__init__.py @@ -1,7 +1,7 @@ import json from pathlib import Path -from .choose import setup +from .choose import setup # noqa: F401 with open(Path(__file__).parent / "info.json") as fp: __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] diff --git a/choose/choose.py b/choose/choose.py index 9e5e256..42da803 100644 --- a/choose/choose.py +++ b/choose/choose.py @@ -26,7 +26,7 @@ def cog_unload(self): if old_choose: try: self.bot.remove_command("choose") - except: + except Exception: pass self.bot.add_command(old_choose) @@ -36,7 +36,10 @@ def format_help_for_context(self, ctx: commands.Context) -> str: """ pre_processed = super().format_help_for_context(ctx) s = "s" if len(self.__author__) > 1 else "" - return f"{pre_processed}\n\nAuthor{s}: {', '.join(self.__author__)}\nCog Version: {self.__version__}" + return ( + f"{pre_processed}\n\nAuthor{s}: {', '.join(self.__author__)}\n" + f"Cog Version: {self.__version__}" + ) async def red_delete_data_for_user(self, **kwargs) -> None: """Nothing to delete""" @@ -55,11 +58,12 @@ async def choose(self, ctx, *, options): choosearray = split(r";|,|\n|\||#", options) if len(choosearray) > 1: - e = discord.Embed( - color=(await ctx.embed_colour()), title=random.choice(choosearray) - ) + e = discord.Embed(color=(await ctx.embed_colour()), title=random.choice(choosearray)) e.set_footer( - text=f"โœจ Choosing for {ctx.author.display_name}, from a list of {len(choosearray)} options." + text=( + f"โœจ Choosing for {ctx.author.display_name}, " + f"from a list of {len(choosearray)} options." + ) ) else: return await ctx.send("Not enough options to pick from.") @@ -68,9 +72,7 @@ async def choose(self, ctx, *, options): return await ctx.send(embed=e) except Exception as exc: log.exception("Error trying to send choose embed.", exc_info=exc) - return await ctx.send( - "Oops, I encountered an error while trying to send the embed." - ) + return await ctx.send("Oops, I encountered an error while trying to send the embed.") async def setup(bot: Red) -> None: diff --git a/choose/info.json b/choose/info.json index 8848e40..9479fdf 100644 --- a/choose/info.json +++ b/choose/info.json @@ -1,17 +1,19 @@ { - "name": "Choose", - "short": "Choose between multiple options", + "author": [ + "raidensakura" + ], "description": "A better replacement for core `choose` command.", - "install_msg": "Thanks for installing. This cog replaces core's `choose` command with a more intuitive one.", + "disabled": false, "end_user_data_statement": "This cog does not persistently store any data about users.", - "author": ["raidensakura"], + "hidden": false, + "install_msg": "Thanks for installing. This cog replaces core's `choose` command with a more intuitive one.", + "min_bot_version": "3.4.12", + "name": "Choose", "required_cogs": {}, "requirements": [], + "short": "Choose between multiple options", "tags": [ "choose" ], - "min_bot_version": "3.4.12", - "hidden": false, - "disabled": false, "type": "COG" -} \ No newline at end of file +} diff --git a/genshinutils/README.md b/genshinutils/README.md new file mode 100644 index 0000000..0d76f40 --- /dev/null +++ b/genshinutils/README.md @@ -0,0 +1,30 @@ +

+

GenshinUtils

+
+ + + +
+ +
+
+

GenshinUtils - Multipurpose Genshin Impact cog. For now, it's able to display in-game profile information and featured character build cards.

+ + +

+ +

Installation

+ +```ini +[p]load downloader +[p]repo add raiden-cogs https://github.com/raidensakura/raiden-cogs/ +[p]cog install raiden-cogs genshin +``` + +

Obtaining Account Cookie (Hoyolab)

+
    +
  1. Go to Hoyolab and log into your account.
  2. +
  3. Press F12 to open Developer Tools and click on Console tab.
  4. +
  5. In the terminal, next to a right arrow > type in document.cookie and copy the output.
  6. +
  7. Paste the cookie next to the registration command for the bot, example:
    ?genshin register hoyolab your_cookie
    Make sure to replace ? in the command with your bot prefix.
  8. +
diff --git a/genshinutils/__init__.py b/genshinutils/__init__.py new file mode 100644 index 0000000..c09efae --- /dev/null +++ b/genshinutils/__init__.py @@ -0,0 +1,17 @@ +import json +from pathlib import Path + +from redbot.core.bot import Red + +from .genshinutils import GenshinUtils + +with open(Path(__file__).parent / "info.json") as fp: + __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] + + +async def setup(bot: Red) -> None: + cog = GenshinUtils(bot) + + r = bot.add_cog(cog) + if r is not None: + await r diff --git a/genshinutils/abc.py b/genshinutils/abc.py new file mode 100644 index 0000000..85cbd5d --- /dev/null +++ b/genshinutils/abc.py @@ -0,0 +1,14 @@ +from abc import ABC + +from redbot.core import Config +from redbot.core.bot import Red + + +class MixinMeta(ABC): + """Base class for well behaved type hint detection with composite class. + Basically, to keep developers sane when not all attributes are defined in each mixin. + """ + + def __init__(self, *_args): + self.config: Config + self.bot: Red diff --git a/genshinutils/constants.py b/genshinutils/constants.py new file mode 100644 index 0000000..f60f81c --- /dev/null +++ b/genshinutils/constants.py @@ -0,0 +1,26 @@ +""" +Mapping for character names +Key name always need to be properly capitalized and match in-game character name +String on first index always need to match formal name but in all lowercase +This make sure the profile command accepts a variation of valid character names + +Ideally I'll want to sort these by character release date +""" +common_names = { + "Kamisato Ayato": ["kamisato ayato", "ayato"], + "Kamisato Ayaka": ["kamisato ayaka", "ayaka", "ayaya"], + "Raiden Shogun": ["raiden shogun", "raiden", "shogun", "ei", "beelzebul"], + "Arataki Itto": ["arataki itto", "itto", "arataki"], + # ... +} + +""" +Mapping for character namecards +Key name always need to be properly capitalized and match in-game character name +URLs should always belong to enka.network +This make sure commands interfacing with a featured character icon always show their namecard +""" +character_namecards = { + "Raiden Shogun": "https://enka.network/ui/UI_NameCardPic_Shougun_P.png", + # ... +} diff --git a/genshinutils/daily.py b/genshinutils/daily.py new file mode 100644 index 0000000..0414cf5 --- /dev/null +++ b/genshinutils/daily.py @@ -0,0 +1,67 @@ +import logging + +import genshin +from redbot.core import commands + +from .utils import generate_embed, get_user_cookie, validate_uid + +log = logging.getLogger("red.raidensakura.genshinutils") + + +class GenshinDaily(commands.Cog): + """GenshinUtils daily command class.""" + + # https://discord.com/channels/133049272517001216/160386989819035648/1067445744497348639 + # This will get replaced by genshinutils.py's `genshin` + # Thanks Jojo#7791! + @commands.group() + async def genshin(self, ctx: commands.Context): + """GenshinUtils main command.""" + + @genshin.command() + @commands.guild_only() + @commands.bot_has_permissions(embed_links=True) + @commands.cooldown(2, 10, commands.BucketType.member) + async def daily(self, ctx: commands.Context): + """ + Redeem your daily login reward from Hoyolab for Genshin Impact. + Command require cookie registration. + """ + + async def redeem_daily(): + try: + client = genshin.Client(cookie) + client.default_game = genshin.Game.GENSHIN + signed_in, claimed_rewards = await client.get_reward_info() + reward = await client.claim_daily_reward() + except genshin.AlreadyClaimed: + e = generate_embed( + title="Genshin Impact Daily Login", + desc="Daily reward already claimed.", + color=await ctx.embed_color(), + ) + e.add_field(name="Total Login", value=f"{claimed_rewards} days") + except Exception as exc: + return await ctx.send(f"Unable to retrieve data from Hoyolab API:\n`{exc}`") + else: + signed_in = "โœ…" + e = generate_embed( + title="Genshin Impact Daily Login", + desc="Daily reward successfully claimed.", + color=await ctx.embed_color(), + ) + e.set_thumbnail(reward.icon) + e.add_field(name="Reward", value=f"{reward.name} x{reward.amount}") + e.add_field(name="Total Login", value=f"{signed_in} {claimed_rewards + 1} days") + return await ctx.send(embed=e) + + uid = await validate_uid(ctx.author, self.config) + if not uid: + return await ctx.send("You do not have a UID linked.") + + cookie = await get_user_cookie(self.config, ctx.author) + if not cookie: + return await ctx.send("No cookie.") + + async with ctx.typing(): + return await redeem_daily() diff --git a/genshinutils/genshinutils.py b/genshinutils/genshinutils.py new file mode 100644 index 0000000..f218ebd --- /dev/null +++ b/genshinutils/genshinutils.py @@ -0,0 +1,85 @@ +import logging +from abc import ABC +from typing import Literal + +from discord.ext import tasks +from enkanetwork import EnkaNetworkAPI +from redbot.core import Config, commands + +from .daily import GenshinDaily +from .notes import GenshinNotes +from .profile import GenshinProfile +from .register import GenshinRegister +from .settings import GenshinSet + +enka_client = EnkaNetworkAPI() + +log = logging.getLogger("red.raidensakura.genshinutils") + + +class CompositeMetaClass(type(commands.Cog), type(ABC)): + """This allows the metaclass used for proper type detection to coexist with discord.py's + metaclass.""" + + +class GenshinUtils( + GenshinSet, + GenshinRegister, + GenshinProfile, + GenshinNotes, + GenshinDaily, + commands.Cog, + metaclass=CompositeMetaClass, +): + """GenshinUtils commands.""" + + __author__ = ["raidensakura"] + __version__ = "1.0.0" + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, 243316261264556032, force_registration=True) + default_global = { + "schema_version": 1, + "verification": True, + "encryption_key": "", + } + default_user = {"UID": "", "ltuid": "", "ltoken": ""} + self.config.register_global(**default_global) + self.config.register_user(**default_user) + self.enka_client = enka_client + self.run_tasks.start() + + def cog_unload(self): + log.debug("Cog unload") + self.run_tasks.stop() + + def format_help_for_context(self, ctx: commands.Context) -> str: + """Thanks Sinbad!""" + pre_processed = super().format_help_for_context(ctx) + s = "s" if len(self.__author__) > 1 else "" + return ( + f"{pre_processed}\n\nAuthor{s}: {', '.join(self.__author__)}" + "\nCog Version: {self.__version__}" + ) + + async def red_delete_data_for_user( + self, + *, + requester: Literal["discord_deleted_user", "owner", "user_strict", "user"], + user_id: int, + ): + await self.config.user_from_id(user_id).clear() + + @commands.group() + async def genshin(self, ctx: commands.Context): + """GenshinUtils main command.""" + # TODO: Embed explaining what this cog does and its info + + @tasks.loop(hours=24) + async def run_tasks(self): + """Schedule tasks to run based on a set loop""" + + @run_tasks.before_loop + async def before_run_tasks(self): + await self.bot.wait_until_ready() diff --git a/genshinutils/info.json b/genshinutils/info.json new file mode 100644 index 0000000..881422b --- /dev/null +++ b/genshinutils/info.json @@ -0,0 +1,25 @@ +{ + "author": [ + "raidensakura" + ], + "description": "Various useful utilities for Genshin Impact, such as retrieving profile data and many more.", + "disabled": false, + "end_user_data_statement": "This cog stores your Genshin UID, Hoyolab ID and Hoyolab Account token if provided.", + "hidden": false, + "install_msg": "Thank you for installing my cog. This is continuously being worked on, expect rapid changes.\nI test stuff in my Discord: https://dsc.gg/transience", + "min_bot_version": "3.4.12", + "name": "GenshinUtils", + "required_cogs": {}, + "requirements": [ + "enkanetwork.py", + "aioenkanetworkcard", + "cryptography", + "genshin", + "pillow" + ], + "short": "A Genshin Impact utility cog", + "tags": [ + "genshin" + ], + "type": "COG" +} diff --git a/genshinutils/notes.py b/genshinutils/notes.py new file mode 100644 index 0000000..d894396 --- /dev/null +++ b/genshinutils/notes.py @@ -0,0 +1,110 @@ +import logging +from time import mktime + +import genshin +from redbot.core import commands + +from .utils import generate_embed, get_user_cookie, validate_uid + +log = logging.getLogger("red.raidensakura.genshinutils") + + +class GenshinNotes(commands.Cog): + """GenshinUtils diary command class.""" + + # https://discord.com/channels/133049272517001216/160386989819035648/1067445744497348639 + # This will get replaced by genshinutils.py's `genshin` + # Thanks Jojo#7791! + @commands.group() + async def genshin(self, ctx: commands.Context): + """GenshinUtils main command.""" + + @genshin.command() + @commands.guild_only() + @commands.bot_has_permissions(embed_links=True) + @commands.cooldown(2, 10, commands.BucketType.member) + async def notes(self, ctx: commands.Context): + """ + Display a Genshin Impact notes such as Resin amount, expedition status etc. + Command require cookie registration. + """ + + async def generate_diary(uid): + try: + client = genshin.Client(cookie) + data = await client.get_notes(uid) + except Exception as exc: + return await ctx.send(f"Unable to retrieve data from Hoyolab API:\n`{exc}`") + e = generate_embed( + title=f"Game Notes for {ctx.author.display_name}", + color=await ctx.embed_color(), + ) + e.add_field( + name="๐ŸŒ™ Resin", + value=( + f"**{data.current_resin} / {data.max_resin}**\n\n" + f"Time until full\n[**{data.remaining_resin_recovery_time}**]" + ), + inline=True, + ) + e.add_field( + name="๐Ÿช™ Realm Currency", + value=( + f"**{data.current_realm_currency} / {data.max_realm_currency}**\n\n" + f"Time until full\n[**{data.remaining_realm_currency_recovery_time}**]" + ), + inline=True, + ) + e.add_field( + name="๐ŸŽŸ๏ธ Weekly Resin Discounts", + value=f"**{data.remaining_resin_discounts} / {data.max_resin_discounts}**", + ) + if data.claimed_commission_reward: + bonus = "Bonus claimed." + else: + bonus = "Bonus unclaimed." + e.add_field( + name="๐Ÿ“‹ Daily Commission Status", + value=( + f"**{data.completed_commissions} / {data.max_commissions}** completed\n\n" + f"{bonus}" + ), + ) + unix_date = data.transformer_recovery_time.timetuple() + e.add_field( + name="๐Ÿ› ๏ธ Parametric Transformer", + value=f"Recovery: \n", + ) + if data.expeditions: + e.add_field( + name="๐Ÿšฉ Expedition Status", + value=f"You can deploy a maximum of **{data.max_expeditions} characters**.", + inline=False, + ) + for expedition in data.expeditions: + e.add_field( + name=f"{expedition.character.name} ({expedition.status}) ", + value=f"Time left: **{expedition.remaining_time}**", + ) + + return await ctx.send(embed=e) + + async def test_honkai(): + try: + client = genshin.Client(cookie) + data = await client.get_full_honkai_user(20177789) + except Exception as exc: + return await ctx.send(f"Unable to retrieve data from Hoyolab API:\n`{exc}`") + return await log.debug(f"```{data}```") + + uid = await validate_uid(ctx.author, self.config) + if not uid: + return await ctx.send("You do not have a UID linked.") + + cookie = await get_user_cookie(self.config, ctx.author) + if not cookie: + return await ctx.send("No cookie.") + + async with ctx.typing(): + # return await test_honkai() + return await generate_diary(uid) diff --git a/genshinutils/profile.py b/genshinutils/profile.py new file mode 100644 index 0000000..888fbe6 --- /dev/null +++ b/genshinutils/profile.py @@ -0,0 +1,202 @@ +import io +import logging +import time +from typing import Union + +import discord +import genshin +from redbot.core import commands + +from .constants import character_namecards +from .utils import ( + enka_get_character_card, + generate_embed, + get_user_cookie, + validate_char_name, + validate_uid, +) + +log = logging.getLogger("red.raidensakura.genshinutils") + + +class GenshinProfile(commands.Cog): + """GenshinUtils profile command class.""" + + # https://discord.com/channels/133049272517001216/160386989819035648/1067445744497348639 + # This will get replaced by genshinutils.py's `genshin` + # Thanks Jojo#7791! + @commands.group() + async def genshin(self, ctx: commands.Context): + """GenshinUtils main command.""" + + @genshin.command() + @commands.guild_only() + @commands.bot_has_permissions(embed_links=True) + @commands.cooldown(2, 10, commands.BucketType.member) + async def profile( + self, + ctx: commands.Context, + user_or_uid: Union[discord.Member, str, None], + *, + character: Union[str, None], + ): + """ + Display a Genshin Impact profile for a UID, Discord user or yourself. + If a character name is provided, it will display character infographic instead. + """ + + async def enka_generate_profile(uid): + try: + data = await self.enka_client.fetch_user(uid) + except Exception as exc: + return await ctx.send(f"Unable to retrieve data from enka.network:\n`{exc}`") + + e = generate_embed( + title=f"Profile for {data.player.nickname} [AR {data.player.level}]", + color=await ctx.embed_color(), + ) + if data.player.characters_preview: + char_str = "" + for character in data.player.characters_preview: + if character.name == data.player.characters_preview[0].name: + char_str += f"{character.name}" + else: + char_str += f", {character.name}" + e.add_field( + name="Characters in Showcase", + value=(f"```fix\n" f"{char_str}" f"```"), + inline=False, + ) + e.set_thumbnail(url=data.player.avatar.icon.url) + e.set_image(url=data.player.namecard.banner.url) + e.add_field(name="Signature", value=f"{data.player.signature}") + e.add_field(name="World Level", value=f"{data.player.world_level}") + e.add_field(name="Achievements", value=data.player.achievement) + e.add_field( + name="Current Spiral Abyss Floor", + value=f"{data.player.abyss_floor}-{data.player.abyss_room}", + ) + + return await ctx.send(embed=e) + + async def enka_generate_char_img(uid, char_name): + with io.BytesIO() as image_binary: + char_card = await enka_get_character_card(uid) + if not char_card: + return await ctx.send("This user does not have any character featured.") + temp_filename = str(time.time()).split(".")[0] + ".png" + log.debug(f"[generate_char_info] Pillow object for character card:\n{char_card}") + first_card = char_card.get("card").get("1-4") + first_card.save(image_binary, "PNG", optimize=True, quality=95) + image_binary.seek(0) + return await ctx.send(file=discord.File(fp=image_binary, filename=temp_filename)) + + async def genshin_generate_profile(uid): + try: + client = genshin.Client(cookie) + data = await client.get_partial_genshin_user(uid) + except Exception as exc: + return await ctx.send(f"Unable to retrieve data from Hoyolab API:\n`{exc}`") + + e = generate_embed( + title=f"Profile for {data.info.nickname} [AR {data.info.level}]", + color=await ctx.embed_color(), + ) + if data.characters: + e.set_thumbnail(url=data.characters[0].icon) + if character_namecards[data.characters[0].name.title()]: + namecard_url = character_namecards[data.characters[0].name.title()] + e.set_image(url=namecard_url) + e.add_field(name="Achievements", value=data.stats.achievements, inline=True) + e.add_field(name="Days Active", value=data.stats.days_active, inline=True) + e.add_field(name="Characters Unlocked", value=data.stats.characters, inline=True) + e.add_field( + name="Current Spiral Abyss Floor", + value=data.stats.spiral_abyss, + inline=True, + ) + total_oculi = data.stats.anemoculi + data.stats.geoculi + +data.stats.electroculi + data.stats.dendroculi + e.add_field( + name="Total Oculi Collected", + value=total_oculi, + inline=True, + ) + e.add_field( + name="Waypoints Unlocked", + value=data.stats.unlocked_waypoints, + inline=True, + ) + total_chest = data.stats.common_chests + data.stats.precious_chests + +data.stats.exquisite_chests + data.stats.luxurious_chests + +data.stats.remarkable_chests + e.add_field( + name="Total Chests Opened", + value=total_chest, + inline=True, + ) + e.add_field( + name="Domains Unlocked", + value=data.stats.unlocked_domains, + inline=True, + ) + + return await ctx.send(embed=e) + + log.debug(f"[Args] user_or_uid: {user_or_uid}") + log.debug(f"[Args] character: {character}") + + """If nothing is passed at all, we assume user is trying to generate their own profile""" + if not user_or_uid and not character: + uid = await validate_uid(ctx.author, self.config) + if not uid: + return await ctx.send("You do not have a UID linked.") + + cookie = await get_user_cookie(self.config, ctx.author) + + async with ctx.typing(): + if not cookie: + return await enka_generate_profile(uid) + + return await genshin_generate_profile(uid) + + """ + Since both args are optional: [user_or_uid] [character] + [character] could be passed as [user_or_uid] + We check and handle it appropriately + """ + if user_or_uid and not character: + uid = await validate_uid(user_or_uid, self.config) + if uid: + async with ctx.typing(): + return await enka_generate_profile(uid) + + log.debug(f"[{ctx.command.name}] Not a UID, assuming it's a character name...") + char = validate_char_name(user_or_uid) + if not char: + return await ctx.send( + "Not a valid UID or character name that's not in dictionary." + ) + + log.debug( + f"[{ctx.command.name}] Valid character name found, trying to fetch author UID..." + ) + uid = await validate_uid(ctx.author, self.config) + if not uid: + return await ctx.send("You do not have a UID linked.") + + async with ctx.typing(): + return await enka_generate_char_img(uid, char) + + """This handles if both [user_or_uid] and [character] are appropriately passed""" + if user_or_uid and character: + uid = await validate_uid(user_or_uid, self.config) + if not uid: + return await ctx.send("Not a valid UID or user does not have a UID linked.") + + char = validate_char_name(character) + if not char: + return await ctx.send("Character name invalid or not in dictionary.") + + async with ctx.typing(): + return await enka_generate_char_img(uid, char) diff --git a/genshinutils/register.py b/genshinutils/register.py new file mode 100644 index 0000000..d5f834b --- /dev/null +++ b/genshinutils/register.py @@ -0,0 +1,213 @@ +import logging +import re +from operator import attrgetter +from re import escape + +import genshin +from discord import Embed +from discord.channel import DMChannel +from redbot.core import commands + +from .utils import decrypt_config, encrypt_config, generate_embed + +log = logging.getLogger("red.raidensakura.genshinutils") + + +class GenshinRegister(commands.Cog): + """GenshinUtils register command class.""" + + # https://discord.com/channels/133049272517001216/160386989819035648/1067445744497348639 + # This will get replaced by genshinutils.py's `genshin` + # Thanks Jojo#7791! + @commands.group() + async def genshin(self, ctx: commands.Context): + """GenshinUtils main command.""" + + @genshin.group() + async def register(self, ctx: commands.Context): + """Registration commands for GenshinUtils cog.""" + + @register.command(name="uid", usage="") + @commands.cooldown(2, 5, commands.BucketType.member) + async def set_uid(self, ctx: commands.Context, uid_or_remove: str): + """ + Link or unlink a Genshin Impact UID to your Discord account. + If verification is enabled, you need to add your Discord tag to your in-game signature. + It can take up to 15 minutes for your signature to be refreshed. + """ + + async def verification_enabled(): + enabled = await self.config.verification() + return enabled + + def pass_verification(discordtag, signature): + if discordtag == signature: + return True + + if uid_or_remove.lower() == "remove" or uid_or_remove.lower() == "unlink": + await self.config.user(ctx.author).UID.clear() + return await ctx.send(f"Successfully removed UID for {ctx.author.name}.") + + uid = uid_or_remove + + if not len(uid) == 9 or not uid.isdigit(): + return await ctx.send("Invalid UID provided, it must consist of 9 digits.") + + try: + async with ctx.typing(): + data = await self.enka_client.fetch_user(uid) + except Exception as exc: + return await ctx.send(f"Unable to retrieve data from enka.network:\n`{exc}`") + + author_discord_id = f"{ctx.author.name}#{ctx.author.discriminator}" + + if await verification_enabled() and not pass_verification( + author_discord_id, data.player.signature + ): + return await ctx.send( + ( + "Your signature does not contain your Discord tag.\n" + "It may take up to 15 minutes for changes to be reflected." + ) + ) + await self.config.user(ctx.author).UID.set(uid) + return await ctx.send(f"Successfully set UID for {ctx.author.name} to {uid}.") + + """ + Important Notes: + 1. This has proprietary DM check since to preface a disclaimer. + 2. I fully acknowledge storing the encryption key along + with the encrypted data itself is bad practice. + Hoyolab account token can be used to performpotentially + dangerous account actions. Since the cog is OSS, the purpose is + to prevent bot owners from having plaintext access to them + in a way such that is require a bit of coding and encryption + knowledge to access them on demand. + """ + + @register.command() + @commands.bot_has_permissions(embed_links=True) + @commands.cooldown(2, 10, commands.BucketType.member) + async def hoyolab(self, ctx: commands.Context, *, cookie: str = None): + """Link or unlink a Hoyolab account token to your Discord account.""" + + if not isinstance(ctx.channel, DMChannel): + if cookie: + try: + await ctx.message.delete() + except Exception: + pass + + # Preface disclaimer + app_info = await self.bot.application_info() + if app_info.team: + owner = app_info.team.name + else: + owner = app_info.owner + desc = ( + "This command links your Hoyolab account token to your Discord account. " + "This allow the bot to perform various account actions on your behalf, " + "such as claiming daily login, fetching character data etc. " + "Make sure you understand the risk of sharing your token online before proceeding." + "\n\nPlease run this command in a DM channel when setting token." + "\n\nRead how to obtain your token [here](https://project-mei.xyz/genshinutils)." + ) + e = generate_embed( + title="Important Disclaimer", desc=desc, color=await ctx.embed_color() + ) + if app_info.bot_public: + public = "Can be invited by anyone." + else: + public = "Can only be invited by the owner." + e.add_field(name="Bot Owner", value=owner) + e.add_field(name="Bot Invite Link Privacy", value=public) + if ctx.me.avatar_url: + e.set_thumbnail(url=ctx.me.avatar_url) + e.set_footer(text=f"Command invoked by {ctx.author}.") + return await ctx.send(embed=e) + + if not cookie: + cog_url = "https://project-mei.xyz/genshinutils" + bot_prefix = f"{escape(ctx.prefix)}" + command_name = f"{escape(ctx.command.name)}" + msg = ( + f"**Provide a valid cookie to bind your Discord account to.**\n\n" + f"` ยป ` Instruction on how to obtain your Hoyolab cookie:\n<{cog_url}>\n\n" + f"` ยป ` For command help context: " + f"`{bot_prefix}help genshin register {command_name}`\n\n" + f"` ยป ` To read disclaimers, type this command again in any server." + ) + return await ctx.send(msg) + + # Captures 2 groups: "ltuid=" and "abcd1234" + re_uid = re.search(r"(ltuid=)([^;]*)", cookie) + re_ltoken = re.search(r"(ltoken=)([^;]*)", cookie) + + if not re_uid: + return await ctx.send("Not a valid `ltuid`.") + if not re_ltoken: + return await ctx.send("Not a valid `ltoken`.") + + ltuid = re_uid.group(2) + ltoken = re_ltoken.group(2) + + # Verify if cookie is valid + async with ctx.typing(): + try: + cookies = {"ltuid": ltuid, "ltoken": ltoken} + client = genshin.Client(cookies) + accounts = await client.get_game_accounts() + except Exception as exc: + return await ctx.send(f"Unable to retrieve data from Hoyolab API:\n`{exc}`") + """ + Accounts: [ GenshinAccount(lang="", game_biz="", level=int...), GenshinAccount(...) ] + Recognized game_biz: + bh3_global: Honkai Impact 3 Global + hk4e_global: Genshin Impact + """ + + # Filter Genshin accounts only + genshin_acc_list = [] + for account in accounts: + if account.game_biz == "hk4e_global": + genshin_acc_list.append(account) + + if not genshin_acc_list: + return await ctx.send("Couldn't find a linked Genshin UID in your Hoyolab account.") + + # https://www.geeksforgeeks.org/python-get-the-object-with-the-max-attribute-value-in-a-list-of-objects/ + # get genshin account with the highest level + highest_level_acc = max(genshin_acc_list, key=attrgetter("level")) + uid = highest_level_acc.uid + + # Save cookie in config + encoded_ltuid = await encrypt_config(self.config, ltuid) + encoded_ltoken = await encrypt_config(self.config, ltoken) + await self.config.user(ctx.author).UID.set(uid) + await self.config.user(ctx.author).ltuid.set(encoded_ltuid) + await self.config.user(ctx.author).ltoken.set(encoded_ltoken) + + # Debugging stuff + log.debug(f"Encrypted credentials saved for {ctx.author}") + + decoded_ltuid = await decrypt_config(self.config, encoded_ltuid) + + log.debug(f"Decoded ltuid for {ctx.author}: {decoded_ltuid}") + + # Send success embed + desc = ( + "Successfully bound Genshin account to your Discord account. " "Details are as follow." + ) + e = Embed( + color=(await ctx.embed_colour()), + title="Account Binding Success", + description=desc, + ) + e.add_field(name="UID", value=highest_level_acc.uid) + e.add_field(name="Nickname", value=highest_level_acc.nickname) + e.add_field(name="Server", value=highest_level_acc.server_name) + e.add_field(name="AR Level", value=highest_level_acc.level) + e.add_field(name="Language", value=highest_level_acc.lang) + e.set_thumbnail(url=ctx.message.author.avatar_url) + + return await ctx.send(embed=e) diff --git a/genshinutils/settings.py b/genshinutils/settings.py new file mode 100644 index 0000000..a23d05f --- /dev/null +++ b/genshinutils/settings.py @@ -0,0 +1,36 @@ +import logging + +from redbot.core import commands + +log = logging.getLogger("red.raidensakura.genshinutils") + + +class GenshinSet(commands.Cog): + """GenshinUtils genshinset command class.""" + + @commands.group() + async def genshinset(self, ctx: commands.Context): + """Various global settings for GenshinUtils cog.""" + + @commands.is_owner() + @genshinset.command() + async def ltoken(self, ctx: commands.Context): + """Instructions on how to set global `ltoken` secret.""" + await ctx.send(f"Use `{ctx.prefix}set api hoyolab ltoken your_ltoken_here`.") + + @commands.is_owner() + @genshinset.command() + async def ltuid(self, ctx: commands.Context): + """Instructions on how to set global `ltuid` secret.""" + await ctx.send(f"Use `{ctx.prefix}set api hoyolab ltuid your_ltuid_here`.") + + @commands.is_owner() + @genshinset.command() + async def verification(self, ctx: commands.Context, toggle: bool): + """ + Globally enable or disable UID verification for GenshinUtils cog. + Only applicable for account-linking via signature check. + """ + await self.config.verification.set(toggle) + status = "enabled" if toggle else "disabled" + return await ctx.send(f"Global UID verification has been {status}.") diff --git a/genshinutils/utils.py b/genshinutils/utils.py new file mode 100644 index 0000000..3d50abe --- /dev/null +++ b/genshinutils/utils.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import logging + +import discord +from aioenkanetworkcard import encbanner +from cryptography.fernet import Fernet + +from .constants import common_names + +log = logging.getLogger("red.raidensakura.genshinutils") + + +# https://stackoverflow.com/questions/44432945/generating-own-key-with-python-fernet +async def get_encryption_key(config): + """Fetch and convert encryption key from config + + :param object config: Red V3 Config object + :return str: Plaintext encryption key + """ + key = await config.encryption_key() + if not key or key is None: + key = Fernet.generate_key() + await config.encryption_key.set(key.decode()) + else: + key = key.encode() + return key + + +async def decrypt_config(config, encoded): + """Decrypt encrypted config data + + :param object config: Red V3 Config object + :param str encoded: encoded data + :return str: decoded data + """ + to_decode = encoded.encode() + cipher_suite = Fernet(await get_encryption_key(config)) + decoded_bytes = cipher_suite.decrypt(to_decode) + decoded = decoded_bytes.decode() + return decoded + + +async def encrypt_config(config, decoded): + """Encrypt unencrypted data to store in config + + :param object config: Red V3 Config object + :param str decoded: data to encrypt + :return str: encoded data + """ + to_encode = decoded.encode() + cipher_suite = Fernet(await get_encryption_key(config)) + encoded_bytes = cipher_suite.encrypt(to_encode) + encoded = encoded_bytes.decode() + return encoded + + +async def validate_uid(u, config): + """Return user UID from config or check if UID is valid + + :param discord.Member or str u: User or UID to check + :param object config: Red V3 Config object + :return str: UID of the user if exist or valid + """ + if isinstance(u, discord.Member): + uid = await config.user(u).UID() + if uid: + exist = "exist" + else: + exist = "does not exist" + log.debug(f"[validate_uid] UID {exist} in config.") + + elif isinstance(u, str) and len(u) == 9 and u.isdigit(): + uid = u + log.debug("[validate_uid] This is a valid UID.") + + else: + uid = None + log.debug("[validate_uid] This is not a valid UID.") + + return uid + + +def validate_char_name(arg): + """Validate character name against constants + + :param str arg: name to check + :return str: Formal name of the character if exist + """ + formal_name = {i for i in common_names if arg in common_names[i]} + if formal_name: + return str(formal_name).strip("{'\"}") + + +async def enka_get_character_card(uid, char_name=None): + """Generate one or more character build image objects in a dict + + :param str uid: UID of the player + :param str char_name: formal name of the character + :return dict: dict containing Pillow image object for the character + """ + async with encbanner.ENC(lang="en", splashArt=True, miniInfo=True) as encard: + ENCpy = await encard.enc(uids=uid) + return await encard.creat(ENCpy, 4) + + +async def get_user_cookie(config, user): + """Retrieve user cookie from config + + :param object config: Red V3 Config object + :param discord.Member user: Discord user to check for + :return cookie: Cookie object for the user + """ + ltuid_config = await config.user(user).ltuid() + ltoken_config = await config.user(user).ltoken() + + if ltuid_config and ltoken_config: + ltuid = await decrypt_config(config, ltuid_config) + ltoken = await decrypt_config(config, ltoken_config) + cookie = {"ltuid": ltuid, "ltoken": ltoken} + + return cookie + + +def generate_embed(title="", desc="", color=""): + """Generate standardized Discord Embed usable for the whole cog + + :param str title: Title of the embed, defaults to "" + :param str desc: Description of the embed, defaults to "" + :param str color: Color of the embed, defaults to "" + :return discord.Embed: Discord Embed object + """ + cog_url = "https://project-mei.xyz/genshinutils" + e = discord.Embed(title=title, description=desc, color=color, url=cog_url) + e.set_footer( + text="genshinutils cog by raidensakura", + icon_url="https://avatars.githubusercontent.com/u/120461773?s=64&v=4", + ) + return e diff --git a/longcat/info.json b/longcat/info.json index d5812c2..8a1372f 100644 --- a/longcat/info.json +++ b/longcat/info.json @@ -1,17 +1,22 @@ { - "name": "Longcat", - "short": "All hail Longcat. Improved from Aioxas' Longcat cog.", + "author": [ + "raidensakura", + "Aioxas" + ], "description": "Send a looooongcat based on how long you typed the command.", - "install_msg": "It's a looooooong loooooooong cat", + "disabled": false, "end_user_data_statement": "This cog does not persistently store any data about users.", - "author": ["raidensakura", "Aioxas"], + "hidden": false, + "install_msg": "It's a looooooong loooooooong cat", + "min_bot_version": "3.4.12", + "name": "Longcat", "required_cogs": {}, - "requirements": ["Pillow"], + "requirements": [ + "Pillow" + ], + "short": "All hail Longcat. Improved from Aioxas' Longcat cog.", "tags": [ "fun" ], - "min_bot_version": "3.4.12", - "hidden": false, - "disabled": false, "type": "COG" } diff --git a/longcat/longcat.py b/longcat/longcat.py index ee8b9da..7c50045 100644 --- a/longcat/longcat.py +++ b/longcat/longcat.py @@ -27,7 +27,10 @@ def format_help_for_context(self, ctx: commands.Context) -> str: """ pre_processed = super().format_help_for_context(ctx) s = "s" if len(self.__author__) > 1 else "" - return f"{pre_processed}\n\nAuthor{s}: {', '.join(self.__author__)}\nCog Version: {self.__version__}" + return ( + f"{pre_processed}\n\nAuthor{s}: {', '.join(self.__author__)}" + f"\nCog Version: {self.__version__}" + ) async def red_delete_data_for_user(self, **kwargs) -> None: """Nothing to delete""" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..49547df --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[project] +name = "raiden-cogs" +version = "1.0.0" +description = "Cogs for Red bot made by raidensakura" +readme = "README.md" +authors = [ + {name = "raidensakura"}, +] +license = {file = "LICENSE"} +requires-python = ">=3.8" +dependencies = [ + "tabulate", + "Pillow", + "genshin", + "enkanetwork.py", + "aioenkanetworkcard", + "cryptography", + "python-dotenv" +] + +[project.urls] +"Issue Tracker" = "https://github.com/raidensakura/raiden-cogs/issues" +"Source Code" = "https://github.com/raidensakura/raiden-cogs" + +[project.optional-dependencies] +dev = [ + "black", + "ruff", + "tox", + "pre-commit" +] + +[tool.setuptools] +py-modules = [ + "choose", + "genshinutils", + "longcat", + "throw" +] + +[tool.black] +line-length = 99 +target-version = ["py38"] +extend-exclude = ".stubs" + +[tool.isort] +profile = "black" +line_length = 99 + +[tool.ruff] +target-version = "py38" +line-length = 99 +select = ["C90", "E", "F", "I001", "PGH004", "RUF100"] +fix = true +fixable = ["I001"] +isort.combine-as-imports = true +force-exclude = true + +[tool.ruff.mccabe] +# Unlike Flake8, default to a complexity level of 10. +max-complexity = 25 diff --git a/throw/info.json b/throw/info.json index e13f47f..10f97e1 100644 --- a/throw/info.json +++ b/throw/info.json @@ -1,16 +1,21 @@ { - "name": "Throw", - "short": "Throw random stuff at your Discord friends", + "author": [ + "raidensakura", + "ow0x" + ], "description": "A cog to throw random things at your friends that may or may not upset them. Originally a roleplay cog by owo-cogs, modified by raidensakura.", + "disabled": false, "end_user_data_statement": "This cog does not persistently store any PII data or metadata about users.", - "author": ["raidensakura", "ow0x"], + "hidden": false, + "min_bot_version": "3.4.12", + "name": "Throw", "required_cogs": {}, - "requirements": ["tabulate"], + "requirements": [ + "tabulate" + ], + "short": "Throw random stuff at your Discord friends", "tags": [ "throw" ], - "min_bot_version": "3.4.12", - "hidden": false, - "disabled": false, "type": "COG" } diff --git a/throw/throw.py b/throw/throw.py index f01739b..8ae226d 100644 --- a/throw/throw.py +++ b/throw/throw.py @@ -4,11 +4,11 @@ from redbot.core import Config, commands from redbot.core.bot import Red from redbot.core.commands import Context -from redbot.core.utils.chat_formatting import bold, box, quote +from redbot.core.utils.chat_formatting import box from redbot.core.utils.menus import DEFAULT_CONTROLS, menu from tabulate import tabulate -from .constants import * +from .constants import HIT, ITEMS, MISS class Throw(commands.Cog): @@ -19,7 +19,7 @@ class Throw(commands.Cog): def __init__(self, bot: Red): self.bot = bot - self.config = Config.get_conf(self, 180109040514130509, force_registration=True) + self.config = Config.get_conf(self, 243316261264556032, force_registration=True) default_global = {"schema_version": 1} self.possible_actions = ["THROW"] default_user = {"ITEMS_THROWN": 0, "TIMES_HIT": 0} @@ -33,7 +33,10 @@ def format_help_for_context(self, ctx: commands.Context) -> str: """ pre_processed = super().format_help_for_context(ctx) s = "s" if len(self.__author__) > 1 else "" - return f"{pre_processed}\n\nAuthor{s}: {', '.join(self.__author__)}\nCog Version: {self.__version__}" + return ( + f"{pre_processed}\n\nAuthor{s}: {', '.join(self.__author__)}" + f"\nCog Version: {self.__version__}" + ) # TODO: Delete user throw stats async def red_delete_data_for_user(self, **kwargs): @@ -119,8 +122,8 @@ async def throw_stats(self, ctx: Context, *, member: discord.Member = None): def parse_actions(data, array, action: str): for key, value in data.items(): if action in key: - sent = str(data.get(f"ITEMS_THROWN", " ")).replace("0", " ") - received = str(data.get(f"TIMES_HIT", " ")).replace("0", " ") + sent = str(data.get("ITEMS_THROWN", " ")).replace("0", " ") + received = str(data.get("TIMES_HIT", " ")).replace("0", " ") array.append([action.lower(), received, sent]) for act in self.possible_actions: @@ -132,9 +135,7 @@ def get_avatar(user): return str(user.avatar_url) pages = [] - dedupe_list_1 = [ - x for i, x in enumerate(people_with_no_creativity, 1) if i % 2 != 0 - ] + dedupe_list_1 = [x for i, x in enumerate(people_with_no_creativity, 1) if i % 2 != 0] server_table = tabulate( dedupe_list_1, headers=header, colalign=colalign, tablefmt="psql" ) @@ -148,21 +149,15 @@ def get_avatar(user): for action in self.possible_actions: parse_actions(global_actions_data, global_actions_array, action) - dedupe_list_2 = [ - x for i, x in enumerate(global_actions_array, 1) if i % 2 != 0 - ] + dedupe_list_2 = [x for i, x in enumerate(global_actions_array, 1) if i % 2 != 0] global_table = tabulate( dedupe_list_2, headers=header, colalign=colalign, tablefmt="psql" ) embed = discord.Embed( colour=await ctx.embed_colour(), description=box(global_table, "nim") ) - embed.set_author( - name=f"Global Throw Stats | {user.name}", icon_url=get_avatar(user) - ) - embed.set_footer( - text=f"Requester: {ctx.author}", icon_url=get_avatar(ctx.author) - ) + embed.set_author(name=f"Global Throw Stats | {user.name}", icon_url=get_avatar(user)) + embed.set_footer(text=f"Requester: {ctx.author}", icon_url=get_avatar(ctx.author)) pages.append(embed) await menu(ctx, pages, DEFAULT_CONTROLS, timeout=60.0) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..9d53708 --- /dev/null +++ b/tox.ini @@ -0,0 +1,44 @@ +[tox] +envlist = py38, style-black, style-ruff +skipsdist = true + +[testenv] +description = Run style and static type checking. +deps = + # style + black + ruff + + # lint + gidgethub + wakeonlan + + # non-typeshed stubs + pandas-stubs + + tabulate + asyncache + rapidfuzz + plotly + pytrends + pyjson5 + expr.py + + red-discordbot + + # type + # (some are covered under below) + pyright + asyncache + +[testenv:style-black] +description = Check the style conforms with black. +envdir = {toxworkdir}/py38 + +commands = black --check . + +[testenv:style-ruff] +description = Check style conform with ruff. +envdir = {toxworkdir}/py38 + +commands = ruff check .