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)
+
+ - Go to Hoyolab and log into your account.
+ - Press
F12
to open Developer Tools and click on Console
tab.
+ - In the terminal, next to a right arrow
>
type in document.cookie
and copy the output.
+ - 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.
+
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 .