├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── README.rst ├── badgetools ├── __init__.py ├── badgetools.py └── info.json ├── country ├── __init__.py ├── api.py ├── country.py ├── info.json └── iso3166.py ├── info.json ├── ipinfo ├── __init__.py ├── core.py ├── info.json ├── models │ ├── __init__.py │ ├── ipdata.py │ └── ipinfo.py └── utils.py ├── jsk ├── __init__.py ├── info.json └── jsk_cog.py ├── kickstarter ├── __init__.py ├── api.py ├── info.json └── kickstarter.py ├── manim ├── LICENSE ├── README.md ├── __init__.py ├── info.json └── manim.py ├── maps ├── __init__.py ├── converter.py ├── info.json └── maps.py ├── moviedb ├── __init__.py ├── api │ ├── __init__.py │ ├── base.py │ ├── details.py │ ├── person.py │ ├── search.py │ └── suggestions.py ├── converter.py ├── embed_utils.py ├── info.json ├── moviedb.py └── utils.py ├── ocr ├── LICENSE.txt ├── __init__.py ├── converter.py ├── info.json ├── iso639.py ├── models.py ├── ocr.py └── utils.py ├── phonefinder ├── __init__.py ├── converter.py ├── info.json └── phonefinder.py ├── pokebase ├── __init__.py ├── data │ └── template.webp ├── info.json ├── pokebase.py └── utils.py ├── pyproject.toml ├── redditinfo ├── __init__.py ├── handles.py ├── info.json └── redditinfo.py ├── roleplay ├── __init__.py ├── constants.py ├── info.json └── roleplay.py ├── steamcog ├── __init__.py ├── converter.py ├── info.json ├── steamcog.py └── stores.py └── yugioh ├── __init__.py ├── api.py ├── info.json └── yugioh.py /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '0 14 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | # - name: Autobuild 56 | # uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v3 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # VS Code 132 | .vscode -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | owo-cogs 3 | ======== 4 | 5 | Some of my private cogs for Red-DiscordBot V3 made public. 6 | 7 | 8 | ------------ 9 | Installation 10 | ------------ 11 | 12 | To install cogs from this repo, you can run below commands in given order. 13 | 14 | Please replace ``[p]`` with your bot's prefix. 15 | 16 | 17 | First load core downloader cog, if not loaded already, with: 18 | 19 | .. code-block:: ini 20 | 21 | [p]load downloader 22 | 23 | Then to add repo and install my cogs, do: 24 | 25 | .. code-block:: ini 26 | 27 | [p]repo add owo https://github.com/owocado/owo-cogs 28 | 29 | 30 | To view the list of my cogs, do: 31 | 32 | .. code-block:: ini 33 | 34 | [p]cog list owo 35 | 36 | 37 | To install & load cogs from this repo: 38 | 39 | .. code-block:: ini 40 | 41 | [p]cog install owo 42 | [p]load 43 | 44 | 45 | ------- 46 | Credits 47 | ------- 48 | 49 | * `Dragon Fire `_ for being very kind and supportive guide. 50 | * `TrustyJAID `_ for providing moviepy logic & converter (also for being unbiased person). 51 | * `Fixator `_ for helping me improve badgetools cog code (also for being unbiased person). 52 | * some few good people from `Red `_ 53 | 54 | 55 | ------- 56 | Contact 57 | ------- 58 | 59 | I have a `Discord server `_ if you need any help from me. 60 | -------------------------------------------------------------------------------- /badgetools/__init__.py: -------------------------------------------------------------------------------- 1 | from discord.utils import maybe_coroutine 2 | 3 | from .badgetools import BadgeTools 4 | 5 | __red_end_user_data_statement__ = "This cog does not persistently store any PII data about users." 6 | 7 | async def setup(bot): 8 | await maybe_coroutine(bot.add_cog, BadgeTools()) 9 | -------------------------------------------------------------------------------- /badgetools/badgetools.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | from datetime import datetime, timezone 3 | 4 | import discord 5 | from dateutil import relativedelta 6 | from redbot.core import commands 7 | from redbot.core.utils import AsyncIter 8 | from redbot.core.utils.chat_formatting import pagify 9 | from redbot.core.utils.menus import close_menu, menu, DEFAULT_CONTROLS 10 | 11 | 12 | class BadgeTools(commands.Cog): 13 | """Various commands to show the stats about users' profile badges.""" 14 | 15 | __authors__ = ["ow0x"] 16 | __version__ = "1.0.0" 17 | 18 | def format_help_for_context(self, ctx: commands.Context) -> str: 19 | """Thanks Sinbad.""" 20 | return ( 21 | f"{super().format_help_for_context(ctx)}\n\n" 22 | f"Authors: {', '.join(self.__authors__)}\n" 23 | f"Cog version: v{self.__version__}" 24 | ) 25 | 26 | def badge_emoji(self, ctx, badge_name: str): 27 | cog = ctx.bot.get_cog("Userinfo") 28 | if cog is None: 29 | return f"{badge_name.replace('_', ' ').title()} :" 30 | emoji = str(cog.badge_emojis.get(badge_name)) 31 | if "848561838974697532" in emoji: 32 | emoji = "<:verified_bot:848557763328344064>" 33 | return emoji 34 | 35 | def statusmoji(self, ctx, member: discord.Member): 36 | cog = ctx.bot.get_cog("Userinfo") 37 | if cog is None: 38 | return "" 39 | if member.is_on_mobile(): 40 | return f"{cog.status_emojis.get('mobile', '📱')} " 41 | elif member.status.name == "online": 42 | return f"{cog.status_emojis.get('online', '🟢')} " 43 | elif member.status.name == "idle": 44 | return f"{cog.status_emojis.get('idle', '🟠')} " 45 | elif member.status.name == "dnd": 46 | return f"{cog.status_emojis.get('dnd', '🔴')} " 47 | elif any(a.type is discord.ActivityType.streaming for a in member.activities): 48 | return f"{cog.status_emojis.get('streaming', '🟣')} " 49 | else: 50 | return f"{cog.status_emojis.get('offline', '⚫')} " 51 | 52 | @staticmethod 53 | def _icon(guild: discord.Guild) -> str: 54 | if int(discord.__version__[0]) >= 2: 55 | return guild.icon.url if guild.icon else "" 56 | return guild.icon_url or "" 57 | 58 | @commands.command() 59 | @commands.guild_only() 60 | @commands.bot_has_permissions(embed_links=True) 61 | async def badgecount(self, ctx: commands.Context): 62 | """Shows the count of user profile badges of the server.""" 63 | async with ctx.typing(): 64 | count = Counter() 65 | # Credits to Fixator10 for improving this snippet 66 | # Thanks Fixator <3 67 | async for user in AsyncIter(ctx.guild.members): 68 | async for flag in AsyncIter(user.public_flags.all()): 69 | count[flag.name] += 1 70 | 71 | fill = len(str(ctx.guild.member_count)) - 1 72 | message = "\n".join( 73 | f"**{self.badge_emoji(ctx, k)}\u2000`{str(v).zfill(fill)}`**" 74 | for k, v in sorted(count.items(), key=lambda x: x[1], reverse=True) 75 | ) 76 | embed = discord.Embed(colour=await ctx.embed_color()) 77 | embed.set_author(name=str(ctx.guild), icon_url=self._icon(ctx.guild)) 78 | embed.description = message 79 | 80 | await ctx.send(embed=embed) 81 | 82 | @commands.is_owner() 83 | @commands.command(hidden=True) 84 | @commands.bot_has_permissions(add_reactions=True, embed_links=True) 85 | async def hasbadge(self, ctx: commands.Context, *, badge: str): 86 | """Returns the list of users with X profile badge in the server.""" 87 | badge = badge.replace(" ", "_").lower() 88 | valid_flags = "\n".join([f"> `{x}`" for x in list(discord.PublicUserFlags.VALID_FLAGS.keys())]) 89 | if badge not in valid_flags: 90 | inform = f"`{badge}` badge not found! It needs to be one of:\n\n{valid_flags}" 91 | return await ctx.send(inform) 92 | 93 | list_of = [] 94 | # Thanks Fixator <3 95 | async for user in AsyncIter(sorted(ctx.guild.members, key=lambda x: x.joined_at)): 96 | async for flag in AsyncIter(user.public_flags.all()): 97 | if flag.name == badge: 98 | list_of.append(f"{self.statusmoji(ctx, user)}{user}") 99 | 100 | output = "\n".join(list_of) 101 | pages = [] 102 | for page in pagify(output, ["\n"], page_length=1000): 103 | em = discord.Embed(colour=await ctx.embed_color(), description=page) 104 | em.set_author(name=str(ctx.guild), icon_url=self._icon(ctx.guild)) 105 | footer = f"Found {len(list_of)} users with {badge.replace('_', ' ').title()} badge!" 106 | em.set_footer(text=footer) 107 | pages.append(em) 108 | 109 | if not pages: 110 | return await ctx.send(f"I could not find any users with `{badge}` badge.") 111 | controls = {"❌": close_menu} if len(pages) == 1 else DEFAULT_CONTROLS 112 | await menu(ctx, pages, controls=controls, timeout=60.0) 113 | 114 | @commands.command() 115 | @commands.guild_only() 116 | @commands.bot_has_permissions(add_reactions=True, embed_links=True) 117 | async def boosters(self, ctx: commands.Context): 118 | """Returns the list of active boosters of the server.""" 119 | if not ctx.guild.premium_subscribers: 120 | return await ctx.send(f"{ctx.guild} does not have any boost(er)s yet.") 121 | 122 | output = "\n".join( 123 | f"{self.statusmoji(ctx, user)}`[since {self._parse_time(user.premium_since):>9}]` {user}" 124 | for user in sorted(ctx.guild.premium_subscribers, key=lambda x: x.premium_since) 125 | ) 126 | boosts, boosters = (ctx.guild.premium_subscription_count, len(ctx.guild.premium_subscribers)) 127 | pages = [] 128 | for page in pagify(output, ["\n"], page_length=1500): 129 | em = discord.Embed(colour=await ctx.embed_color(), description=page) 130 | em.set_author(name=str(ctx.guild), icon_url=self._icon(ctx.guild)) 131 | em.set_footer(text=f"{boosts} boosts • {boosters} boosters!") 132 | pages.append(em) 133 | 134 | controls = {"❌": close_menu} if len(pages) == 1 else DEFAULT_CONTROLS 135 | await menu(ctx, pages, controls=controls, timeout=60.0) 136 | 137 | @staticmethod 138 | def _parse_time(date_time): 139 | dt1 = datetime.now(timezone.utc).replace(tzinfo=None) 140 | diff = relativedelta.relativedelta(dt1, date_time) 141 | 142 | yrs, mths, days = (diff.years, diff.months, diff.days) 143 | hrs, mins, secs = (diff.hours, diff.minutes, diff.seconds) 144 | pretty = f"{yrs}y {mths}mo {days}d {hrs}h {mins}m {secs}s" 145 | return " ".join([x for x in pretty.split() if x[0] != "0"][:2]) 146 | -------------------------------------------------------------------------------- /badgetools/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BadgeTools", 3 | "short": "Various tools to show stats about your server's user profile badges.", 4 | "description": "Various tools to show stats about your server's user profile badges.", 5 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 6 | "install_msg": "⚠ **THIS COG IS NOT FOR GENERAL USE** ⚠\n This cog was only made for educational purposes only. I do not recommend installing this cog on a public bot.\nDO NOT LOAD THIS COG IF YOU DO NOT AGREE TO THE RISKS. NOT ALL RISKS HAVE BEEN EXPLAINED HERE. USE IT AT YOUR OWN RISK.", 7 | "author": ["ow0x"], 8 | "required_cogs": {}, 9 | "requirements": [], 10 | "tags": ["badgetools", "badges", "badgecount", "boosters"], 11 | "min_bot_version": "3.4.0", 12 | "hidden": true, 13 | "disabled": false, 14 | "type": "COG" 15 | } 16 | -------------------------------------------------------------------------------- /country/__init__.py: -------------------------------------------------------------------------------- 1 | from discord.utils import maybe_coroutine 2 | 3 | from .country import Country 4 | 5 | __red_end_user_data_statement__ = "This cog does not persistently store data about users." 6 | 7 | 8 | async def setup(bot): 9 | await maybe_coroutine(bot.add_cog, Country()) 10 | -------------------------------------------------------------------------------- /country/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import math 5 | from dataclasses import dataclass, field 6 | from typing import Any, Dict, Optional, Sequence, Union 7 | 8 | import aiohttp 9 | 10 | from .iso3166 import ALPHA3_CODES 11 | 12 | 13 | # credits to devon 14 | def natural_size(value: int) -> str: 15 | if value < 1000: 16 | return str(value) 17 | 18 | units = ('', 'K', 'million', 'billion') 19 | power = int(math.log(max(abs(value), 1), 1000)) 20 | return f"{value / (1000 ** power):.2f} {units[power]}" 21 | 22 | 23 | @dataclass 24 | class Currency: 25 | code: str 26 | name: str 27 | symbol: str 28 | 29 | def __str__(self) -> str: 30 | return f"{self.name} ({self.code})" 31 | 32 | 33 | @dataclass 34 | class Flags: 35 | svg: str 36 | png: str = "" 37 | 38 | def __str__(self) -> str: 39 | return self.png 40 | 41 | 42 | @dataclass 43 | class Language: 44 | name: str 45 | nativeName: str 46 | iso639_1: str = "" 47 | iso639_2: str = "" 48 | 49 | def __str__(self) -> str: 50 | return self.name 51 | 52 | 53 | @dataclass 54 | class Translation: 55 | br: str 56 | pt: str 57 | nl: str 58 | hr: str 59 | fa: str 60 | de: str 61 | es: str 62 | fr: str 63 | ja: str 64 | it: str 65 | hu: str 66 | 67 | def __str__(self) -> str: 68 | return "\n".join(f":flag_{k} `[{k.upper()}]` {v}" for k, v in self.__dict__.items() if v) 69 | 70 | 71 | @dataclass 72 | class NotFound: 73 | status: int 74 | message: str 75 | 76 | def __str__(self) -> str: 77 | return f"{self.status} {self.message}" 78 | 79 | @property 80 | def image(self) -> str: 81 | return f"https://http.cat/{self.status}" 82 | 83 | 84 | @dataclass 85 | class RegionalBloc: 86 | name: str 87 | acronym: str 88 | otherAcronyms: Optional[Sequence[str]] = None 89 | otherNames: Sequence[str] = field(default_factory=list) 90 | 91 | def __str__(self) -> str: 92 | return f"{self.name} ({self.acronym})" 93 | 94 | 95 | @dataclass 96 | class CountryData: 97 | name: str 98 | topLevelDomain: Sequence[str] 99 | alpha2Code: str 100 | alpha3Code: str 101 | callingCodes: Sequence[str] 102 | altSpellings: Sequence[str] 103 | subregion: str 104 | region: str 105 | population: int 106 | demonym: str 107 | timezones: Sequence[str] 108 | nativeName: str 109 | numericCode: str 110 | flags: Flags 111 | currencies: Sequence[Currency] 112 | languages: Sequence[Language] 113 | translations: Translation 114 | flag: str 115 | independent: bool 116 | area: Optional[float] = None 117 | borders: Sequence[str] = field(default_factory=list) 118 | capital: Optional[str] = None 119 | cioc: Optional[str] = None 120 | gini: Optional[float] = None 121 | latlng: Optional[Sequence[float]] = None 122 | regionalBlocs: Optional[Sequence[RegionalBloc]] = None 123 | 124 | @property 125 | def tld(self) -> str: 126 | return self.topLevelDomain[0] if self.topLevelDomain else "" 127 | 128 | @property 129 | def calling_codes(self) -> str: 130 | return ", ".join([f"+{c}" for c in self.callingCodes]) 131 | 132 | @property 133 | def co_ords(self) -> str: 134 | return f"{', '.join(str(x) for x in self.latlng)}" if self.latlng else "" 135 | 136 | @property 137 | def png_flag(self) -> str: 138 | return str(self.flags) 139 | 140 | @property 141 | def inhabitants(self) -> str: 142 | return natural_size(self.population) 143 | 144 | @property 145 | def shared_borders(self) -> str: 146 | return ", ".join(ALPHA3_CODES.get(code, '???') for code in self.borders) 147 | 148 | @property 149 | def trade_blocs(self) -> str: 150 | if not self.regionalBlocs: 151 | return "" 152 | return ", ".join(str(bloc) for bloc in self.regionalBlocs) 153 | 154 | @classmethod 155 | def from_dict(cls, data: dict) -> CountryData: 156 | flags = data.pop("flags", {}) 157 | currencies = data.pop("currencies", []) 158 | languages = data.pop("languages", []) 159 | translations = data.pop("translations", {}) 160 | blocs = data.pop("regionalBlocs", []) 161 | return cls( 162 | flags=Flags(**flags), 163 | currencies=[Currency(**c) for c in currencies], 164 | languages=[Language(**l) for l in languages], 165 | translations=Translation(**translations), 166 | regionalBlocs=[RegionalBloc(**b) for b in blocs], 167 | **data, 168 | ) 169 | 170 | @classmethod 171 | async def request( 172 | cls, session: aiohttp.ClientSession, country: str 173 | ) -> Union[Sequence[CountryData], NotFound]: 174 | try: 175 | async with session.get(f"https://restcountries.com/v2/name/{country}") as resp: 176 | if resp.status == 404: 177 | err_data = await resp.json() 178 | return NotFound(**err_data) 179 | if resp.status != 200: 180 | return NotFound(status=resp.status, message="") 181 | data: Sequence[Dict[str, Any]] = await resp.json() 182 | except (asyncio.TimeoutError, aiohttp.ClientError): 183 | return NotFound(status=408, message="Request timeout!") 184 | 185 | return [cls.from_dict(d) for d in data] 186 | -------------------------------------------------------------------------------- /country/country.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from urllib.parse import quote 3 | 4 | import aiohttp 5 | import discord 6 | from redbot.core import commands 7 | from redbot.core.utils.chat_formatting import humanize_number 8 | from redbot.core.utils.menus import DEFAULT_CONTROLS, menu 9 | 10 | from .api import CountryData, NotFound 11 | 12 | 13 | class Country(commands.Cog): 14 | """Shows basic statistics and info about a country.""" 15 | 16 | __authors__ = ["ow0x"] 17 | __version__ = "2.0.0" 18 | 19 | def format_help_for_context(self, ctx: commands.Context) -> str: 20 | """Thanks Sinbad.""" 21 | return ( 22 | f"{super().format_help_for_context(ctx)}\n\n" 23 | f"**Authors:** {', '.join(self.__authors__)}\n" 24 | f"**Cog version:** v{self.__version__}" 25 | ) 26 | 27 | session = aiohttp.ClientSession() 28 | 29 | def cog_unload(self) -> None: 30 | if self.session: 31 | asyncio.create_task(self.session.close()) 32 | 33 | @commands.command() 34 | @commands.bot_has_permissions(embed_links=True, add_reactions=True) 35 | async def country(self, ctx: commands.Context, *, name: str): 36 | """Fetch basic summary info about a country.""" 37 | async with ctx.typing(): 38 | result = await CountryData.request(self.session, name) 39 | if isinstance(result, NotFound): 40 | return await ctx.send(f"❌ Error: {result}! {result.image}") 41 | 42 | pages = [] 43 | for i, data in enumerate(result, start=1): 44 | colour = await ctx.embed_colour() 45 | footer = f"Page {i} of {len(result)} | Data provided by RestCountries.com" 46 | embed = self.country_embed(data, colour, footer) 47 | pages.append(embed) 48 | 49 | await menu(ctx, pages, DEFAULT_CONTROLS, timeout=90.0) 50 | 51 | @staticmethod 52 | def country_embed(data: CountryData, colour: discord.Colour, footer: str) -> discord.Embed: 53 | emb = discord.Embed(colour=colour) 54 | emb.set_author(name=data.name) 55 | wiki_link = f"https://en.wikipedia.org/wiki/{quote(data.name)}" 56 | alt_names = "" 57 | emb.set_thumbnail(url=data.png_flag) 58 | if data.altSpellings: 59 | sep = '\n' if len(data.altSpellings) > 2 else ' ' 60 | alt_names += f"**Commonly known as:** {sep}{', '.join(data.altSpellings)}\n\n" 61 | emb.description = alt_names + f"**[See Wikipedia page!]({wiki_link})**" 62 | emb.add_field(name="Population", value=f"≈ {data.inhabitants}") 63 | if data.area: 64 | emb.add_field(name="Estimated Area", value=f"{humanize_number(data.area)} sq. km.") 65 | emb.add_field(name="Calling Code(s)", value=data.calling_codes) 66 | emb.add_field(name="Capital", value=str(data.capital)) 67 | # "continent" key was changed to "region" now in API! 68 | emb.add_field(name="Continent", value=data.subregion) 69 | # Fix for Antarctica 70 | if data.currencies: 71 | emb.add_field(name="Currency", value=', '.join(str(c) for c in data.currencies)) 72 | if data.tld: 73 | emb.add_field(name="Top Level Domain", value=data.tld) 74 | if data.gini: 75 | gini_wiki = "https://en.wikipedia.org/wiki/Gini_coefficient" 76 | emb.add_field(name="GINI Index", value=f"[{data.gini}]({gini_wiki})") 77 | if data.demonym: 78 | emb.add_field(name="Demonym", value=data.demonym) 79 | if data.regionalBlocs: 80 | emb.add_field(name="Trade Bloc", value=data.trade_blocs) 81 | emb.add_field(name="Timezones", value=', '.join(data.timezones)) 82 | if len(emb.fields) in {8, 11}: 83 | emb.add_field(name="\u200b", value="\u200b") 84 | if data.borders: 85 | noun = 'countries' if len(data.borders) > 1 else 'country' 86 | emb.add_field( 87 | name=f"Shares {len(data.borders)} borders | with:", 88 | value=data.shared_borders, 89 | inline=False 90 | ) 91 | emb.set_footer(text=footer) 92 | return emb 93 | -------------------------------------------------------------------------------- /country/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Country", 3 | "short": "Fetch some basic info about a country.", 4 | "description": "Fetch some basic trivia & information about a country.", 5 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 6 | "install_msg": "Thank you for installing Country cog.", 7 | "author": ["ow0x"], 8 | "required_cogs": {}, 9 | "requirements": [], 10 | "tags": ["country", "countries"], 11 | "min_bot_version": "3.4.0", 12 | "hidden": false, 13 | "disabled": false, 14 | "type": "COG" 15 | } 16 | -------------------------------------------------------------------------------- /country/iso3166.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | # Taken from https://www.iban.com/country-codes 4 | ALPHA3_CODES: Dict[str, str] = { 5 | "AFG": "Afghanistan", 6 | "ALB": "Albania", 7 | "DZA": "Algeria", 8 | "ASM": "American Samoa", 9 | "AND": "Andorra", 10 | "AGO": "Angola", 11 | "AIA": "Anguilla", 12 | "ATA": "Antarctica", 13 | "ATG": "Antigua and Barbuda", 14 | "ARG": "Argentina", 15 | "ARM": "Armenia", 16 | "ABW": "Aruba", 17 | "AUS": "Australia", 18 | "AUT": "Austria", 19 | "AZE": "Azerbaijan", 20 | "BHS": "Bahamas", 21 | "BHR": "Bahrain", 22 | "BGD": "Bangladesh", 23 | "BRB": "Barbados", 24 | "BLR": "Belarus", 25 | "BEL": "Belgium", 26 | "BLZ": "Belize", 27 | "BEN": "Benin", 28 | "BMU": "Bermuda", 29 | "BTN": "Bhutan", 30 | "BOL": "Bolivia", 31 | "BES": "Bonaire island (Caribbean Netherlands)", 32 | "BIH": "Bosnia and Herzegovina", 33 | "BWA": "Botswana", 34 | "BVT": "Bouvet Island", 35 | "BRA": "Brazil", 36 | "IOT": "British Indian Ocean Territory", 37 | "BRN": "Brunei Darussalam", 38 | "BGR": "Bulgaria", 39 | "BFA": "Burkina Faso", 40 | "BDI": "Burundi", 41 | "CPV": "Cabo Verde", 42 | "KHM": "Cambodia", 43 | "CMR": "Cameroon", 44 | "CAN": "Canada", 45 | "CYM": "Cayman Islands", 46 | "CAF": "Central African Republic", 47 | "TCD": "Chad", 48 | "CHL": "Chile", 49 | "CHN": "China", 50 | "CXR": "Christmas Island", 51 | "CCK": "Cocos (Keeling) Islands", 52 | "COL": "Colombia", 53 | "COM": "Comoros", 54 | "COD": "Democratic Republic Congo", 55 | "COG": "Congo", 56 | "COK": "Cook Islands", 57 | "CRI": "Costa Rica", 58 | "HRV": "Croatia", 59 | "CUB": "Cuba", 60 | "CUW": "Curaçao", 61 | "CYP": "Cyprus", 62 | "CZE": "Czechia", 63 | "CIV": "Côte d'Ivoire", 64 | "DNK": "Denmark", 65 | "DJI": "Djibouti", 66 | "DMA": "Dominica", 67 | "DOM": "Dominican Republic", 68 | "ECU": "Ecuador", 69 | "EGY": "Egypt", 70 | "SLV": "El Salvador", 71 | "GNQ": "Equatorial Guinea", 72 | "ERI": "Eritrea", 73 | "EST": "Estonia", 74 | "SWZ": "Eswatini", 75 | "ETH": "Ethiopia", 76 | "FLK": "Falkland Islands [Malvinas]", 77 | "FRO": "Faroe Islands", 78 | "FJI": "Fiji", 79 | "FIN": "Finland", 80 | "FRA": "France", 81 | "GUF": "French Guiana", 82 | "PYF": "French Polynesia", 83 | "ATF": "French Southern Territories", 84 | "GAB": "Gabon", 85 | "GMB": "Gambia", 86 | "GEO": "Georgia", 87 | "DEU": "Germany", 88 | "GHA": "Ghana", 89 | "GIB": "Gibraltar", 90 | "GRC": "Greece", 91 | "GRL": "Greenland", 92 | "GRD": "Grenada", 93 | "GLP": "Guadeloupe", 94 | "GUM": "Guam", 95 | "GTM": "Guatemala", 96 | "GGY": "Guernsey", 97 | "GIN": "Guinea", 98 | "GNB": "Guinea-Bissau", 99 | "GUY": "Guyana", 100 | "HTI": "Haiti", 101 | "HMD": "Heard Island and McDonald Islands", 102 | "VAT": "Holy See", 103 | "HND": "Honduras", 104 | "HKG": "Hong Kong", 105 | "HUN": "Hungary", 106 | "ISL": "Iceland", 107 | "IND": "India", 108 | "IDN": "Indonesia", 109 | "IRN": "Iran", 110 | "IRQ": "Iraq", 111 | "IRL": "Ireland", 112 | "IMN": "Isle of Man", 113 | "ISR": "Israel", 114 | "ITA": "Italy", 115 | "JAM": "Jamaica", 116 | "JPN": "Japan", 117 | "JEY": "Jersey", 118 | "JOR": "Jordan", 119 | "KAZ": "Kazakhstan", 120 | "KEN": "Kenya", 121 | "KIR": "Kiribati", 122 | "KWT": "Kuwait", 123 | "KGZ": "Kyrgyzstan", 124 | "LAO": "Lao People's Democratic Republic", 125 | "LVA": "Latvia", 126 | "LBN": "Lebanon", 127 | "LSO": "Lesotho", 128 | "LBR": "Liberia", 129 | "LBY": "Libya", 130 | "LIE": "Liechtenstein", 131 | "LTU": "Lithuania", 132 | "LUX": "Luxembourg", 133 | "MAC": "Macao", 134 | "MDG": "Madagascar", 135 | "MWI": "Malawi", 136 | "MYS": "Malaysia", 137 | "MDV": "Maldives", 138 | "MLI": "Mali", 139 | "MLT": "Malta", 140 | "MHL": "Marshall Islands", 141 | "MTQ": "Martinique", 142 | "MRT": "Mauritania", 143 | "MUS": "Mauritius", 144 | "MYT": "Mayotte", 145 | "MEX": "Mexico", 146 | "FSM": "Federated States of Micronesia", 147 | "MDA": "Moldova", 148 | "MCO": "Monaco", 149 | "MNG": "Mongolia", 150 | "MNE": "Montenegro", 151 | "MSR": "Montserrat", 152 | "MAR": "Morocco", 153 | "MOZ": "Mozambique", 154 | "MMR": "Myanmar", 155 | "NAM": "Namibia", 156 | "NRU": "Nauru", 157 | "NPL": "Nepal", 158 | "NLD": "Netherlands", 159 | "NCL": "New Caledonia", 160 | "NZL": "New Zealand", 161 | "NIC": "Nicaragua", 162 | "NER": "Niger", 163 | "NGA": "Nigeria", 164 | "NIU": "Niue", 165 | "NFK": "Norfolk Island", 166 | "PRK": "North Korea", 167 | "MNP": "Northern Mariana Islands", 168 | "NOR": "Norway", 169 | "OMN": "Oman", 170 | "PAK": "Pakistan", 171 | "PLW": "Palau", 172 | "PSE": "State of Palestine", 173 | "PAN": "Panama", 174 | "PNG": "Papua New Guinea", 175 | "PRY": "Paraguay", 176 | "PER": "Peru", 177 | "PHL": "Philippines", 178 | "PCN": "Pitcairn", 179 | "POL": "Poland", 180 | "PRT": "Portugal", 181 | "PRI": "Puerto Rico", 182 | "QAT": "Qatar", 183 | "MKD": "Republic of North Macedonia", 184 | "ROU": "Romania", 185 | "RUS": "Russia", 186 | "RWA": "Rwanda", 187 | "REU": "Réunion", 188 | "BLM": "Saint Barthélemy", 189 | "SHN": "Saint Helena, Ascension and Tristan da Cunha", 190 | "KNA": "Saint Kitts and Nevis", 191 | "LCA": "Saint Lucia", 192 | "MAF": "Saint Martin island", 193 | "SPM": "Saint Pierre and Miquelon", 194 | "VCT": "Saint Vincent and the Grenadines", 195 | "WSM": "Samoa", 196 | "SMR": "San Marino", 197 | "STP": "Sao Tome and Principe", 198 | "SAU": "Saudi Arabia", 199 | "SEN": "Senegal", 200 | "SRB": "Serbia", 201 | "SYC": "Seychelles", 202 | "SLE": "Sierra Leone", 203 | "SGP": "Singapore", 204 | "SXM": "Sint Maarten", 205 | "SVK": "Slovakia", 206 | "SVN": "Slovenia", 207 | "SLB": "Solomon Islands", 208 | "SOM": "Somalia", 209 | "ZAF": "South Africa", 210 | "SGS": "South Georgia and the South Sandwich Islands", 211 | "KOR": "South Korea", 212 | "SSD": "South Sudan", 213 | "ESP": "Spain", 214 | "LKA": "Sri Lanka", 215 | "SDN": "Sudan", 216 | "SUR": "Suriname", 217 | "SJM": "Svalbard and Jan Mayen", 218 | "SWE": "Sweden", 219 | "CHE": "Switzerland", 220 | "SYR": "Syrian Arab Republic", 221 | "TWN": "Taiwan", 222 | "TJK": "Tajikistan", 223 | "TZA": "Tanzania", 224 | "THA": "Thailand", 225 | "TLS": "Timor-Leste", 226 | "TGO": "Togo", 227 | "TKL": "Tokelau", 228 | "TON": "Tonga", 229 | "TTO": "Trinidad and Tobago", 230 | "TUN": "Tunisia", 231 | "TUR": "Turkey", 232 | "TKM": "Turkmenistan", 233 | "TCA": "Turks and Caicos Islands", 234 | "TUV": "Tuvalu", 235 | "UGA": "Uganda", 236 | "UKR": "Ukraine", 237 | "ARE": "United Arab Emirates", 238 | "GBR": "United Kingdom", 239 | "UMI": "United States Minor Outlying Islands", 240 | "USA": "United States of America", 241 | "URY": "Uruguay", 242 | "UZB": "Uzbekistan", 243 | "VUT": "Vanuatu", 244 | "VEN": "Venezuela", 245 | "VNM": "Viet Nam", 246 | "VGB": "Virgin Islands (British)", 247 | "VIR": "Virgin Islands (U.S.)", 248 | "WLF": "Wallis and Futuna", 249 | "ESH": "Western Sahara", 250 | "YEM": "Yemen", 251 | "ZMB": "Zambia", 252 | "ZWE": "Zimbabwe", 253 | "ALA": "Åland Islands", 254 | } 255 | -------------------------------------------------------------------------------- /info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author" : ["ow0x (<@306810730055729152>)"], 3 | "install_msg" : "Hi, thanks for adding my repo. If you find my cogs useful, maybe star my cog repo on GitHub. 🙂 🙏🏼", 4 | "name" : "owo-cogs", 5 | "short" : "Some fun and utility cogs for Red-DiscordBot", 6 | "description" : "Some fun and utility cogs for Red-DiscordBot" 7 | } 8 | -------------------------------------------------------------------------------- /ipinfo/__init__.py: -------------------------------------------------------------------------------- 1 | from discord.utils import maybe_coroutine 2 | 3 | from .core import IP 4 | 5 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 6 | 7 | 8 | async def setup(bot): 9 | await maybe_coroutine(bot.add_cog, IP()) 10 | -------------------------------------------------------------------------------- /ipinfo/core.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import cast 3 | 4 | import aiohttp 5 | import discord 6 | from redbot.core import commands 7 | from redbot.core.utils.menus import DEFAULT_CONTROLS, menu 8 | 9 | from .models import IPData, IPInfoIO 10 | from .utils import make_embed, query_ipinfo 11 | 12 | 13 | class IP(commands.Cog): 14 | """Get basic geolocation info on a public IP address.""" 15 | 16 | __authors__ = ["ow0x"] 17 | __version__ = "3.0.0" 18 | 19 | def format_help_for_context(self, ctx: commands.Context) -> str: 20 | """Thanks Sinbad.""" 21 | return ( 22 | f"{super().format_help_for_context(ctx)}\n\n" 23 | f"**Authors:** {', '.join(self.__authors__)}\n" 24 | f"**Cog version:** v{self.__version__}" 25 | ) 26 | 27 | session = aiohttp.ClientSession() 28 | 29 | def cog_unload(self) -> None: 30 | if self.session: 31 | asyncio.create_task(self.session.close()) 32 | 33 | @commands.is_owner() 34 | @commands.command(name="ip") 35 | @commands.bot_has_permissions(add_reactions=True, embed_links=True) 36 | async def ipinfo(self, ctx: commands.Context, *, ip_address: str): 37 | """Fetch basic geolocation data on a public IPv4 address. 38 | 39 | **Usage:** `[p]ip ` 40 | 41 | You can bulk query info on upto 20 IPv4 addresses at once. 42 | For this, simply provide IP addresses separated by space. 43 | Only max. 20 IPs will be processed at once to avoid API ratelimits. 44 | 45 | **Example:** 46 | - `[p]ip 136.23.11.195` 47 | - `[p]ip 117.111.1.112 183.157.171.217 62.171.168.2 107.189.14.180` 48 | """ 49 | api_key = (await ctx.bot.get_shared_api_tokens("ipdata")).get("api_key") 50 | async with ctx.typing(): 51 | ip_addrs = ip_address.split(" ") 52 | if len(ip_addrs) == 1: 53 | data = await IPData.request(self.session, ip_address, api_key) 54 | ipinfo_result = await query_ipinfo(self.session, ip_address) 55 | ipinfo_data = ( 56 | IPInfoIO.from_data(ipinfo_result["data"]) 57 | if "data" in ipinfo_result else None 58 | ) 59 | if data.message: 60 | await ctx.send(str(data.message)) 61 | return 62 | data = cast(IPData, data) 63 | embed = make_embed(await ctx.embed_colour(), data, ipinfo_data) 64 | await ctx.send(embed=embed) 65 | return 66 | 67 | embeds = [] 68 | for i, ip_addr in enumerate(ip_addrs[:20], 1): 69 | data = await IPData.request(self.session, ip_addr, api_key) 70 | if error_msg := data.message: 71 | embed = discord.Embed(colour=await ctx.embed_colour(), description=error_msg) 72 | if str(error_msg).startswith("http"): 73 | embed.set_image(url=error_msg) 74 | else: 75 | embed = make_embed(await ctx.embed_colour(), data) 76 | embed.set_footer(text=f"Page {i} of {len(ip_addrs)}") 77 | if ctx.author.id == 306810730055729152: 78 | embed.add_field( 79 | name='API Quota', 80 | value=f"{data.count}/1500 used today", 81 | inline=False 82 | ) 83 | embeds.append(embed) 84 | 85 | if not embeds: 86 | await ctx.send("Sad trombone. No results. 😔") 87 | return 88 | 89 | await menu(ctx, embeds, DEFAULT_CONTROLS, timeout=90.0) 90 | -------------------------------------------------------------------------------- /ipinfo/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "IPInfo", 3 | "short": "Get basic geolocation info on a public IP address.", 4 | "description": "Get basic geolocation info on a public IP address.", 5 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 6 | "install_msg": "Thanks for installing. This cog shows various info about given **>> Public IP address <<** from ipdata.co API.\n\n**Use `[p]help IP` to see this cog's commands.**", 7 | "author": ["ow0x"], 8 | "required_cogs": {}, 9 | "requirements": [], 10 | "tags": ["ip", "whois", "ipdata", "ipinfo"], 11 | "min_bot_version": "3.4.18", 12 | "hidden": false, 13 | "disabled": false, 14 | "type": "COG" 15 | } 16 | -------------------------------------------------------------------------------- /ipinfo/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .ipdata import * 2 | from .ipinfo import * -------------------------------------------------------------------------------- /ipinfo/models/ipdata.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from dataclasses import dataclass, field 5 | from typing import Any, Dict, List, Optional, Union 6 | 7 | import aiohttp 8 | 9 | 10 | @dataclass 11 | class ASN: 12 | asn: str 13 | name: str 14 | route: str 15 | type: str = "" 16 | domain: str = "" 17 | 18 | def __str__(self) -> str: 19 | return f"{self.name}\n(Type: `{self.type.upper()}`)\n" 20 | 21 | 22 | @dataclass 23 | class TimeZone: 24 | name: Optional[str] 25 | abbr: Optional[str] 26 | offset: Optional[str] 27 | is_dst: Optional[bool] 28 | current_time: Optional[str] 29 | 30 | def __str__(self) -> str: 31 | if self.name and self.abbr: 32 | return f"{self.name} ({self.abbr})" 33 | return "N/A" 34 | 35 | 36 | @dataclass 37 | class Threat: 38 | is_tor: bool 39 | is_icloud_relay: bool 40 | is_proxy: bool 41 | is_datacenter: bool 42 | is_anonymous: bool 43 | is_known_attacker: bool 44 | is_known_abuser: bool 45 | is_threat: bool 46 | is_bogon: bool 47 | is_vpn: Optional[bool] = None 48 | blocklists: List[Blocklist] = field(default_factory=list) 49 | 50 | def __str__(self) -> str: 51 | return "\n".join( 52 | f"✅ IP {key.replace('_', ' ').title()}" 53 | for key, value in self.__dict__.items() 54 | if value and type(value) is bool 55 | ).replace("Icloud", "iCloud").replace('Is', 'is') 56 | 57 | @classmethod 58 | def from_dict(cls, data: dict) -> Threat: 59 | blocklists = data.pop("blocklists", []) 60 | return cls(blocklists=[Blocklist(**i) for i in blocklists], **data) 61 | 62 | 63 | @dataclass 64 | class Blocklist: 65 | name: str 66 | site: str 67 | type: str 68 | 69 | def __str__(self) -> str: 70 | return f"[{self.name}]({self.site})" 71 | 72 | 73 | @dataclass 74 | class ErrorMessage: 75 | message: str 76 | count: Optional[str] = None 77 | 78 | def __str__(self) -> str: 79 | return self.message 80 | 81 | 82 | @dataclass 83 | class IPData: 84 | ip: str 85 | is_eu: bool 86 | city: Optional[str] 87 | region: Optional[str] 88 | region_code: Optional[str] 89 | region_type: Optional[str] 90 | country_name: str 91 | country_code: str 92 | continent_name: str 93 | continent_code: str 94 | latitude: float 95 | longitude: float 96 | postal: Optional[str] 97 | calling_code: str 98 | flag: str 99 | emoji_flag: str 100 | emoji_unicode: str 101 | asn: Optional[ASN] 102 | time_zone: Optional[TimeZone] 103 | threat: Optional[Threat] 104 | count: str 105 | message: Optional[ErrorMessage] = None 106 | 107 | @property 108 | def country(self): 109 | return f"{self.emoji_flag} {self.country_name}\n({self.continent_name})" 110 | 111 | @property 112 | def co_ordinates(self): 113 | return f"{self.latitude:.6f}, {self.longitude:.6f}" 114 | 115 | @classmethod 116 | def from_json(cls, json: dict) -> IPData: 117 | asn = json.pop("asn", None) 118 | _ = json.pop("carrier", None) 119 | _ = json.pop("currency", None) 120 | _ = json.pop("languages", []) 121 | timezone = json.pop("time_zone", None) 122 | threat = json.pop("threat", None) 123 | return cls( 124 | asn=ASN(**asn) if asn else None, 125 | time_zone=TimeZone(**timezone) if timezone else None, 126 | threat=Threat.from_dict(threat) if threat else None, 127 | **json, 128 | ) 129 | 130 | @classmethod 131 | async def request( 132 | cls, 133 | session: aiohttp.ClientSession, 134 | ip: str, 135 | api_key: Optional[str] = None, 136 | ) -> Union[ErrorMessage, IPData]: 137 | image, bait, flakes, come = ("9cac2b3", "7b9ada7", "ba746e3", "c7c6179") 138 | snow, AKx7UEj, some, take = ("bd74cc8", "415cd36", "99182d1", "69af730") 139 | key = api_key or f"{come}{snow}{flakes}{take}{some}{bait}{image}{AKx7UEj}" 140 | 141 | url = f"https://api.ipdata.co/v1/{ip}?api-key={key}" 142 | try: 143 | async with session.get(url) as resp: 144 | cat = f"https://http.cat/{resp.status}.png" 145 | if resp.status in [400, 401, 403]: 146 | err_data: Dict[str, str] = await resp.json() 147 | cls.message = ErrorMessage(err_data['message']) 148 | return cls.message 149 | elif resp.status != 200: 150 | cls.message = ErrorMessage(message=cat) 151 | return cls.message 152 | 153 | data: Dict[str, Any] = await resp.json() 154 | except asyncio.TimeoutError: 155 | return ErrorMessage(message="https://http.cat/408.png") 156 | 157 | return cls.from_json(data) 158 | -------------------------------------------------------------------------------- /ipinfo/models/ipinfo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Optional 5 | 6 | from .ipdata import ASN 7 | 8 | 9 | @dataclass 10 | class Company: 11 | name: str 12 | domain: Optional[str] = None 13 | type: Optional[str] = None 14 | 15 | def __str__(self) -> str: 16 | msg = self.name 17 | if self.domain: 18 | msg += f"\n(domain: {self.domain})" 19 | if self.type: 20 | msg += f"\n(Type: `{self.type.upper()}`)" 21 | return msg 22 | 23 | 24 | @dataclass 25 | class Privacy: 26 | vpn: bool 27 | proxy: bool 28 | tor: bool 29 | relay: bool 30 | hosting: bool 31 | service: Optional[str] = None 32 | 33 | 34 | @dataclass 35 | class Abuse: 36 | address: Optional[str] = None 37 | country: Optional[str] = None 38 | email: Optional[str] = None 39 | name: Optional[str] = None 40 | network: Optional[str] = None 41 | phone: Optional[str] = None 42 | 43 | def __str__(self) -> str: 44 | # return json.dumps(self.__dict__, indent=4) 45 | msg = f'{self.address or ""}\n' 46 | if self.name: 47 | msg += f"{self.name} ({self.email or 'E-mail: N/A'})\n" 48 | if self.network: 49 | msg += f"CIDR: {self.network}\n" 50 | if self.phone: 51 | msg += f"Phone: {self.phone}" 52 | return msg 53 | 54 | 55 | @dataclass 56 | class IPInfoIO: 57 | ip: str 58 | hostname: Optional[str] 59 | city: Optional[str] 60 | region: Optional[str] 61 | country: Optional[str] 62 | loc: Optional[str] 63 | org: Optional[str] 64 | postal: Optional[str] 65 | timezone: Optional[str] 66 | asn: Optional[ASN] 67 | company: Optional[Company] 68 | privacy: Optional[Privacy] 69 | abuse: Optional[Abuse] 70 | 71 | @classmethod 72 | def from_data(cls, data: dict) -> IPInfoIO: 73 | _ = data.pop("domains", {}) 74 | asn = data.pop("asn", {}) 75 | company = data.pop("company", {}) 76 | privacy = data.pop("privacy", {}) 77 | abuse = data.pop("abuse", {}) 78 | return cls( 79 | ip=data["ip"], 80 | hostname=data.pop("hostname", None), 81 | city=data.pop("city", None), 82 | region=data.pop("region", None), 83 | country=data.pop("country", None), 84 | loc=data.pop("loc", None), 85 | org=data.pop("org", None), 86 | postal=data.pop("postal", None), 87 | timezone=data.pop("timezone", None), 88 | asn=ASN(**asn) if asn else None, 89 | company=Company(**company) if company else None, 90 | privacy=Privacy(**privacy) if privacy else None, 91 | abuse=Abuse(**abuse) if abuse else None, 92 | ) 93 | -------------------------------------------------------------------------------- /ipinfo/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Dict, Optional 3 | 4 | import discord 5 | from aiohttp import ClientError, ClientSession 6 | 7 | from .models import IPData, IPInfoIO 8 | 9 | 10 | def make_embed( 11 | color: discord.Colour, data: IPData, ipinfo_data: Optional[IPInfoIO] = None 12 | ) -> discord.Embed: 13 | embed = discord.Embed(color=color) 14 | # embed.description = f"__**Threat Info:**__\n\n{data.threat}" if data.threat else "" 15 | embed.set_author(name=f"Info for IP: {data.ip}", icon_url=data.flag or "") 16 | if ipinfo_data and ipinfo_data.asn: 17 | embed.add_field(name="Carrier (ASN)", value=str(ipinfo_data.asn)) 18 | if ipinfo_data.asn.route: 19 | embed.add_field( 20 | name="ASN Route", 21 | value=f"{ipinfo_data.asn.route}\n{ipinfo_data.asn.domain or ''}" 22 | ) 23 | elif data.asn: 24 | embed.add_field(name="ASN Carrier", value=str(data.asn)) 25 | embed.add_field(name="ASN Route", value=f"{data.asn.route}\n{data.asn.domain or ''}") 26 | if ipinfo_data and ipinfo_data.city: 27 | embed.add_field(name="City & Region", value=f"{ipinfo_data.city}\n{ipinfo_data.region or ''}") 28 | elif data.city: 29 | embed.add_field(name="City & Region", value=f"{data.city}\n{data.region or ''}") 30 | if data.country_name: 31 | embed.add_field(name="Country / Continent", value=data.country) 32 | if data.calling_code: 33 | embed.add_field(name="Calling Code", value=f"+{data.calling_code}") 34 | if ipinfo_data and (loc := ipinfo_data.loc): 35 | lat, long = loc.split(",") 36 | maps_link = f"[{loc}](https://www.google.com/maps?q={lat},{long})" 37 | embed.add_field(name="Geolocation", value=maps_link) 38 | elif (lat := data.latitude) and (long := data.longitude): 39 | maps_link = f"[{data.co_ordinates}](https://www.google.com/maps?q={lat},{long})" 40 | embed.add_field(name="Geolocation", value=maps_link) 41 | if ipinfo_data.company: 42 | embed.add_field(name="Company Info", value=str(ipinfo_data.company), inline=False) 43 | if ipinfo_data.abuse: 44 | embed.add_field(name="Abuse Contact", value=str(ipinfo_data.abuse), inline=False) 45 | if data.threat.blocklists: 46 | embed.add_field( 47 | name=f"In {len(data.threat.blocklists)} Blocklists", 48 | value=", ".join(str(b) for b in data.threat.blocklists), 49 | inline=False, 50 | ) 51 | return embed 52 | 53 | 54 | async def query_ipinfo(session: ClientSession, ip_address: str) -> Dict[str, Dict[str, Any]]: 55 | url = f"https://ipinfo.io/widget/demo/{ip_address}" 56 | h = { 57 | 'content-type': 'application/json', 58 | 'referer': 'https://ipinfo.io/', 59 | 'user-agent': 'Mozilla/5.0 (Linux; Android 12; M2101K6P) AppleWebKit/537.36' 60 | ' (KHTML, like Gecko) Chrome/107.0.0.0 Mobile Safari/537.36' 61 | } 62 | try: 63 | async with session.get(url, headers=h) as resp: 64 | if resp.status != 200: 65 | return {} 66 | data = await resp.json() 67 | except (asyncio.TimeoutError, ClientError): 68 | return {} 69 | else: 70 | return data 71 | -------------------------------------------------------------------------------- /jsk/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .jsk_cog import Jishaku 4 | 5 | 6 | async def setup(bot: Red): 7 | await bot.add_cog(Jishaku(bot=bot)) 8 | -------------------------------------------------------------------------------- /jsk/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Jishaku", 3 | "short": "Jishaku by Gorialis ported for Red-DiscordBot.", 4 | "description": "Jishaku, a debugging and testing cog for discord.py rewrite bots, ported for Red.", 5 | "install_msg": "This is a port of Jishaku module for Red; originally made by Gorialis over at \n Uninstall this cog if you do not know what this cog or Jishaku does.\n\n**With great power comes great responsibilities. Use this wisely or feel pain.**", 6 | "author": ["Gorialis", "ow0x"], 7 | "requirements": ["git+https://github.com/ow0x/jishaku"], 8 | "tags": ["jishaku", "jsk"], 9 | "min_bot_version": "3.5.0", 10 | "hidden": false, 11 | "disabled": false, 12 | "type": "COG" 13 | } 14 | -------------------------------------------------------------------------------- /jsk/jsk_cog.py: -------------------------------------------------------------------------------- 1 | import jishaku 2 | from jishaku.cog import OPTIONAL_FEATURES, STANDARD_FEATURES 3 | 4 | from redbot.core import commands 5 | 6 | jishaku.Flags.RETAIN = True 7 | jishaku.Flags.NO_UNDERSCORE = True 8 | jishaku.Flags.FORCE_PAGINATOR = True 9 | jishaku.Flags.NO_DM_TRACEBACK = True 10 | 11 | 12 | class Jishaku(*STANDARD_FEATURES, *OPTIONAL_FEATURES): 13 | """Jishaku, a debugging and testing cog for discord.py rewrite bots.""" 14 | 15 | __authors__ = ["Gorialis", "ow0x"] 16 | __version__ = "2.5.1" 17 | 18 | def format_help_for_context(self, ctx: commands.Context) -> str: # Thanks Sinbad! 19 | authors = map(lambda x: f'[{x}](https://github.com/{x})', self.__authors__) 20 | return ( 21 | f"{super().format_help_for_context(ctx)}\n\n" 22 | f"**Authors:** {', '.join(authors)}\n" 23 | f"**Cog version:** v{self.__version__}" 24 | ) 25 | -------------------------------------------------------------------------------- /kickstarter/__init__.py: -------------------------------------------------------------------------------- 1 | from discord.utils import maybe_coroutine 2 | 3 | from .kickstarter import Kickstarter 4 | 5 | __red_end_user_data_statement__ = "This cog does not persistently store any PII data about users." 6 | 7 | 8 | async def setup(bot): 9 | await maybe_coroutine(bot.add_cog, Kickstarter()) 10 | -------------------------------------------------------------------------------- /kickstarter/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from dataclasses import dataclass, field 5 | from datetime import datetime, timezone 6 | from typing import Any, Dict, Optional, Sequence 7 | 8 | import aiohttp 9 | from redbot.core.utils.chat_formatting import humanize_number 10 | 11 | 12 | @dataclass 13 | class Photo: 14 | key: str 15 | full: str 16 | ed: str 17 | med: str 18 | little: str 19 | small: str 20 | thumb: str 21 | h576: Optional[str] 22 | h864: Optional[str] 23 | 24 | def __str__(self) -> str: 25 | return self.h864 or self.h576 or self.full 26 | 27 | @classmethod 28 | def from_dict(cls, data: Dict[str, str]) -> Photo: 29 | return cls(h576=data.pop('1024x576', ''), h864=data.pop('1536x864', ''), **data) 30 | 31 | 32 | @dataclass 33 | class CreatorAvatar: 34 | thumb: str 35 | small: str 36 | medium: str 37 | 38 | 39 | @dataclass 40 | class URLs: 41 | web: Dict[str, str] = field(default_factory=dict) 42 | 43 | 44 | @dataclass 45 | class Creator: 46 | id: int 47 | name: str 48 | is_registered: Optional[bool] 49 | is_email_verified: Optional[bool] 50 | chosen_currency: Optional[str] 51 | is_superbacker: Optional[bool] 52 | avatar: CreatorAvatar 53 | slug: str = "" 54 | urls: Dict[str, Dict[str, str]] = field(default_factory=dict) 55 | 56 | def __str__(self) -> str: 57 | if self.urls.get('web'): 58 | return f"[{self.name}]({self.urls['web']['user']})" 59 | return self.name 60 | 61 | @classmethod 62 | def from_dict(cls, data: Dict[str, Any]) -> Creator: 63 | avatar = data.pop('avatar', {}) 64 | return cls(avatar=CreatorAvatar(**avatar), **data) 65 | 66 | 67 | @dataclass 68 | class Location: 69 | id: int 70 | name: str 71 | slug: str 72 | short_name: str 73 | displayable_name: str 74 | localized_name: str 75 | country: str 76 | state: str 77 | type: str 78 | is_root: bool 79 | expanded_country: str 80 | urls: Dict[str, Dict[str, str]] = field(default_factory=dict) 81 | 82 | 83 | @dataclass 84 | class Category: 85 | id: int 86 | name: str 87 | analytics_name: str 88 | slug: str 89 | position: int 90 | color: int 91 | parent_id: int = 0 92 | parent_name: str = "" 93 | urls: Dict[str, Dict[str, str]] = field(default_factory=dict) 94 | 95 | def __str__(self) -> str: 96 | return self.name or '' 97 | 98 | 99 | @dataclass 100 | class Profile: 101 | id: int 102 | project_id: int 103 | state: str 104 | state_changed_at: int 105 | show_feature_image: bool 106 | background_image_opacity: float 107 | should_show_feature_image_section: bool 108 | name: Optional[str] = None 109 | blurb: Optional[str] = None 110 | background_color: Optional[str] = None 111 | text_color: Optional[str] = None 112 | link_background_color: Optional[str] = None 113 | link_text_color: Optional[str] = None 114 | link_text: Optional[str] = None 115 | link_url: Optional[str] = None 116 | background_image_attributes: Dict[str, Dict[str, str]] = field(default_factory=dict) 117 | feature_image_attributes: Dict[str, Dict[str, str]] = field(default_factory=dict) 118 | 119 | 120 | @dataclass 121 | class KickstarterProject: 122 | id: int 123 | name: str 124 | blurb: str 125 | goal: int 126 | pledged: int 127 | state: str 128 | slug: str 129 | disable_communication: bool 130 | country: str 131 | country_displayable_name: str 132 | currency: str 133 | currency_symbol: str 134 | currency_trailing_code: bool 135 | deadline: int 136 | state_changed_at: int 137 | created_at: int 138 | launched_at: int 139 | staff_pick: bool 140 | is_starrable: bool 141 | backers_count: int 142 | static_usd_rate: int 143 | usd_pledged: str 144 | converted_pledged_amount: int 145 | fx_rate: int 146 | usd_exchange_rate: int 147 | current_currency: str 148 | usd_type: str 149 | spotlight: bool 150 | creator: Creator 151 | photo: Optional[Photo] 152 | location: Optional[Location] 153 | category: Optional[Category] 154 | profile: Optional[Profile] 155 | urls: Optional[URLs] = None 156 | 157 | @property 158 | def who_created(self) -> str: 159 | return f"**Creator**: {self.creator}" 160 | 161 | @property 162 | def project_goal(self) -> str: 163 | return f"{self.currency_symbol}{humanize_number(round(self.goal or 0))}" 164 | 165 | @property 166 | def pledged_till_now(self) -> str: 167 | pledged = f"{self.currency_symbol}{humanize_number(round(self.pledged))}" 168 | percent_funded = round((self.pledged / self.goal) * 100) 169 | return f"{pledged}\n({humanize_number(percent_funded)}% funded)" 170 | 171 | @property 172 | def when_created(self) -> str: 173 | return f"**Creation Date**: \n" 174 | 175 | @property 176 | def when_launched(self) -> str: 177 | return f"**Launched Date**: \n" 178 | 179 | @property 180 | def when_deadline(self) -> str: 181 | deadline = datetime.now(timezone.utc).timestamp() > self.deadline 182 | past_or_future = "`**EXPIRED**`" if deadline else "" 183 | return f"**Deadline**: {past_or_future}\n" 184 | 185 | @classmethod 186 | def from_data(cls, data: Dict[str, Any]) -> KickstarterProject: 187 | photo=data.pop('photo', {}) 188 | creator=data.pop('creator', {}) 189 | location=data.pop('location', {}) 190 | category=data.pop('category', {}) 191 | profile=data.pop('profile', {}) 192 | urls=data.pop('urls', {}) 193 | return cls( 194 | photo=Photo.from_dict(photo) if photo else None, 195 | creator=Creator.from_dict(creator), 196 | location=Location(**location) if location else None, 197 | category=Category(**category) if category else None, 198 | profile=Profile(**profile) if profile else None, 199 | urls=URLs(**urls) if urls else None, 200 | **data 201 | ) 202 | 203 | @classmethod 204 | async def request( 205 | cls, session: aiohttp.ClientSession, url: str 206 | ) -> NotFound | Sequence[KickstarterProject]: 207 | projects: Sequence[Dict[str, Any]] = [] 208 | try: 209 | async with session.get(url=url) as resp: 210 | if resp.status != 200: 211 | return NotFound(status=resp.status) 212 | data: Dict[str, Any] = await resp.json() 213 | projects = data.get('projects', []) 214 | if not projects: 215 | return NotFound(suggestion=data['suggestion']) 216 | except (KeyError, asyncio.TimeoutError): 217 | return NotFound(status=408) 218 | 219 | return [cls.from_data(item) for item in projects] 220 | 221 | 222 | @dataclass 223 | class NotFound: 224 | status: int = 0 225 | suggestion: Optional[str] = None 226 | 227 | def __str__(self) -> str: 228 | if match := self.suggestion: 229 | prompt = "If not, then either ignore or retry with correct query." 230 | return f"Maybe you meant... **{match}**?\n{prompt}" 231 | return f"https://http.cat/{self.status}.jpg" 232 | -------------------------------------------------------------------------------- /kickstarter/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Kickstarter", 3 | "short": "Search for and get info on a Kickstarter project.", 4 | "description": "Search for your query to fetch info on a Kickstarter project.", 5 | "install_msg": "Thanks for installing this MEH cog. 😔", 6 | "end_user_data_statement": "This cog does not persistently store any PII data or metadata about users.", 7 | "author": [ 8 | "ow0x (<@306810730055729152>)", 9 | "dragonfire535" 10 | ], 11 | "required_cogs": {}, 12 | "requirements": [], 13 | "tags": ["kickstarter"], 14 | "min_bot_version": "3.4.14", 15 | "hidden": false, 16 | "disabled": false, 17 | "type": "COG" 18 | } -------------------------------------------------------------------------------- /kickstarter/kickstarter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | 5 | import aiohttp 6 | import discord 7 | from redbot.core import commands 8 | from redbot.core.utils.chat_formatting import humanize_number 9 | from redbot.core.utils.menus import DEFAULT_CONTROLS, menu 10 | 11 | from .api import KickstarterProject, NotFound 12 | 13 | 14 | class Kickstarter(commands.Cog): 15 | """Get various nerdy info on a Kickstarter project.""" 16 | 17 | __authors__ = ["dragonfire535", "ow0x"] 18 | __version__ = "2.0.0" 19 | 20 | def format_help_for_context(self, ctx: commands.Context) -> str: 21 | """Thanks Sinbad.""" 22 | return ( 23 | f"{super().format_help_for_context(ctx)}\n\n" 24 | f"**Authors:** {', '.join(self.__authors__)}\n" 25 | f"**Cog version:** v{self.__version__}" 26 | ) 27 | 28 | async def red_delete_data_for_user(self, **kwargs) -> None: 29 | """Nothing to delete""" 30 | pass 31 | 32 | session = aiohttp.ClientSession() 33 | 34 | def cog_unload(self) -> None: 35 | if self.session: 36 | asyncio.create_task(self.session.close()) 37 | 38 | @staticmethod 39 | def make_embed(data: KickstarterProject, footer: str) -> discord.Embed: 40 | embed = discord.Embed(colour=0x14E06E, title=data.name or "") 41 | if data.urls and data.urls.web: 42 | embed.url = data.urls.web.get('project', '') or '' 43 | embed.set_author(name="Kickstarter", icon_url="https://i.imgur.com/EHDlH5t.png") 44 | if data.photo: 45 | embed.set_image(url=data.photo.h864 or data.photo.full) 46 | embed.add_field(name="Project Goal", value=data.project_goal) 47 | embed.add_field(name="Pledged", value=data.pledged_till_now) 48 | embed.add_field(name="Backers", value=f'{humanize_number(data.backers_count)}') 49 | embed.description = ( 50 | f"{data.blurb or ''}\n\n{data.who_created}\n" 51 | f"{data.when_created}{data.when_launched}{data.when_deadline}" 52 | ) 53 | embed.set_footer(text=f"{footer} • Category: {data.category}") 54 | return embed 55 | 56 | @commands.command() 57 | @commands.bot_has_permissions(embed_links=True) 58 | @commands.cooldown(1, 5.0, commands.BucketType.user) 59 | async def kickstarter(self, ctx: commands.Context, *, query: str): 60 | """Search for a project on Kickstarter by given query.""" 61 | query = query.replace(" ", "%20").lower() 62 | url = f"https://www.kickstarter.com/projects/search.json?term={query}" 63 | 64 | async with ctx.typing(): 65 | projects = await KickstarterProject.request(self.session, url) 66 | if isinstance(projects, NotFound): 67 | return await ctx.send(f"❌ No results! {projects}") 68 | 69 | pages = [] 70 | for i, data in enumerate(projects, start=1): 71 | footer = f"Page {i} of {len(projects)}" 72 | embed = self.make_embed(data, footer) 73 | pages.append(embed) 74 | 75 | if len(pages) == 1: 76 | return await ctx.send(embed=pages[0]) 77 | await menu(ctx, pages, DEFAULT_CONTROLS, timeout=90.0) 78 | -------------------------------------------------------------------------------- /manim/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021, the Manim Community Developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /manim/README.md: -------------------------------------------------------------------------------- 1 | ## Pre-requisites 2 | 3 | > **Warning** 4 | > Installing pre-requisites and all of Manim's dependencies + Docker image may take considerable amount of disk space (anywhere from 1 to 4 GiB if TeX packages are also installed) and may consume significant amount of internet bandwidth while trying to download prerequisite software packages. 5 | 6 | > **Note** 7 | > Docker images are only available for Linux/Unix based OS architectures. 8 | 9 | * First install docker for your OS where you are running/hosting your redbot instance. 10 | * Install required dependencies for Manim from: https://docs.manim.community/en/stable/installation.html 11 | 12 | 13 | ## Pull Manim docker image 14 | 15 | by using this command in your terminal (may require you to run with sudo): 16 | ``` 17 | docker pull manimcommunity/manim:stable 18 | ``` 19 | Current stable Manim docker image version as of 25th April 2023 is v0.17.3 20 | 21 | It may take sometime to download+extract this docker image, depending on your ISP host provider's internet speed. 22 | Once it's done, check if its successfully downloaded with: 23 | ``` 24 | $ docker images -a 25 | REPOSITORY TAG IMAGE ID CREATED SIZE 26 | manimcommunity/manim stable a594bd60e7a7 2 weeks ago 1.97GB 27 | ``` 28 | 29 | # Install manim cog 30 | ``` 31 | # Add this repo, if haven't already 32 | [p]repo add owo-cogs https://github.com/owocado/owo-cogs 33 | 34 | # Install cog 35 | [p]cog install owo-cogs manim 36 | 37 | # Load the cog 38 | [p]load manim 39 | ``` 40 | 41 | If it's loaded successfully, then try evaluating a Manim code snippet with `[p]manimate` command like this: 42 | ![image](https://user-images.githubusercontent.com/24418520/114295266-c9cdb780-9ac1-11eb-9d43-64ae427d5c60.png) 43 | 44 | Or pick a code snippet from Manim's example gallery to try out for demo: https://docs.manim.community/en/stable/examples.html 45 | -------------------------------------------------------------------------------- /manim/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | 3 | from .manim import Manim 4 | 5 | __red_end_user_data_statement__ = "This cog does not persistently store data about users." 6 | 7 | 8 | async def setup(bot: Red): 9 | await bot.add_cog(Manim()) 10 | -------------------------------------------------------------------------------- /manim/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "The Manim Community Developers", 4 | "owocado" 5 | ], 6 | "install_msg": "This cog lets you evaluate Manim code snippets to render short mathematical animations through Manim python animation engine.\nYou can find out how to setup this cog and Manim here: https://github.com/owocado/owo-cogs/blob/main/manim/README.md", 7 | "name": "Manim", 8 | "disabled": false, 9 | "short": "Render mathematical animations with Manim.", 10 | "description": "Render mathematical animations with Manim animation engine.", 11 | "end_user_data_statement": "This cog does not persistently store data about users.", 12 | "tags": [ 13 | "manim", 14 | "manimate" 15 | ], 16 | "requirements": [ 17 | "docker" 18 | ], 19 | "min_bot_version": "3.5.0", 20 | "hidden": false, 21 | "type": "COG" 22 | } 23 | -------------------------------------------------------------------------------- /manim/manim.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import os 4 | import tempfile 5 | import textwrap 6 | import traceback 7 | import re 8 | import io 9 | from pathlib import Path 10 | from typing import Any, Dict 11 | 12 | import discord 13 | import docker 14 | from redbot.core import commands 15 | 16 | 17 | # https://github.com/Cog-Creators/Red-DiscordBot/blob/V3/develop/redbot/core/dev_commands.py#L31 18 | START_CODE_BLOCK_RE = re.compile(r"((```py(thon)?)(?=\s)|(```))") 19 | 20 | dockerclient = docker.from_env() 21 | 22 | 23 | # The code is taken from https://github.com/ManimCommunity/DiscordManimator 24 | # to port it for Red bot. LICENSE is included with the cog to respect authors. 25 | # All credits belong to the Manim Community Developers and not me. Thanks. 26 | 27 | 28 | class Manim(commands.Cog): 29 | """A cog for interacting with Manim python animation engine.""" 30 | 31 | __authors__ = ["Manim Community Developers", "owocado"] 32 | __version__ = "0.17.3" 33 | 34 | def format_help_for_context(self, ctx: commands.Context) -> str: 35 | """Thanks Sinbad.""" 36 | return ( 37 | f"{super().format_help_for_context(ctx)}\n\n" 38 | f"Authors: {', '.join(self.__authors__)}\n" 39 | f"Cog version: v{self.__version__}" 40 | ) 41 | 42 | @commands.is_owner() 43 | @commands.command(aliases=["manimate"]) 44 | @commands.bot_has_permissions(attach_files=True) 45 | @commands.max_concurrency(1, commands.BucketType.default) 46 | async def manim(self, ctx: commands.Context, *, snippet: str) -> None: 47 | """Evaluate short Manim code snippets to render mathematical animations. 48 | 49 | Code **must** be properly formatted and indented in a markdown code block. 50 | 51 | **Supported (CLI) flags:** 52 | see 53 | 54 | **Example:** 55 | ```py 56 | [p]manimate -s 57 | def construct(self): 58 | self.play(ReplacementTransform(Square(), Circle())) 59 | ``` 60 | """ 61 | async with ctx.typing(): 62 | fake_task = functools.partial(self.construct_reply, snippet) 63 | loop = asyncio.get_running_loop() 64 | task = loop.run_in_executor(None, fake_task) 65 | try: 66 | reply_args = await asyncio.wait_for(task, timeout=120) 67 | except asyncio.TimeoutError: 68 | await ctx.send("Operation timed out after 2 minutes. No output received from Docker process.") 69 | return 70 | 71 | reply_args["reference"] = ctx.message.to_reference(fail_if_not_exists=False) 72 | # reply_args["mention_author"] = False 73 | await ctx.send(**reply_args) 74 | return 75 | 76 | 77 | def construct_reply(self, script: str) -> Dict[str, Any]: 78 | if script.count("```") != 2: 79 | reply_args = { 80 | "content": "Your message has to be properly formatted " 81 | " and code should be written in a code block, like so:\n" 82 | "\\`\\`\\`py\nyour code here\n\\`\\`\\`" 83 | } 84 | return reply_args 85 | 86 | arg = START_CODE_BLOCK_RE.sub("", script) 87 | header, *code = arg.split("\n") 88 | 89 | cli_flags = header.split() 90 | if "--renderer=opengl" in cli_flags: 91 | cli_flags.append("--write_to_movie") 92 | joined_flags = " ".join(cli_flags) 93 | 94 | body = "\n".join(code).strip() 95 | 96 | # for convenience: allow construct-only: 97 | if body.startswith("def construct(self):"): 98 | code_snippet = "class Manimation(Scene):\n%s" % textwrap.indent(body, " ") 99 | else: 100 | code_snippet = body 101 | 102 | code_snippet = "from manim import *\n\n" + code_snippet 103 | 104 | # write code to temporary file (ideally in temporary directory) 105 | base_flags = "-qm --disable_caching --progress_bar=none -o scriptoutput" 106 | with tempfile.TemporaryDirectory() as tmpdirname: 107 | with open(Path(tmpdirname) / "script.py", "w", encoding="utf-8") as f: 108 | f.write(code_snippet) 109 | 110 | reply_args = None 111 | try: 112 | dockerclient.containers.run( 113 | image="manimcommunity/manim:stable", 114 | volumes={tmpdirname: {"bind": "/manim/", "mode": "rw"}}, 115 | command=f"timeout 120 manim {base_flags} {joined_flags} /manim/script.py", 116 | user=os.getuid(), 117 | stderr=True, 118 | stdout=False, 119 | remove=True, 120 | ) 121 | except Exception as exc: 122 | if isinstance(exc, docker.errors.ContainerError): 123 | tb = exc.stderr 124 | else: 125 | tb = str.encode(traceback.format_exc()) 126 | reply_args = { 127 | "content": "Something went wrong, the error log is attached.", 128 | "file": discord.File(fp=io.BytesIO(tb), filename="error.log"), 129 | } 130 | finally: 131 | if reply_args: 132 | return reply_args 133 | 134 | try: 135 | [outfilepath] = Path(tmpdirname).rglob("scriptoutput.*") 136 | except Exception as exc: 137 | tb = str.encode(traceback.format_exc()) 138 | reply_args = { 139 | "content": "Something went wrong; the error.log is attached.", 140 | "file": discord.File(fp=io.BytesIO(tb), filename="error.log"), 141 | } 142 | return reply_args 143 | else: 144 | reply_args = { 145 | "content": "Here you go:", 146 | "file": discord.File(outfilepath), 147 | } 148 | finally: 149 | return reply_args 150 | -------------------------------------------------------------------------------- /maps/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | 4 | from redbot.core.errors import CogLoadError 5 | 6 | from .maps import Maps 7 | 8 | if TYPE_CHECKING: 9 | from redbot.core.bot import Red 10 | 11 | __red_end_user_data_statement__ = "This cog does not persistently store data about users." 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | if not getattr(bot, "session", None): 16 | raise CogLoadError("This cog requires bot.session attr to be set.") 17 | await bot.add_cog(Maps()) 18 | -------------------------------------------------------------------------------- /maps/converter.py: -------------------------------------------------------------------------------- 1 | from redbot.core import commands 2 | 3 | 4 | class MapFlags(commands.FlagConverter, prefix="-", delimiter=" "): 5 | location: str | None = commands.flag( 6 | default=None, 7 | description="Input a location name that is available on Google Maps.", 8 | positional=True, 9 | ) 10 | zoom: int = commands.flag( 11 | default=12, 12 | description="Zoom level of the map, from 1 to 20. Defaults to 12.", 13 | max_args=1, 14 | ) 15 | maptype: str = commands.flag( 16 | default="roadmap", 17 | description="The type or format of the map, either 'roadmap' (default), 'satellite', 'terrain' or 'hybrid'", 18 | max_args=1, 19 | ) 20 | -------------------------------------------------------------------------------- /maps/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Google Maps", 3 | "short": "Fetch map of a location from Google Maps.", 4 | "description": "Fetch map of a location from Google Maps in various modes.", 5 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 6 | "install_msg": "Hi, thanks for installing my Maps cog. Please note that this cog requires some setup to do on your part to get a free API key, mainly to enable Maps API. If you can figure that out on your own, that's genius, but if you find yourself struggling on how to enable it and still want to use this cog, you can ask me on Discord for help. I'll try my best to help you out.", 7 | "author": ["owocado"], 8 | "required_cogs": {}, 9 | "requirements": [], 10 | "tags": ["maps", "map", "google map", "google maps"], 11 | "min_bot_version": "3.6.0", 12 | "min_python_version": [ 13 | 3, 14 | 12, 15 | 0 16 | ], 17 | "hidden": true, 18 | "disabled": false, 19 | "type": "COG" 20 | } 21 | -------------------------------------------------------------------------------- /maps/maps.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | import discord 4 | from redbot.core import commands 5 | 6 | from .converter import MapFlags 7 | 8 | MAP_TYPES: tuple[str, ...] = ("roadmap", "satellite", "terrain", "hybrid") 9 | 10 | 11 | class Maps(commands.Cog): 12 | """Fetch a Google map of a specific location with zoom and map types.""" 13 | 14 | __authors__ = ("<@306810730055729152>",) 15 | __version__ = "2.1.2" 16 | 17 | def format_help_for_context(self, ctx: commands.Context) -> str: 18 | """Thanks Sinbad.""" 19 | return ( 20 | f"{super().format_help_for_context(ctx)}\n\n" 21 | f"Authors: {', '.join(self.__authors__)}\n" 22 | f"Cog version: v{self.__version__}" 23 | ) 24 | 25 | @commands.bot_has_permissions(attach_files=True) 26 | @commands.command() 27 | async def map(self, ctx: commands.Context, *, flags: MapFlags) -> None: 28 | """Fetch map of a location from Google Maps. 29 | 30 | **Zoom level:** 31 | `zoom` parameter value must be from level 1 to 20. Defaults to 12. 32 | Below zoom levels that will show the approximate level of detail: 33 | ```prolog 34 | 1 to 4 : World 35 | 5 to 9 : Landmass or continent 36 | 10 to 14 : City 37 | 15 to 19 : Streets 38 | 20 : Buildings 39 | ``` 40 | 41 | **Map types:** 42 | - `maptype` parameter accepts only below 4 values: 43 | - `roadmap`, `satellite`, `terrain`, `hybrid` 44 | - Defaults to `roadmap` if invalid value provided 45 | - See Google's online [docs](https://developers.google.com/maps/documentation/maps-static/start) for more information. 46 | 47 | **Example:** 48 | - `[p]map new york -zoom 17 -maptype hybrid` 49 | - `[p]map jumeirah beach dubai -maptype terrain` 50 | - `[p]map niagara falls canada -zoom 15 -maptype satellite` 51 | """ 52 | api_key = (await ctx.bot.get_shared_api_tokens("googlemaps")).get("api_key") 53 | if not api_key: 54 | await ctx.send("⚠️ Bot owner need to set API key first!", ephemeral=True) 55 | return 56 | 57 | location, zoom, map_type = flags.location, flags.zoom, flags.maptype 58 | if not location: 59 | await ctx.send("You need to provide a location name silly", ephemeral=True) 60 | return 61 | zoom = zoom if (1 <= zoom <= 20) else 12 62 | map_type = "roadmap" if map_type not in MAP_TYPES else map_type 63 | 64 | await ctx.typing() 65 | base_url = "https://maps.googleapis.com/maps/api/staticmap" 66 | params = { 67 | "center": location, 68 | "zoom": zoom, 69 | "size": "640x640", 70 | "scale": "2", 71 | "format": "png32", 72 | "maptype": map_type, 73 | "key": api_key, 74 | } 75 | if map_type == "roadmap": 76 | params["style"] = "feature:road.highway|element:labels.text.fill|visibility:on|color:0xffffff" 77 | try: 78 | async with ctx.bot.session.get(base_url, params=params) as response: 79 | if response.status != 200: 80 | await ctx.send(f"https://http.cat/{response.status}") 81 | return 82 | image = io.BytesIO(await response.read()) 83 | image.seek(0) 84 | except Exception as error: 85 | await ctx.send(f"Operation timed out: {error}") 86 | return 87 | 88 | url = f"" 89 | await ctx.send(url, file=discord.File(image, "google_maps.png")) 90 | image.close() 91 | return 92 | -------------------------------------------------------------------------------- /moviedb/__init__.py: -------------------------------------------------------------------------------- 1 | from discord.utils import maybe_coroutine 2 | from redbot.core.bot import Red 3 | 4 | from .moviedb import MovieDB 5 | 6 | __red_end_user_data_statement__ = "This cog does not persistently store data about users." 7 | 8 | 9 | async def setup(bot: Red): 10 | await maybe_coroutine(bot.add_cog, MovieDB()) 11 | -------------------------------------------------------------------------------- /moviedb/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owocado/cogs/4d829f151b8d2d1455329720110cf3d1e332e949/moviedb/api/__init__.py -------------------------------------------------------------------------------- /moviedb/api/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from dataclasses import dataclass, field 5 | from typing import Any, Dict, List, Literal, Optional, Sequence 6 | 7 | import aiohttp 8 | 9 | 10 | API_BASE = "https://api.themoviedb.org/3" 11 | CDN_BASE = "https://image.tmdb.org/t/p/original" 12 | 13 | 14 | @dataclass 15 | class BaseSearch: 16 | id: int 17 | media_type: str 18 | overview: str = '' 19 | popularity: float = 0.0 20 | vote_count: int = 0 21 | vote_average: float = 0.0 22 | backdrop_path: str = '' 23 | poster_path: str = '' 24 | genre_ids: Sequence[int] = field(default_factory=list) 25 | 26 | 27 | @dataclass 28 | class MediaNotFound: 29 | status_message: str 30 | http_code: int 31 | status_code: Optional[int] = None 32 | success: bool = False 33 | 34 | def __len__(self) -> int: 35 | return 0 36 | 37 | def __str__(self) -> str: 38 | return self.status_message or f'https://http.cat/{self.http_code}.jpg' 39 | 40 | 41 | @dataclass 42 | class CelebrityCast: 43 | id: int 44 | order: int 45 | name: str 46 | original_name: str 47 | adult: bool 48 | credit_id: str 49 | character: str 50 | known_for_department: str 51 | gender: int = 0 52 | cast_id: int = 0 53 | popularity: float = 0.0 54 | profile_path: str = "" 55 | 56 | 57 | @dataclass 58 | class Genre: 59 | id: int 60 | name: str 61 | 62 | 63 | @dataclass 64 | class ProductionCompany: 65 | id: int 66 | name: str 67 | logo_path: str = "" 68 | origin_country: str = "" 69 | 70 | 71 | @dataclass 72 | class ProductionCountry: 73 | iso_3166_1: str 74 | name: str 75 | 76 | 77 | @dataclass 78 | class SpokenLanguage: 79 | name: str 80 | iso_639_1: str 81 | english_name: str = "" 82 | 83 | 84 | async def multi_search( 85 | session: aiohttp.ClientSession, 86 | api_key: str, 87 | query: str, 88 | include_adult: Literal["true", "false"] = "false", 89 | ) -> List[Dict[str, Any]] | MediaNotFound: 90 | try: 91 | async with session.get( 92 | f"{API_BASE}/search/multi", 93 | params={"api_key": api_key, "query": query, "include_adult": include_adult} 94 | ) as resp: 95 | if resp.status in [401, 404]: 96 | data = await resp.json() 97 | return MediaNotFound(**data) 98 | if resp.status != 200: 99 | return MediaNotFound("No results found.", resp.status) 100 | all_data: dict = await resp.json() 101 | except (asyncio.TimeoutError, aiohttp.ClientError): 102 | return MediaNotFound("Operation timed out!", 408) 103 | 104 | if not all_data.get("results"): 105 | return MediaNotFound("No results found.", resp.status) 106 | return all_data["results"] 107 | -------------------------------------------------------------------------------- /moviedb/api/details.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from dataclasses import dataclass, field 5 | from typing import Any, Dict, Optional, Sequence 6 | 7 | import aiohttp 8 | from redbot.core.utils.chat_formatting import humanize_number 9 | 10 | from .base import API_BASE, CelebrityCast, Genre, MediaNotFound, ProductionCompany, ProductionCountry, SpokenLanguage 11 | from ..utils import format_date 12 | 13 | 14 | @dataclass 15 | class MovieDetails: 16 | id: int 17 | title: str 18 | original_title: str 19 | original_language: str 20 | adult: bool 21 | video: bool 22 | status: str 23 | tagline: str = '' 24 | overview: str = '' 25 | release_date: str = '' 26 | budget: int = 0 27 | revenue: int = 0 28 | runtime: int = 0 29 | vote_count: int = 0 30 | vote_average: float = 0.0 31 | popularity: float = 0.0 32 | homepage: Optional[str] = None 33 | imdb_id: Optional[str] = None 34 | poster_path: Optional[str] = None 35 | backdrop_path: Optional[str] = None 36 | belongs_to_collection: Optional[Dict[str, Any]] = None 37 | genres: Sequence[Genre] = field(default_factory=list) 38 | credits: Sequence[CelebrityCast] = field(default_factory=list) 39 | spoken_languages: Sequence[SpokenLanguage] = field(default_factory=list) 40 | production_companies: Sequence[ProductionCompany] = field(default_factory=list) 41 | production_countries: Sequence[ProductionCountry] = field(default_factory=list) 42 | 43 | @property 44 | def all_genres(self) -> str: 45 | return ', '.join([g.name for g in self.genres]) 46 | 47 | @property 48 | def all_production_companies(self) -> str: 49 | return ', '.join([g.name for g in self.production_companies]) 50 | 51 | @property 52 | def all_production_countries(self) -> str: 53 | return ', '.join([g.name for g in self.production_countries]) 54 | 55 | @property 56 | def all_spoken_languages(self) -> str: 57 | return ', '.join([g.name for g in self.spoken_languages]) 58 | 59 | @property 60 | def humanize_runtime(self) -> str: 61 | if not self.runtime: 62 | return '' 63 | return f'{self.runtime // 60}h {self.runtime % 60}m' 64 | 65 | @property 66 | def humanize_votes(self) -> str: 67 | if not self.vote_count: 68 | return '' 69 | return f'**{self.vote_average:.1f}** ⭐ / 10\n({humanize_number(self.vote_count)} votes)' 70 | 71 | @classmethod 72 | def from_json(cls, data: Dict[str, Any]) -> MovieDetails: 73 | btc = data.pop('belongs_to_collection', None) 74 | genres = [Genre(**g) for g in data.pop('genres', [])] 75 | credits = [CelebrityCast(**c) for c in data.pop('credits', {}).get('cast', [])] 76 | spoken_languages = [ 77 | SpokenLanguage(**l) for l in data.pop('spoken_languages', []) 78 | ] 79 | production_companies = [ 80 | ProductionCompany(**p) for p in data.pop('production_companies', []) 81 | ] 82 | production_countries = [ 83 | ProductionCountry(**pc) for pc in data.pop('production_countries', []) 84 | ] 85 | return cls( 86 | belongs_to_collection=btc, 87 | genres=genres, 88 | credits=credits, 89 | spoken_languages=spoken_languages, 90 | production_companies=production_companies, 91 | production_countries=production_countries, 92 | **data 93 | ) 94 | 95 | @classmethod 96 | async def request( 97 | cls, session: aiohttp.ClientSession, api_key: str, movie_id: Any 98 | ) -> MediaNotFound | MovieDetails: 99 | movie_data = {} 100 | params = {'api_key': api_key, 'append_to_response': 'credits'} 101 | try: 102 | async with session.get(f'{API_BASE}/movie/{movie_id}', params=params) as resp: 103 | if resp.status in [401, 404]: 104 | err_data = await resp.json() 105 | return MediaNotFound(err_data['status_message'], resp.status) 106 | if resp.status != 200: 107 | return MediaNotFound('', resp.status) 108 | movie_data = await resp.json() 109 | except (asyncio.TimeoutError, aiohttp.ClientError): 110 | return MediaNotFound('⚠️ Operation timed out.', 408) 111 | 112 | return cls.from_json(movie_data) 113 | 114 | 115 | @dataclass 116 | class Creator: 117 | id: int 118 | credit_id: str 119 | name: str 120 | gender: int 121 | profile_path: str = '' 122 | 123 | 124 | @dataclass 125 | class EpisodeInfo: 126 | id: int 127 | name: str 128 | overview: str 129 | air_date: str 130 | episode_number: int 131 | season_number: int 132 | production_code: str 133 | runtime: None 134 | show_id: int = 0 135 | vote_average: float = 0.0 136 | vote_count: int = 0 137 | still_path: str = '' 138 | 139 | 140 | 141 | @dataclass 142 | class Network: 143 | id: int 144 | name: str 145 | logo_path: str = '' 146 | origin_country: str = '' 147 | 148 | 149 | @dataclass 150 | class Season: 151 | id: int 152 | name: str 153 | air_date: str 154 | overview: str 155 | episode_count: int 156 | poster_path: str = '' 157 | season_number: int = 0 158 | 159 | 160 | @dataclass 161 | class TVShowDetails: 162 | id: int 163 | adult: bool 164 | name: str 165 | original_name: str 166 | first_air_date: str 167 | last_air_date: str 168 | homepage: str 169 | overview: str 170 | in_production: bool 171 | status: str 172 | type: str = '' 173 | tagline: str = '' 174 | number_of_episodes: int = 0 175 | number_of_seasons: int = 0 176 | popularity: float = 0.0 177 | vote_average: float = 0.0 178 | vote_count: int = 0 179 | original_language: str = '' 180 | backdrop_path: str = '' 181 | poster_path: str = '' 182 | next_episode_to_air: Optional[EpisodeInfo] = None 183 | last_episode_to_air: Optional[EpisodeInfo] = None 184 | created_by: Sequence[Creator] = field(default_factory=list) 185 | credits: Sequence[CelebrityCast] = field(default_factory=list) 186 | episode_run_time: Sequence[int] = field(default_factory=list) 187 | genres: Sequence[Genre] = field(default_factory=list) 188 | seasons: Sequence[Season] = field(default_factory=list) 189 | languages: Sequence[str] = field(default_factory=list) 190 | networks: Sequence[Network] = field(default_factory=list) 191 | origin_country: Sequence[str] = field(default_factory=list) 192 | production_companies: Sequence[ProductionCompany] = field(default_factory=list) 193 | production_countries: Sequence[ProductionCountry] = field(default_factory=list) 194 | spoken_languages: Sequence[SpokenLanguage] = field(default_factory=list) 195 | 196 | @property 197 | def all_genres(self) -> str: 198 | return ', '.join([g.name for g in self.genres]) 199 | 200 | @property 201 | def all_production_companies(self) -> str: 202 | return ', '.join([g.name for g in self.production_companies]) 203 | 204 | @property 205 | def all_production_countries(self) -> str: 206 | return ', '.join([g.name for g in self.production_countries]) 207 | 208 | @property 209 | def all_spoken_languages(self) -> str: 210 | return ', '.join([g.name for g in self.spoken_languages]) 211 | 212 | @property 213 | def all_networks(self) -> str: 214 | return ', '.join([g.name for g in self.networks]) 215 | 216 | @property 217 | def all_seasons(self) -> str: 218 | return '\n'.join( 219 | f'**{i}.** {tv.name}{format_date(tv.air_date, prefix=", aired ")}' 220 | f' ({tv.episode_count or 0} episodes)' 221 | for i, tv in enumerate(self.seasons, start=1) 222 | ) 223 | 224 | @property 225 | def creators(self) -> str: 226 | return '\n'.join([c.name for c in self.created_by]) 227 | 228 | @property 229 | def humanize_votes(self) -> str: 230 | return f'**{self.vote_average:.1f}** ⭐ / 10\n({humanize_number(self.vote_count)} votes)' 231 | 232 | @property 233 | def next_episode_info(self) -> str: 234 | if not self.next_episode_to_air: 235 | return '' 236 | 237 | next_ep = self.next_episode_to_air 238 | next_airing = 'not sure when this episode will air!' 239 | if next_ep.air_date: 240 | next_airing = format_date(next_ep.air_date, prefix="likely airing ") 241 | return ( 242 | f'**S{next_ep.season_number or 0}E{next_ep.episode_number or 0}**' 243 | f' : {next_airing}\n**Titled as:** {next_ep.name}' 244 | ) 245 | 246 | @property 247 | def seasons_count(self) -> str: 248 | return f'{self.number_of_seasons} ({self.number_of_episodes} episodes)' 249 | 250 | @classmethod 251 | def from_dict(cls, data: Dict[str, Any]) -> TVShowDetails: 252 | n_eta = data.pop('next_episode_to_air', {}) 253 | l_eta = data.pop('last_episode_to_air', {}) 254 | created_by = [Creator(**c) for c in data.pop('created_by', [])] 255 | credits = [CelebrityCast(**ccs) for ccs in data.pop('credits', {}).get('cast', [])] 256 | genres = [Genre(**g) for g in data.pop('genres', [])] 257 | seasons = [Season(**s) for s in data.pop('seasons', [])] 258 | networks = [Network(**n) for n in data.pop('networks', [])] 259 | production_companies = [ 260 | ProductionCompany(**pcom) for pcom in data.pop('production_companies', []) 261 | ] 262 | production_countries = [ 263 | ProductionCountry(**pctr) for pctr in data.pop('production_countries', []) 264 | ] 265 | spoken_languages = [ 266 | SpokenLanguage(**sl) for sl in data.pop('spoken_languages', []) 267 | ] 268 | return cls( 269 | next_episode_to_air=EpisodeInfo(**n_eta) if n_eta else None, 270 | last_episode_to_air=EpisodeInfo(**l_eta) if l_eta else None, 271 | created_by=created_by, 272 | credits=credits, 273 | genres=genres, 274 | seasons=seasons, 275 | networks=networks, 276 | production_companies=production_companies, 277 | production_countries=production_countries, 278 | spoken_languages=spoken_languages, 279 | **data 280 | ) 281 | 282 | @classmethod 283 | async def request( 284 | cls, 285 | session: aiohttp.ClientSession, 286 | api_key: str, 287 | tvshow_id: Any 288 | ) -> MediaNotFound | TVShowDetails: 289 | tvshow_data = {} 290 | params = {'api_key': api_key, 'append_to_response': 'credits'} 291 | try: 292 | async with session.get(f'{API_BASE}/tv/{tvshow_id}', params=params) as resp: 293 | if resp.status in [401, 404]: 294 | err_data = await resp.json() 295 | return MediaNotFound(err_data['status_message'], resp.status) 296 | if resp.status != 200: 297 | return MediaNotFound('', resp.status) 298 | tvshow_data = await resp.json() 299 | except (asyncio.TimeoutError, aiohttp.ClientError): 300 | return MediaNotFound('⚠️ Operation timed out.', 408) 301 | 302 | return cls.from_dict(tvshow_data) -------------------------------------------------------------------------------- /moviedb/api/person.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import asyncio 3 | 4 | from dataclasses import dataclass, field 5 | from typing import List, Optional 6 | 7 | import aiohttp 8 | 9 | from .base import API_BASE, CDN_BASE, MediaNotFound as NotFound 10 | 11 | 12 | @dataclass 13 | class BaseCredits: 14 | id: int 15 | media_type: str 16 | name: Optional[str] = None 17 | original_name: Optional[str] = None 18 | title: Optional[str] = None 19 | original_title: Optional[str] = None 20 | episode_count: Optional[int] = None 21 | first_air_date: Optional[str] = None 22 | release_date: Optional[str] = None 23 | origin_country: List[str] = field(default_factory=list) 24 | 25 | @property 26 | def year(self) -> str: 27 | date = self.first_air_date or self.release_date 28 | return date.split("-")[0] if date and "-" in date else "" 29 | 30 | 31 | @dataclass 32 | class CastCredits(BaseCredits): 33 | character: str = "" 34 | 35 | @property 36 | def portray_as(self) -> str: 37 | return f"as *{self.character}*" if self.character else "" 38 | 39 | @classmethod 40 | def from_data(cls, data: dict) -> CastCredits: 41 | for key in [ 42 | "credit_id", "adult", "video", "original_language", "overview", "popularity", 43 | "order", "vote_average", "vote_count", "backdrop_path", "poster_path", "genre_ids" 44 | ]: 45 | data.pop(key, None) 46 | return cls(**data) 47 | 48 | 49 | @dataclass 50 | class CrewCredits(BaseCredits): 51 | department: str = "" 52 | job: str = "" 53 | 54 | @classmethod 55 | def from_data(cls, data: dict) -> CastCredits: 56 | for key in [ 57 | "credit_id", "adult", "video", "original_language", "overview", "popularity", 58 | "order", "vote_average", "vote_count", "backdrop_path", "poster_path", "genre_ids" 59 | ]: 60 | data.pop(key, None) 61 | return cls(**data) 62 | 63 | 64 | @dataclass 65 | class PersonCredits: 66 | cast: List[CastCredits] = field(default_factory=list) 67 | crew: List[CrewCredits] = field(default_factory=list) 68 | 69 | @classmethod 70 | def from_data(cls, data: dict) -> PersonCredits: 71 | cast_data = data.pop("cast", []) 72 | crew_data = data.pop("crew", []) 73 | cast_data.sort( 74 | key=lambda x: x.get('release_date', '') or x.get('first_air_date', ''), 75 | reverse=True 76 | ) 77 | crew_data.sort( 78 | key=lambda x: x.get('release_date', '') or x.get('first_air_date', ''), 79 | reverse=True 80 | ) 81 | return cls( 82 | cast=[CastCredits.from_data(csc) for csc in cast_data] if cast_data else [], 83 | crew=[CrewCredits.from_data(crw) for crw in crew_data] if crew_data else [] 84 | ) 85 | 86 | 87 | @dataclass 88 | class Person: 89 | id: int 90 | name: str 91 | gender: int 92 | adult: bool 93 | imdb_id: str 94 | biography: str 95 | known_for_department: str 96 | popularity: float 97 | birthday: Optional[str] = None 98 | deathday: Optional[str] = None 99 | place_of_birth: Optional[str] = None 100 | profile_path: Optional[str] = None 101 | homepage: Optional[str] = None 102 | combined_credits: Optional[PersonCredits] = None 103 | also_known_as: List[str] = field(default_factory=list) 104 | 105 | @property 106 | def person_image(self) -> str: 107 | return f"{CDN_BASE}{self.profile_path}" if self.profile_path else "" 108 | 109 | @classmethod 110 | def from_data(cls, data: dict) -> Person: 111 | credits = data.pop("combined_credits", {}) 112 | return cls(combined_credits=PersonCredits.from_data(credits), **data) 113 | 114 | @classmethod 115 | async def request( 116 | cls, 117 | session: aiohttp.ClientSession, 118 | api_key: str, 119 | person_id: str 120 | ) -> Person | NotFound: 121 | try: 122 | async with session.get( 123 | f"{API_BASE}/person/{person_id}", 124 | params={"api_key": api_key, "append_to_response": "combined_credits"} 125 | ) as resp: 126 | if resp.status in [401, 404]: 127 | data = await resp.json() 128 | return NotFound(**data) 129 | if resp.status != 200: 130 | return NotFound("No results found.", resp.status) 131 | person_data = await resp.json() 132 | except (asyncio.TimeoutError, aiohttp.ClientConnectionError): 133 | return NotFound("Operation timed out!", 408) 134 | 135 | return cls.from_data(person_data) 136 | -------------------------------------------------------------------------------- /moviedb/api/search.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Optional, List 5 | 6 | import aiohttp 7 | 8 | from .base import BaseSearch, MediaNotFound, multi_search 9 | 10 | 11 | @dataclass 12 | class PersonSearch: 13 | id: int 14 | adult: bool 15 | name: str 16 | gender: int 17 | media_type: str 18 | popularity: float 19 | known_for_department: str 20 | profile_path: Optional[str] = None 21 | known_for: List[dict] = field(default_factory=list) 22 | 23 | @property 24 | def notable_roles(self) -> str: 25 | if not self.known_for: 26 | return "" 27 | first = self.known_for[0].get('title') or self.known_for[0].get('name') 28 | if len(self.known_for) > 1: 29 | first += f" & {len(self.known_for) - 1} more!" 30 | return f"(known for {first})" 31 | 32 | @classmethod 33 | async def request( 34 | cls, 35 | session: aiohttp.ClientSession, 36 | api_key: str, 37 | query: str 38 | ) -> MediaNotFound | List[PersonSearch]: 39 | all_data = await multi_search(session, api_key, query) 40 | if isinstance(all_data, MediaNotFound): 41 | return all_data 42 | filtered_data = [media for media in all_data if media.get("media_type") == "person"] 43 | if not filtered_data: 44 | return MediaNotFound("❌ No results.", 404) 45 | 46 | # filtered_data.sort(key=lambda x: x.get('name')) 47 | return [cls(**person) for person in filtered_data] 48 | 49 | 50 | @dataclass 51 | class MovieSearch(BaseSearch): 52 | title: str = '' 53 | original_title: str = '' 54 | release_date: str = '' 55 | original_language: str = '' 56 | video: Optional[bool] = None 57 | adult: Optional[bool] = None 58 | 59 | @classmethod 60 | async def request( 61 | cls, 62 | session: aiohttp.ClientSession, 63 | api_key: str, 64 | query: str 65 | ) -> MediaNotFound | List[MovieSearch]: 66 | all_data = await multi_search(session, api_key, query) 67 | if isinstance(all_data, MediaNotFound): 68 | return all_data 69 | filtered_data = [media for media in all_data if media.get("media_type") == "movie"] 70 | if not filtered_data: 71 | return MediaNotFound("❌ No results.", 404) 72 | 73 | filtered_data.sort(key=lambda x: x.get('release_date'), reverse=True) 74 | return [cls(**movie) for movie in filtered_data] 75 | 76 | 77 | @dataclass 78 | class TVShowSearch(BaseSearch): 79 | name: str = '' 80 | original_name: str = '' 81 | first_air_date: str = '' 82 | original_language: str = '' 83 | origin_country: List[str] = field(default_factory=list) 84 | 85 | @classmethod 86 | async def request( 87 | cls, 88 | session: aiohttp.ClientSession, 89 | api_key: str, 90 | query: str 91 | ) -> MediaNotFound | List[TVShowSearch]: 92 | all_data = await multi_search(session, api_key, query) 93 | if isinstance(all_data, MediaNotFound): 94 | return all_data 95 | filtered_data = [media for media in all_data if media.get("media_type") == "tv"] 96 | if not filtered_data: 97 | return MediaNotFound("❌ No results.", 404) 98 | 99 | filtered_data.sort(key=lambda x: x.get('first_air_date'), reverse=True) 100 | return [cls(**tvshow) for tvshow in filtered_data] 101 | -------------------------------------------------------------------------------- /moviedb/api/suggestions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from dataclasses import dataclass 5 | from typing import Any, Dict, Sequence 6 | 7 | import aiohttp 8 | from redbot.core.utils.chat_formatting import humanize_number 9 | 10 | from .base import API_BASE, MediaNotFound 11 | 12 | 13 | @dataclass 14 | class BaseSuggestions: 15 | id: int 16 | adult: bool 17 | overview: str 18 | original_language: str 19 | media_type: str 20 | popularity: float 21 | vote_count: int 22 | vote_average: float 23 | genre_ids: Sequence[int] 24 | 25 | 26 | @dataclass 27 | class MovieSuggestions(BaseSuggestions): 28 | title: str 29 | original_title: str 30 | release_date: str 31 | video: bool 32 | backdrop_path: str = '' 33 | poster_path: str = '' 34 | 35 | @property 36 | def humanize_votes(self) -> str: 37 | return f'**{self.vote_average:.1f}** ⭐ / 10\n({humanize_number(self.vote_count)} votes)' 38 | 39 | @classmethod 40 | def from_json(cls, data: Dict[str, Any]) -> MovieSuggestions: 41 | genre_ids = data.pop('genre_ids', []) 42 | return cls(genre_ids=genre_ids, **data) 43 | 44 | @classmethod 45 | async def request( 46 | cls, 47 | session: aiohttp.ClientSession, 48 | api_key: str, 49 | movie_id: Any 50 | ) -> MediaNotFound | Sequence[MovieSuggestions]: 51 | url = f"{API_BASE}/movie/{movie_id}/recommendations" 52 | try: 53 | async with session.get(url, params={"api_key": api_key}) as resp: 54 | if resp.status in [401, 404]: 55 | err_data = await resp.json() 56 | return MediaNotFound(err_data['status_message'], resp.status) 57 | if resp.status != 200: 58 | return MediaNotFound('', resp.status) 59 | data = await resp.json() 60 | except (aiohttp.ClientError, asyncio.TimeoutError): 61 | return MediaNotFound('⚠️ Operation timed out.', 408) 62 | 63 | if not data.get('results') or data['total_results'] < 1: 64 | return MediaNotFound('❌ No recommendations found related to that movie.', 404) 65 | 66 | return [cls.from_json(obj) for obj in data['results']] 67 | 68 | 69 | @dataclass 70 | class TVShowSuggestions(BaseSuggestions): 71 | name: str 72 | original_name: str 73 | first_air_date: str 74 | origin_country: Sequence[str] 75 | backdrop_path: str = '' 76 | poster_path: str = '' 77 | 78 | @property 79 | def humanize_votes(self) -> str: 80 | return f'**{self.vote_average:.1f}** ⭐ / 10\n({humanize_number(self.vote_count)} votes)' 81 | 82 | @classmethod 83 | def from_json(cls, data: Dict[str, Any]) -> TVShowSuggestions: 84 | genre_ids = data.pop('genre_ids', []) 85 | origin_country = data.pop('origin_country', []) 86 | return cls(origin_country=origin_country, genre_ids=genre_ids, **data) 87 | 88 | @classmethod 89 | async def request( 90 | cls, 91 | session: aiohttp.ClientSession, 92 | api_key: str, 93 | tmdb_id: Any 94 | ) -> MediaNotFound | Sequence[TVShowSuggestions]: 95 | url = f"{API_BASE}/tv/{tmdb_id}/recommendations" 96 | try: 97 | async with session.get(url, params={"api_key": api_key}) as resp: 98 | if resp.status in [401, 404]: 99 | err_data = await resp.json() 100 | return MediaNotFound(err_data['status_message'], resp.status) 101 | if resp.status != 200: 102 | return MediaNotFound('', resp.status) 103 | data = await resp.json() 104 | except (aiohttp.ClientError, asyncio.TimeoutError): 105 | return MediaNotFound('⚠️ Operation timed out.', 408) 106 | 107 | if not data.get('results') or data['total_results'] < 1: 108 | return MediaNotFound('❌ No recommendations found related to that TV show.', 404) 109 | 110 | return [cls.from_json(obj) for obj in data['results']] -------------------------------------------------------------------------------- /moviedb/converter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import contextlib 5 | from datetime import datetime 6 | from textwrap import shorten 7 | from typing import List, cast 8 | 9 | import discord 10 | from redbot.core.bot import Red 11 | from redbot.core.commands import BadArgument, Context 12 | 13 | from .api.base import MediaNotFound 14 | from .api.details import MovieDetails, TVShowDetails 15 | from .api.person import Person as PersonDetails 16 | from .api.search import MovieSearch, PersonSearch, TVShowSearch 17 | from .api.suggestions import MovieSuggestions, TVShowSuggestions 18 | from .utils import format_date 19 | 20 | 21 | class PersonFinder(discord.app_commands.Transformer): 22 | 23 | async def convert(self, ctx: Context, argument: str): 24 | session = ctx.bot.get_cog('MovieDB').session 25 | api_key = (await ctx.bot.get_shared_api_tokens("tmdb")).get("api_key", "") 26 | results = await PersonSearch.request(session, api_key, argument.lower()) 27 | if isinstance(results, MediaNotFound): 28 | raise BadArgument(str(results)) 29 | if not results: 30 | raise BadArgument("⛔ No celebrity or media persons found from given query.") 31 | if len(results) == 1: 32 | return await PersonDetails.request(session, api_key, results[0].id) 33 | 34 | items = [ 35 | f"**{i}.** {obj.name} {obj.notable_roles}" for i, obj in enumerate(results, 1) 36 | ] 37 | prompt: discord.Message = await ctx.send( 38 | f"Found below {len(items)} results. Choose one in 60 seconds:\n\n" 39 | + "\n".join(items) 40 | ) 41 | 42 | def check(msg: discord.Message) -> bool: 43 | return bool( 44 | msg.content and msg.content.isdigit() 45 | and int(msg.content) in range(len(items) + 1) 46 | and msg.author.id == ctx.author.id 47 | and msg.channel.id == ctx.channel.id 48 | ) 49 | 50 | try: 51 | choice = await ctx.bot.wait_for("message", timeout=60, check=check) 52 | except asyncio.TimeoutError: 53 | choice = None 54 | 55 | if choice is None or (choice.content and choice.content.strip() == "0"): 56 | with contextlib.suppress(discord.NotFound, discord.HTTPException): 57 | await prompt.delete() 58 | raise BadArgument("‼ You didn't pick a valid choice. Operation cancelled.") 59 | 60 | with contextlib.suppress(discord.NotFound, discord.HTTPException): 61 | await prompt.delete() 62 | person_id = results[int(choice.content.strip()) - 1].id 63 | return await PersonDetails.request(session, api_key, person_id) 64 | 65 | async def transform(self, interaction: discord.Interaction, value: str): 66 | bot = cast(Red, interaction.client) 67 | session = bot.get_cog('MovieDB').session 68 | key = (await bot.get_shared_api_tokens('tmdb')).get('api_key', '') 69 | return await PersonDetails.request(session, key, value) 70 | 71 | async def autocomplete( 72 | self, interaction: discord.Interaction, value: int | float | str 73 | ) -> List[discord.app_commands.Choice]: 74 | bot = cast(Red, interaction.client) 75 | session = bot.get_cog('MovieDB').session 76 | token = (await bot.get_shared_api_tokens('tmdb')).get('api_key', '') 77 | results = await PersonSearch.request(session, token, str(value)) 78 | if not results or isinstance(results, MediaNotFound): 79 | return [] 80 | 81 | choices = [ 82 | discord.app_commands.Choice( 83 | name=f"{person.name} {person.notable_roles}", value=str(person.id) 84 | ) 85 | for person in results 86 | ] 87 | return choices[:24] 88 | 89 | 90 | class MovieFinder(discord.app_commands.Transformer): 91 | 92 | async def convert(self, ctx: Context, argument: str): 93 | session = ctx.bot.get_cog('MovieDB').session 94 | api_key = (await ctx.bot.get_shared_api_tokens("tmdb")).get("api_key", "") 95 | results = await MovieSearch.request(session, api_key, argument.lower()) 96 | if isinstance(results, MediaNotFound): 97 | raise BadArgument(str(results)) 98 | if not results: 99 | raise BadArgument("⛔ No such movie found from given query.") 100 | if len(results) == 1: 101 | return await MovieDetails.request(session, api_key, results[0].id) 102 | 103 | # https://github.com/Sitryk/sitcogsv3/blob/master/lyrics/lyrics.py#L142 104 | items = [ 105 | f"**{i}.** {obj.title} ({format_date(obj.release_date, 'd')})" 106 | for i, obj in enumerate(results, start=1) 107 | ] 108 | prompt: discord.Message = await ctx.send( 109 | f"Found below {len(items)} movies. Choose one in 60 seconds:\n\n" 110 | + "\n".join(items).replace(" ()", "") 111 | ) 112 | 113 | def check(msg: discord.Message) -> bool: 114 | return bool( 115 | msg.content and msg.content.isdigit() 116 | and int(msg.content) in range(len(items) + 1) 117 | and msg.author.id == ctx.author.id 118 | and msg.channel.id == ctx.channel.id 119 | ) 120 | 121 | try: 122 | choice = await ctx.bot.wait_for("message", timeout=60, check=check) 123 | except asyncio.TimeoutError: 124 | choice = None 125 | 126 | if choice is None or (choice.content and choice.content.strip() == "0"): 127 | with contextlib.suppress(discord.NotFound, discord.HTTPException): 128 | await prompt.delete() 129 | raise BadArgument("‼ You didn't pick a valid choice. Operation cancelled.") 130 | 131 | with contextlib.suppress(discord.NotFound, discord.HTTPException): 132 | await prompt.delete() 133 | movie_id = results[int(choice.content.strip()) - 1].id 134 | return await MovieDetails.request(session, api_key, movie_id) 135 | 136 | async def transform(self, interaction: discord.Interaction, value: str): 137 | bot = cast(Red, interaction.client) 138 | session = bot.get_cog('MovieDB').session 139 | key = (await bot.get_shared_api_tokens('tmdb')).get('api_key', '') 140 | if 'suggest' in interaction.command.name: 141 | return await MovieSuggestions.request(session, key, value) 142 | return await MovieDetails.request(session, key, value) 143 | 144 | async def autocomplete( 145 | self, interaction: discord.Interaction, value: int | float | str 146 | ) -> List[discord.app_commands.Choice]: 147 | bot = cast(Red, interaction.client) 148 | session = bot.get_cog('MovieDB').session 149 | token = (await bot.get_shared_api_tokens('tmdb')).get('api_key', '') 150 | results = await MovieSearch.request(session, token, str(value)) 151 | if not results or isinstance(results, MediaNotFound): 152 | return [] 153 | 154 | def parser(title: str, date: str) -> str: 155 | if not date: 156 | return shorten(title, 96, placeholder=' …') 157 | date = datetime.strptime(date, '%Y-%m-%d').strftime('%d %b, %Y') 158 | return f"{shorten(title, 82, placeholder=' …')} ({date})" 159 | 160 | choices = [ 161 | discord.app_commands.Choice( 162 | name=f"{parser(movie.title, movie.release_date)}", 163 | value=str(movie.id) 164 | ) 165 | for movie in results 166 | ] 167 | return choices[:24] 168 | 169 | 170 | class TVShowFinder(discord.app_commands.Transformer): 171 | 172 | async def convert(self, ctx: Context, argument: str): 173 | session = ctx.bot.get_cog('MovieDB').session 174 | api_key = (await ctx.bot.get_shared_api_tokens("tmdb")).get("api_key", "") 175 | results = await TVShowSearch.request(session, api_key, argument.lower()) 176 | if isinstance(results, MediaNotFound): 177 | raise BadArgument(str(results)) 178 | if not results: 179 | raise BadArgument("⛔ No such TV show found from given query.") 180 | if len(results) == 1: 181 | return await TVShowDetails.request(session, api_key, results[0].id) 182 | 183 | # https://github.com/Sitryk/sitcogsv3/blob/master/lyrics/lyrics.py#L142 184 | items = [ 185 | f"**{i}.** {v.name or v.original_name}" 186 | f" ({format_date(v.first_air_date, 'd', prefix='first aired on ')})" 187 | for i, v in enumerate(results, start=1) 188 | ] 189 | prompt: discord.Message = await ctx.send( 190 | f"Found below {len(items)} TV shows. Choose one in 60 seconds:\n\n" 191 | + "\n".join(items).replace(" ()", "") 192 | ) 193 | 194 | def check(msg: discord.Message) -> bool: 195 | return bool( 196 | msg.content and msg.content.isdigit() 197 | and int(msg.content) in range(len(items) + 1) 198 | and msg.author.id == ctx.author.id 199 | and msg.channel.id == ctx.channel.id 200 | ) 201 | 202 | try: 203 | choice = await ctx.bot.wait_for("message", timeout=60, check=check) 204 | except asyncio.TimeoutError: 205 | choice = None 206 | 207 | if choice is None or (choice.content and choice.content.strip() == "0"): 208 | with contextlib.suppress(discord.NotFound, discord.HTTPException): 209 | await prompt.delete() 210 | raise BadArgument("‼ You didn't pick a valid choice. Operation cancelled.") 211 | 212 | with contextlib.suppress(discord.NotFound, discord.HTTPException): 213 | await prompt.delete() 214 | tv_id = results[int(choice.content.strip()) - 1].id 215 | return await TVShowDetails.request(session, api_key, tv_id) 216 | 217 | async def transform(self, interaction: discord.Interaction, value: str): 218 | bot = cast(Red, interaction.client) 219 | session = bot.get_cog('MovieDB').session 220 | key = (await bot.get_shared_api_tokens('tmdb')).get('api_key', '') 221 | if 'suggest' in interaction.command.name: 222 | return await TVShowSuggestions.request(session, key, value) 223 | return await TVShowDetails.request(session, key, value) 224 | 225 | async def autocomplete( 226 | self, interaction: discord.Interaction, value: int | float | str 227 | ) -> List[discord.app_commands.Choice]: 228 | bot = cast(Red, interaction.client) 229 | session = bot.get_cog('MovieDB').session 230 | token = (await bot.get_shared_api_tokens('tmdb')).get('api_key', '') 231 | results = await TVShowSearch.request(session, token, str(value)) 232 | if not results or isinstance(results, MediaNotFound): 233 | return [] 234 | 235 | def parser(title: str, date: str) -> str: 236 | if not date: 237 | return shorten(title, 96, placeholder=' …') 238 | date = datetime.strptime(date, '%Y-%m-%d').strftime('%d %b, %Y') 239 | return f'{shorten(title, 70, placeholder=" …")} (began on {date})' 240 | 241 | choices = [ 242 | discord.app_commands.Choice( 243 | name=f"{parser(tvshow.name, tvshow.first_air_date)}", 244 | value=str(tvshow.id) 245 | ) 246 | for tvshow in results 247 | ] 248 | return choices[:24] 249 | -------------------------------------------------------------------------------- /moviedb/embed_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from textwrap import shorten 4 | from typing import List, Sequence 5 | 6 | import discord 7 | from redbot.core.utils.chat_formatting import pagify 8 | 9 | from .api.base import CDN_BASE, CelebrityCast 10 | from .api.details import MovieDetails, TVShowDetails 11 | from .api.person import Person 12 | from .api.suggestions import MovieSuggestions, TVShowSuggestions 13 | from .utils import format_date, natural_size 14 | 15 | GENDERS = ["", "♀ ", "♂ ", "⚧ "] 16 | 17 | 18 | def make_person_embed(person: Person, colour: discord.Colour) -> discord.Embed: 19 | emb = discord.Embed(colour=colour, title=person.name) 20 | emb.description = shorten(person.biography or "", 500, placeholder=" …") 21 | emb.url = f"https://www.themoviedb.org/person/{person.id}" 22 | emb.set_thumbnail(url=person.person_image) 23 | emb.add_field(name="Known For", value=person.known_for_department) 24 | if dob := person.birthday: 25 | emb.add_field(name="Birth Date", value=f"{format_date(dob, 'D')}\n({format_date(dob)})") 26 | if rip := person.deathday: 27 | emb.add_field( 28 | name="🙏 Passed away on", value=f"{format_date(rip, 'D')}\n({format_date(rip)})" 29 | ) 30 | if person.place_of_birth: 31 | emb.add_field(name="Place of Birth", value=person.place_of_birth) 32 | ext_links: List[str] = [] 33 | if person.imdb_id: 34 | ext_links.append(f"[IMDb](https://www.imdb.com/name/{person.imdb_id})") 35 | if person.homepage: 36 | ext_links.append(f"[Personal website]({person.homepage})\n") 37 | if ext_links: 38 | emb.add_field(name="External Links", value=", ".join(ext_links)) 39 | emb.set_footer(text="Data provided by TheMovieDB!", icon_url="https://i.imgur.com/sSE7Usn.png") 40 | return emb 41 | 42 | 43 | def make_movie_embed(data: MovieDetails, colour: discord.Colour) -> discord.Embed: 44 | embed = discord.Embed(title=data.title, colour=colour) 45 | description = data.overview 46 | if imdb_id := data.imdb_id: 47 | description += f"\n\n**[see IMDB page!](https://www.imdb.com/title/{imdb_id})**" 48 | embed.url = f"https://www.themoviedb.org/movie/{data.id}" 49 | embed.description = description 50 | embed.set_image(url=f"{CDN_BASE}{data.backdrop_path or '/'}") 51 | embed.set_thumbnail(url=f"{CDN_BASE}{data.poster_path or '/'}") 52 | if data.release_date: 53 | embed.add_field(name="Release Date", value=format_date(data.release_date)) 54 | if data.budget: 55 | embed.add_field(name="Budget (USD)", value=f"${natural_size(data.budget)}") 56 | if data.revenue: 57 | embed.add_field(name="Revenue (USD)", value=f"${natural_size(data.revenue)}") 58 | if data.humanize_runtime: 59 | embed.add_field(name="Runtime", value=data.humanize_runtime) 60 | if data.vote_average and data.vote_count: 61 | embed.add_field(name="TMDB Rating", value=data.humanize_votes) 62 | if data.spoken_languages: 63 | embed.add_field(name="Spoken languages", value=data.all_spoken_languages) 64 | if data.genres: 65 | embed.add_field(name="Genres", value=data.all_genres) 66 | if len(embed.fields) in {5, 8}: 67 | embed.add_field(name="\u200b", value="\u200b") 68 | embed.set_footer( 69 | text="Browse more info on this movie on next page!", 70 | icon_url="https://i.imgur.com/sSE7Usn.png" 71 | ) 72 | return embed 73 | 74 | 75 | def parse_credits( 76 | cast_data: Sequence[CelebrityCast], 77 | colour: discord.Colour, 78 | title: str, 79 | tmdb_id: str 80 | ) -> List[discord.Embed]: 81 | pretty_cast = "\n".join( 82 | f"**`[{i:>2}]`** {GENDERS[actor.gender]} [{actor.name}]" 83 | f"(https://www.themoviedb.org/person/{actor.id})" 84 | f" as **{actor.character or '???'}**" 85 | for i, actor in enumerate(cast_data, 1) 86 | ) 87 | 88 | pages = [] 89 | all_pages = list(pagify(pretty_cast, page_length=1500)) 90 | for i, page in enumerate(all_pages, start=1): 91 | emb = discord.Embed(colour=colour, description=page, title=title) 92 | emb.url = f"https://www.themoviedb.org/{tmdb_id}/cast" 93 | emb.set_footer( 94 | text=f"Celebrities Cast • Page {i} of {len(all_pages)}", 95 | icon_url="https://i.imgur.com/sSE7Usn.png", 96 | ) 97 | pages.append(emb) 98 | 99 | return pages 100 | 101 | 102 | def make_tvshow_embed(data: TVShowDetails, colour: discord.Colour) -> discord.Embed: 103 | embed = discord.Embed(title=data.name, colour=colour) 104 | summary = f"► Series status: **{data.status or 'Unknown'}** ({data.type})\n" 105 | if runtime := data.episode_run_time: 106 | summary += f"► Average episode runtime: **{runtime[0]} minutes**\n" 107 | if data.in_production: 108 | summary += f"► In production? ✅ Yes" 109 | embed.description=f"{data.overview or ''}\n\n{summary}" 110 | embed.url = f"https://www.themoviedb.org/tv/{data.id}" 111 | embed.set_image(url=f"{CDN_BASE}{data.backdrop_path or '/'}") 112 | embed.set_thumbnail(url=f"{CDN_BASE}{data.poster_path or '/'}") 113 | if data.created_by: 114 | embed.add_field(name="Creators", value=data.creators) 115 | if first_air_date := data.first_air_date: 116 | embed.add_field(name="First Air Date", value=format_date(first_air_date)) 117 | if last_air_date := data.last_air_date: 118 | embed.add_field(name="Last Air Date", value=format_date(last_air_date)) 119 | if data.number_of_seasons: 120 | embed.add_field(name="Total Seasons", value=data.seasons_count) 121 | if data.genres: 122 | embed.add_field(name="Genres", value=data.all_genres) 123 | if data.vote_average and data.vote_count: 124 | embed.add_field(name="TMDB Rating", value=data.humanize_votes) 125 | if data.networks: 126 | embed.add_field(name="Networks", value=data.all_networks) 127 | if data.spoken_languages: 128 | embed.add_field(name="Spoken Language(s)", value=data.all_spoken_languages) 129 | if len(embed.fields) in {5, 8}: 130 | embed.add_field(name="\u200b", value="\u200b") 131 | if data.seasons: 132 | for page in pagify(data.all_seasons, page_length=1000): 133 | embed.add_field(name="Seasons summary", value=page, inline=False) 134 | if data.next_episode_to_air: 135 | embed.add_field(name="Next Episode Info", value=data.next_episode_info, inline=False) 136 | embed.set_footer( 137 | text=f"Browse more info on this TV show on next page!", 138 | icon_url="https://i.imgur.com/sSE7Usn.png", 139 | ) 140 | return embed 141 | 142 | 143 | def make_suggestmovies_embed( 144 | data: MovieSuggestions, colour: discord.Colour, footer: str, 145 | ) -> discord.Embed: 146 | embed = discord.Embed(colour=colour, title=data.title, description=data.overview or "") 147 | embed.url = f"https://www.themoviedb.org/movie/{data.id}" 148 | embed.set_image(url=f"{CDN_BASE}{data.backdrop_path or '/'}") 149 | embed.set_thumbnail(url=f"{CDN_BASE}{data.poster_path or '/'}") 150 | if data.release_date: 151 | embed.add_field(name="Release Date", value=format_date(data.release_date)) 152 | if data.vote_average and data.vote_count: 153 | embed.add_field(name="TMDB Rating", value=data.humanize_votes) 154 | embed.set_footer(text=footer, icon_url="https://i.imgur.com/sSE7Usn.png") 155 | return embed 156 | 157 | 158 | def make_suggestshows_embed( 159 | data: TVShowSuggestions, colour: discord.Colour, footer: str, 160 | ) -> discord.Embed: 161 | embed = discord.Embed(title=data.name, description=data.overview or "", colour=colour) 162 | embed.url = f"https://www.themoviedb.org/tv/{data.id}" 163 | embed.set_image(url=f"{CDN_BASE}{data.backdrop_path or '/'}") 164 | embed.set_thumbnail(url=f"{CDN_BASE}{data.poster_path or '/'}") 165 | if data.first_air_date: 166 | embed.add_field(name="First Aired", value=format_date(data.first_air_date)) 167 | if data.vote_average and data.vote_count: 168 | embed.add_field(name="TMDB Rating", value=data.humanize_votes) 169 | embed.set_footer(text=footer, icon_url="https://i.imgur.com/sSE7Usn.png") 170 | return embed 171 | -------------------------------------------------------------------------------- /moviedb/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MovieDB", 3 | "short": "Fetch various info about a movie or TV series.", 4 | "description": "Fetch various info about a movie or TV series from The Movie DB API.", 5 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 6 | "install_msg": "Hi, thanks for installing this cog. Please note, this cog requires a free API key from themoviedb.org.\nFollow the TMDB docs for how to get your API key:\n\n\nOnce you have the key, you can then set it in your redbot instance with:\n```[p]set api tmdb api_key ```", 7 | "author": [ 8 | "ow0x" 9 | ], 10 | "required_cogs": {}, 11 | "requirements": [], 12 | "tags": ["movie", "tv series", "tv show", "themoviedb", "imdb"], 13 | "min_bot_version": "3.5.0", 14 | "hidden": false, 15 | "disabled": false, 16 | "type": "COG" 17 | } 18 | -------------------------------------------------------------------------------- /moviedb/moviedb.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, cast 2 | 3 | import aiohttp 4 | import discord 5 | from discord.app_commands import describe 6 | from redbot.core import commands 7 | from redbot.core.commands import Context 8 | from redbot.core.utils.menus import DEFAULT_CONTROLS, menu 9 | 10 | from .api.base import CDN_BASE, MediaNotFound 11 | from .api.details import MovieDetails, TVShowDetails 12 | from .api.person import Person 13 | from .api.suggestions import MovieSuggestions, TVShowSuggestions 14 | from .converter import MovieFinder, PersonFinder, TVShowFinder 15 | from .embed_utils import ( 16 | make_movie_embed, 17 | make_person_embed, 18 | make_suggestmovies_embed, 19 | make_suggestshows_embed, 20 | make_tvshow_embed, 21 | parse_credits, 22 | ) 23 | 24 | 25 | class MovieDB(commands.Cog): 26 | """Get summarized info about a movie or TV show/series.""" 27 | 28 | __authors__ = "ow0x" 29 | __version__ = "4.2.1" 30 | 31 | def format_help_for_context(self, ctx: Context) -> str: # Thanks Sinbad! 32 | return ( 33 | f"{super().format_help_for_context(ctx)}\n\n" 34 | f"**Author(s):** {self.__authors__}\n" 35 | f"**Cog version:** {self.__version__}" 36 | ) 37 | 38 | def __init__(self, *args: Any, **kwargs: Any) -> None: 39 | self.session = aiohttp.ClientSession() 40 | 41 | async def cog_unload(self) -> None: 42 | await self.session.close() 43 | 44 | async def red_delete_data_for_user(self, **kwargs) -> None: 45 | """Nothing to delete""" 46 | pass 47 | 48 | async def cog_check(self, ctx: Context) -> bool: 49 | if not ctx.guild: 50 | return True 51 | 52 | my_perms = ctx.channel.permissions_for(ctx.guild.me) 53 | return my_perms.embed_links and my_perms.read_message_history 54 | 55 | @commands.bot_has_permissions(embed_links=True) 56 | @commands.hybrid_command(aliases=["actor", "director"]) 57 | @describe(name="Type name of celebrity! i.e. actor, director, producer etc.") 58 | async def celebrity(self, ctx: Context, *, name: PersonFinder): 59 | """Get various info about a movie/tvshow celebrity or crew!""" 60 | await ctx.typing() 61 | if not name or isinstance(name, MediaNotFound): 62 | return await ctx.send(str(name)) 63 | 64 | data = cast(Person, name) 65 | embeds = [] 66 | emb1 = make_person_embed(data, await ctx.embed_colour()) 67 | MEDIA_TYPE = {"tv": "TV", "movie": "Movie"} 68 | if acting := data.combined_credits.cast: 69 | emb2 = discord.Embed(colour=emb1.colour) 70 | emb2.set_author( 71 | name=f"{data.name}'s Acting Roles", icon_url=data.person_image, url=emb1.url 72 | ) 73 | emb2.description = "\n".join( 74 | f"`{cast.year}` • **{cast.title or cast.name}**" 75 | f" ({MEDIA_TYPE[cast.media_type]}) {cast.portray_as}" 76 | for i, cast in enumerate(data.combined_credits.cast[:20], 1) 77 | ) 78 | if len(data.combined_credits.cast) > 20: 79 | emb2.set_footer( 80 | text=f"and {len(acting) - 20} more! | Sorted from recent to oldest!", 81 | icon_url="https://i.imgur.com/sSE7Usn.png" 82 | ) 83 | embeds.append(emb2) 84 | if crew := data.combined_credits.crew: 85 | emb3 = discord.Embed(colour=emb1.colour) 86 | emb3.set_author( 87 | name=f"{data.name}'s Production Roles", icon_url=data.person_image, url=emb1.url 88 | ) 89 | emb3.description = "\n".join( 90 | f"`{crew.year or '????'}` • **{crew.title or crew.name}**" 91 | f" ({MEDIA_TYPE[crew.media_type]}) as *{crew.job}*" 92 | for i, crew in enumerate(data.combined_credits.crew[:20], 1) 93 | ) 94 | if len(data.combined_credits.crew) > 20: 95 | emb3.set_footer( 96 | text=f"and {len(crew) - 20} more! | Sorted from recent to oldest!", 97 | icon_url="https://i.imgur.com/sSE7Usn.png" 98 | ) 99 | embeds.append(emb3) 100 | embeds.insert(0, emb1) 101 | await menu(ctx, embeds, DEFAULT_CONTROLS, timeout=120) 102 | 103 | @commands.bot_has_permissions(embed_links=True) 104 | @commands.hybrid_command() 105 | @describe(movie="Provide name of the movie. Try to be specific for accurate results!") 106 | async def movie(self, ctx: Context, *, movie: MovieFinder): 107 | """Show various info about a movie.""" 108 | await ctx.typing() 109 | # api_key = (await ctx.bot.get_shared_api_tokens("tmdb")).get("api_key", "") 110 | # data = await MovieDetails.request(ctx.bot.session, api_key, query) 111 | if not movie or isinstance(movie, MediaNotFound): 112 | return await ctx.send(str(movie)) 113 | 114 | data = cast(MovieDetails, movie) 115 | emb1 = make_movie_embed(data, await ctx.embed_colour()) 116 | emb2 = discord.Embed(colour=await ctx.embed_colour(), title=data.title) 117 | emb2.url = f"https://www.themoviedb.org/movie/{data.id}" 118 | emb2.set_image(url=f"{CDN_BASE}{data.backdrop_path or '/'}") 119 | if data.production_companies: 120 | emb2.add_field(name="Production Companies", value=data.all_production_companies) 121 | if data.production_countries: 122 | emb2.add_field( 123 | name="Production Countries", 124 | value=data.all_production_countries, 125 | inline=False, 126 | ) 127 | if data.tagline: 128 | emb2.add_field(name="Tagline", value=data.tagline, inline=False) 129 | 130 | celebrities = [] 131 | if data.credits: 132 | emb2.set_footer( 133 | text="See next page to see the celebrity cast!", 134 | icon_url="https://i.imgur.com/sSE7Usn.png", 135 | ) 136 | celebrities = parse_credits( 137 | data.credits, 138 | colour=await ctx.embed_colour(), 139 | tmdb_id=f"movie/{data.id}", 140 | title=data.title, 141 | ) 142 | 143 | await menu(ctx, [emb1, emb2] + celebrities, DEFAULT_CONTROLS, timeout=120) 144 | 145 | @commands.bot_has_permissions(embed_links=True) 146 | @commands.hybrid_command(aliases=["tv", "tvseries"], fallback='search') 147 | @describe(tv_show="Provide name of TV show. Try to be specific for accurate results!") 148 | async def tvshow(self, ctx: Context, *, tv_show: TVShowFinder): 149 | """Show various info about a TV show/series.""" 150 | await ctx.typing() 151 | # api_key = (await ctx.bot.get_shared_api_tokens("tmdb")).get("api_key", "") 152 | # data = await TVShowDetails.request(ctx.bot.session, api_key, query) 153 | if not tv_show or isinstance(tv_show, MediaNotFound): 154 | return await ctx.send(str(tv_show)) 155 | 156 | data = cast(TVShowDetails, tv_show) 157 | emb1 = make_tvshow_embed(data, await ctx.embed_colour()) 158 | emb2 = discord.Embed(colour=await ctx.embed_colour(), title=data.name) 159 | emb2.url = f"https://www.themoviedb.org/tv/{data.id}" 160 | emb2.set_image(url=f"{CDN_BASE}{data.backdrop_path or '/'}") 161 | if production_countries := data.production_countries: 162 | emb2.add_field( 163 | name="Production Countries", 164 | value=", ".join([m.name for m in production_countries]), 165 | ) 166 | if production_companies := data.production_companies: 167 | emb2.add_field( 168 | name="Production Companies", 169 | value=", ".join([m.name for m in production_companies]), 170 | inline=False, 171 | ) 172 | if data.tagline: 173 | emb2.add_field(name="Tagline", value=data.tagline, inline=False) 174 | 175 | celebrities = [] 176 | if data.credits: 177 | emb2.set_footer( 178 | text="See next page to see this series' celebrity cast!", 179 | icon_url="https://i.imgur.com/sSE7Usn.png", 180 | ) 181 | celebrities = parse_credits( 182 | data.credits, 183 | colour=await ctx.embed_colour(), 184 | tmdb_id=f"tv/{data.id}", 185 | title=data.name, 186 | ) 187 | 188 | await menu(ctx, [emb1, emb2] + celebrities, DEFAULT_CONTROLS, timeout=120) 189 | 190 | @commands.bot_has_permissions(embed_links=True) 191 | @commands.hybrid_command(aliases=['suggestmovie']) 192 | @describe(movie="Provide name of the movie. Try to be specific in your query!") 193 | async def suggestmovies(self, ctx: Context, *, movie: MovieFinder): 194 | """Get similar movies suggestions based on the given movie name.""" 195 | await ctx.typing() 196 | # api_key = (await ctx.bot.get_shared_api_tokens("tmdb")).get("api_key", "") 197 | # output = await MovieSuggestions.request(ctx.bot.session, api_key, query) 198 | if not movie or isinstance(movie, MediaNotFound): 199 | return await ctx.send(str(movie)) 200 | 201 | pages = [] 202 | output = cast(List[MovieSuggestions], movie) 203 | for i, data in enumerate(output, start=1): 204 | colour = await ctx.embed_colour() 205 | footer = f"Page {i} of {len(output)}" 206 | pages.append(make_suggestmovies_embed(data, colour, footer)) 207 | 208 | await menu(ctx, pages, DEFAULT_CONTROLS, timeout=120) 209 | 210 | @commands.bot_has_permissions(embed_links=True) 211 | @commands.hybrid_command(aliases=['suggestshow']) 212 | @describe(tv_show="Provide name of TV show. Try to be specific for accurate results!") 213 | async def suggestshows(self, ctx: Context, *, tv_show: TVShowFinder): 214 | """Get similar TV show suggestions from the given TV series name.""" 215 | await ctx.typing() 216 | # api_key = (await ctx.bot.get_shared_api_tokens("tmdb")).get("api_key", "") 217 | # output = await TVShowSuggestions.request(ctx.bot.session, api_key, query) 218 | if not tv_show or isinstance(tv_show, MediaNotFound): 219 | return await ctx.send(str(tv_show)) 220 | 221 | pages = [] 222 | output = cast(List[TVShowSuggestions], tv_show) 223 | for i, data in enumerate(output, start=1): 224 | colour = await ctx.embed_colour() 225 | footer = f"Page {i} of {len(output)}" 226 | pages.append(make_suggestshows_embed(data, colour, footer)) 227 | 228 | await menu(ctx, pages, DEFAULT_CONTROLS, timeout=120) 229 | -------------------------------------------------------------------------------- /moviedb/utils.py: -------------------------------------------------------------------------------- 1 | import math 2 | from datetime import datetime 3 | 4 | 5 | def format_date(date_string: str, style: str = "R", *, prefix: str = "") -> str: 6 | if not date_string: 7 | return "" 8 | 9 | try: 10 | date_obj = datetime.strptime(date_string, "%Y-%m-%d") 11 | # Future proof it in case API changes date string 12 | except ValueError: 13 | return "" 14 | 15 | return f"{prefix}" 16 | 17 | 18 | # credits to devon (Gorialis) 19 | def natural_size(value: int) -> str: 20 | if value < 1000: 21 | return str(value) 22 | 23 | units = ('', 'K', 'M', 'B') 24 | power = int(math.log(max(abs(value), 1), 1000)) 25 | return f"{value / (1000 ** power):.1f}{units[power]}" -------------------------------------------------------------------------------- /ocr/LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 TrustyJAID 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ocr/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.errors import CogLoadError 2 | 3 | from .ocr import OCR 4 | 5 | __red_end_user_data_statement__ = "This cog does not persistently store any data or metadata about users." 6 | 7 | 8 | async def setup(bot): 9 | if not getattr(bot, "session", None): 10 | raise CogLoadError("This cog requires bot.session attr to be set.") 11 | await bot.add_cog(OCR(bot)) 12 | -------------------------------------------------------------------------------- /ocr/converter.py: -------------------------------------------------------------------------------- 1 | # originally made by TrustyJAID for his NotSoBot cog 2 | # https://github.com/TrustyJAID/Trusty-cogs/blob/master/notsobot/converter.py 3 | import re 4 | 5 | import discord 6 | from redbot.core.commands import BadArgument, Context, Converter 7 | 8 | IMAGE_LINKS: re.Pattern[str] = re.compile( 9 | r"(https?:\/\/[^\"\'\s]*\.(?:png|jpg|jpeg|webp)(\?size=[0-9]{1,4})?)", flags=re.I 10 | ) 11 | 12 | DISCORD_CDN: tuple[str, str] = ( 13 | "https://cdn.discordapp.com/attachments", 14 | "https://media.discordapp.net/attachments", 15 | ) 16 | 17 | 18 | class ImageFinder(Converter): 19 | """ 20 | This is a class to convert NotSoBot's image searching 21 | capabilities into a more general converter class 22 | """ 23 | 24 | async def convert(self, ctx: Context, argument: str) -> list[str]: 25 | urls: list[str] = [] 26 | if argument.startswith(DISCORD_CDN): 27 | urls.append(argument.split()[0]) 28 | if matches := IMAGE_LINKS.finditer(argument): 29 | urls.extend(match.group(1) for match in matches) 30 | if attachments := ctx.message.attachments: 31 | urls.extend(img.url for img in attachments if img.content_type and img.content_type.startswith("image")) 32 | if (e := ctx.message.embeds) and e[0].image: 33 | urls.append(e[0].image.url) 34 | if not urls: 35 | if ctx.message.reference and (message := ctx.message.reference.resolved): 36 | urls = await find_images_in_replies(message) 37 | else: 38 | urls = await search_for_images(ctx) 39 | if not urls: 40 | raise BadArgument("No images or image links found in chat bro 🥸") 41 | return urls 42 | 43 | 44 | async def find_images_in_replies(reference: discord.DeletedReferencedMessage | discord.Message | None) -> list[str]: 45 | if not reference or not isinstance(reference, discord.Message): 46 | return [] 47 | urls = [] 48 | argument = reference.system_content 49 | if argument.startswith(DISCORD_CDN): 50 | urls.append(argument.split()[0]) 51 | if match := IMAGE_LINKS.search(argument): 52 | urls.append(match.group(1)) 53 | if reference.attachments: 54 | urls.extend( 55 | img.url for img in reference.attachments if img.content_type and img.content_type.startswith("image") 56 | ) 57 | if reference.embeds and reference.embeds[0].image: 58 | urls.append(reference.embeds[0].image.url) 59 | return urls 60 | 61 | 62 | async def search_for_images(ctx: Context) -> list[str]: 63 | urls = [] 64 | async for message in ctx.channel.history(limit=20): 65 | if message.embeds and message.embeds[0].image: 66 | urls.append(message.embeds[0].image.url) 67 | if message.attachments: 68 | urls.extend( 69 | img.url for img in message.attachments if img.content_type and img.content_type.startswith("image") 70 | ) 71 | if message.system_content.startswith(DISCORD_CDN): 72 | urls.append(message.system_content.split()[0]) 73 | if match := IMAGE_LINKS.search(message.system_content): 74 | urls.append(match.group(1)) 75 | return urls 76 | -------------------------------------------------------------------------------- /ocr/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OCR", 3 | "author": ["owocado", "TrustyJAID"], 4 | "short": "Detect text in images through OCR.", 5 | "description": "Detect text in images through OCR.", 6 | "install_msg": "**NOTE FOR BOT OWNER:**\n\nThis cog uses free ocr.space API which may give subpar results.\n\n**`[OPTIONAL]`**\nThere is optional support for Google Cloud Vision OCR API for improved text detection.\n(requires you to have a Google cloud project with active enabled billing account).\nYou may read more on that over at:\n", 7 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users.", 8 | "tags": ["ocr", "image to text"], 9 | "min_bot_version": "3.6.0", 10 | "hidden": false, 11 | "disabled": false, 12 | "type": "COG" 13 | } 14 | -------------------------------------------------------------------------------- /ocr/iso639.py: -------------------------------------------------------------------------------- 1 | from typing import Mapping 2 | 3 | 4 | ISO639_MAP: Mapping[str, str] = { 5 | "af": "Afrikaans", 6 | "ak": "Twi (Akan)", 7 | "am": "Amharic", 8 | "ar": "Arabic", 9 | "as": "Assamese", 10 | "ay": "Aymara", 11 | "az": "Azerbaijani", 12 | "be": "Belarusian", 13 | "bg": "Bulgarian", 14 | "bho": "Bhojpuri", 15 | "bm": "Bambara", 16 | "bn": "Bengali", 17 | "bs": "Bosnian", 18 | "ca": "Catalan", 19 | "ceb": "Cebuano", 20 | "ckb": "Kurdish (Sorani)", 21 | "co": "Corsican", 22 | "cs": "Czech", 23 | "cy": "Welsh", 24 | "da": "Danish", 25 | "de": "German", 26 | "doi": "Dogri", 27 | "dv": "Divehi", 28 | "ee": "Ewe", 29 | "el": "Greek", 30 | "en": "English", 31 | "eo": "Esperanto", 32 | "es": "Spanish", 33 | "et": "Estonian", 34 | "eu": "Basque", 35 | "fa": "Persian", 36 | "fi": "Finnish", 37 | "fo": "Faroese", 38 | "fr": "French", 39 | "fy": "Frisian", 40 | "ga": "Irish Gaelic", 41 | "gd": "Scottish Gaelic", 42 | "gl": "Galician", 43 | "gn": "Guarani", 44 | "gom": "Konkani", 45 | "gu": "Gujarati", 46 | "ha": "Hausa", 47 | "haw": "Hawaiian", 48 | "he": "Hebrew", 49 | "hi": "Hindi", 50 | "hmn": "Hmong", 51 | "hr": "Croatian", 52 | "ht": "Haitian Creole", 53 | "hu": "Hungarian", 54 | "hy": "Armenian", 55 | "id": "Indonesian", 56 | "ig": "Igbo", 57 | "ilo": "Ilocano", 58 | "is": "Icelandic", 59 | "it": "Italian", 60 | "iw": "Hebrew", 61 | "ja": "Japanese", 62 | "jv": "Javanese", 63 | "jw": "Javanese", 64 | "ka": "Georgian", 65 | "kk": "Kazakh", 66 | "kl": "Kalaallisut", 67 | "km": "Khmer", 68 | "kn": "Kannada", 69 | "ko": "Korean", 70 | "kri": "Krio", 71 | "ku": "Kurdish (Kurmanji)", 72 | "ky": "Kyrgyz", 73 | "la": "Latin", 74 | "lb": "Luxembourgish", 75 | "lg": "Luganda", 76 | "ln": "Lingala", 77 | "lo": "Lao", 78 | "lt": "Lithuanian", 79 | "lus": "Mizo", 80 | "lv": "Latvian", 81 | "mai": "Maithili", 82 | "mg": "Malagasy", 83 | "mi": "Māori", 84 | "mk": "Macedonian", 85 | "ml": "Malayalam", 86 | "mn": "Mongolian", 87 | "mni-Mtei": "Meitei (Manipuri)", 88 | "mr": "Marathi", 89 | "ms": "Malay", 90 | "mt": "Maltese", 91 | "my": "Burmese", 92 | "ne": "Nepali", 93 | "nl": "Dutch", 94 | "no": "Norwegian", 95 | "nso": "Sepedi (Northern Sotho)", 96 | "ny": "Chichewa", 97 | "om": "Oromo", 98 | "or": "Odia (Oriya)", 99 | "pa": "Punjabi", 100 | "pl": "Polish", 101 | "ps": "Pashto", 102 | "pt": "Portuguese", 103 | "qu": "Quechua", 104 | "ro": "Romanian", 105 | "ru": "Russian", 106 | "rw": "Kinyarwanda", 107 | "sa": "Sanskrit", 108 | "sd": "Sindhi", 109 | "si": "Sinhalese", 110 | "sk": "Slovak", 111 | "sl": "Slovenian", 112 | "sm": "Samoan", 113 | "sn": "Shona", 114 | "so": "Somali", 115 | "sq": "Albanian", 116 | "sr": "Serbian", 117 | "st": "Sesotho", 118 | "su": "Sundanese", 119 | "sv": "Swedish", 120 | "sw": "Swahili", 121 | "ta": "Tamil", 122 | "te": "Telugu", 123 | "tg": "Tajik", 124 | "th": "Thai", 125 | "ti": "Tigrinya", 126 | "tk": "Turkmen", 127 | "tl": "Filipino", 128 | "tr": "Turkish", 129 | "ts": "Tsonga", 130 | "tt": "Tatar", 131 | "ug": "Uyghur", 132 | "uk": "Ukrainian", 133 | "ur": "Urdu", 134 | "uz": "Uzbek", 135 | "vi": "Vietnamese", 136 | "xh": "Xhosa", 137 | "yi": "Yiddish", 138 | "yo": "Yoruba", 139 | "zu": "Zulu", 140 | "zh": "Chinese Simplified", 141 | "zh-cn": "Chinese Simplified", 142 | "zh-tw": "Chinese Traditional", 143 | "zh-CN": "Chinese Simplified", 144 | "zh-TW": "Chinese Traditional", 145 | "auto": "Autodetect", 146 | } 147 | 148 | -------------------------------------------------------------------------------- /ocr/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List 3 | 4 | 5 | @dataclass(slots=True) 6 | class TextAnnotation: 7 | locale: str = "en" 8 | description: str = "" 9 | 10 | 11 | @dataclass(slots=True) 12 | class DetectedLanguage: 13 | languageCode: str 14 | confidence: float 15 | 16 | 17 | @dataclass(slots=True) 18 | class Property: 19 | detectedLanguages: List[DetectedLanguage] 20 | 21 | 22 | @dataclass(slots=True) 23 | class Page: 24 | width: int 25 | height: int 26 | confidence: float 27 | property: Property | None = None 28 | 29 | 30 | @dataclass(slots=True) 31 | class FullTextAnnotation: 32 | pages: List[Page] = field(default_factory=list) 33 | text: str = "" 34 | 35 | @property 36 | def language_code(self) -> str: 37 | if not self.pages: 38 | return "auto" 39 | if not self.pages[0].property: 40 | return "auto" 41 | if not self.pages[0].property.detectedLanguages: 42 | return "auto" 43 | return self.pages[0].property.detectedLanguages[0].languageCode 44 | 45 | 46 | @dataclass(slots=True) 47 | class VisionError: 48 | code: int 49 | message: str 50 | status: str | None 51 | 52 | def __str__(self) -> str: 53 | return f"Error code: {self.code} ({self.message})" 54 | 55 | 56 | @dataclass(slots=True) 57 | class VisionPayload: 58 | fullTextAnnotation: FullTextAnnotation | None 59 | error: VisionError | None 60 | textAnnotations: List[TextAnnotation] = field(default_factory=list) 61 | 62 | @property 63 | def text_value(self) -> str | None: 64 | if not self.fullTextAnnotation: 65 | if self.error: 66 | return self.error.message 67 | return None 68 | return self.fullTextAnnotation.text or self.textAnnotations[0].description 69 | -------------------------------------------------------------------------------- /ocr/ocr.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, Annotated, Any, List 5 | 6 | import discord 7 | from redbot.core import commands 8 | from redbot.core.utils.chat_formatting import box, pagify, pprint, text_to_file 9 | 10 | from .converter import ImageFinder, find_images_in_replies, search_for_images 11 | from .iso639 import ISO639_MAP 12 | from .utils import vision_ocr as do_vision_ocr 13 | 14 | try: 15 | from translate.models import DetectedLanguage 16 | HAS_TRANSLATE_COG = True 17 | except (ImportError, ModuleNotFoundError): 18 | HAS_TRANSLATE_COG = False 19 | 20 | if TYPE_CHECKING: 21 | from redbot.core.commands import Context 22 | from redbot.core.bot import Red 23 | 24 | logger = logging.getLogger("ocr.ocr") 25 | 26 | 27 | class OCR(commands.Cog): 28 | """Detect text in images using ocr.space or Google Cloud Vision API.""" 29 | 30 | __authors__ = ["<@306810730055729152>", "TrustyJAID"] 31 | __version__ = "2.4.0" 32 | 33 | def format_help_for_context(self, ctx: Context) -> str: 34 | """Thanks Sinbad.""" 35 | return ( 36 | f"{super().format_help_for_context(ctx)}\n\n" 37 | f"**Authors:** {', '.join(self.__authors__)}\n" 38 | f"**Cog version:** v{self.__version__}" 39 | ) 40 | 41 | def __init__(self, bot: Red) -> None: 42 | self.bot = bot 43 | self.ocr_ctx = discord.app_commands.ContextMenu( 44 | name="Run OCR", 45 | callback=self.ocr_ctx_menu, 46 | ) 47 | if bot.get_cog("Translate"): 48 | self.ocr_translate_ctx = discord.app_commands.ContextMenu( 49 | name="OCR + Translate", 50 | callback=self.ocr_translate_ctx_menu, 51 | ) 52 | 53 | async def cog_load(self) -> None: 54 | self.bot.tree.add_command(self.ocr_ctx) 55 | # self.bot.tree.add_command(self.ocr_translate_ctx) 56 | return 57 | 58 | async def cog_unload(self) -> None: 59 | self.bot.tree.remove_command(self.ocr_ctx.name, type=self.ocr_ctx.type) 60 | # self.bot.tree.remove_command(self.ocr_translate_ctx.name, type=self.ocr_translate_ctx.type) 61 | return 62 | 63 | async def red_delete_data_for_user(self, **kwargs: Any) -> None: 64 | """Nothing to delete""" 65 | pass 66 | 67 | @staticmethod 68 | async def _pre_processing(inter: discord.Interaction[Red], message: discord.Message) -> str | None: 69 | logger.debug( 70 | "%s (%s) used OCR ctx menu in %r in guild: %r (%s)", 71 | inter.user.name, 72 | inter.user.id, 73 | inter.channel, 74 | inter.guild, 75 | message.jump_url, 76 | ) 77 | images = await find_images_in_replies(message) 78 | if not images: 79 | await inter.followup.send( 80 | "No images or image links were found in that message!", 81 | ephemeral=True, 82 | ) 83 | return discord.utils.MISSING 84 | logger.debug("\n".join(images)) 85 | ctx = await commands.Context.from_interaction(inter) 86 | r = await do_vision_ocr(ctx, detect_handwriting=True, image=images[0]) 87 | if not r: 88 | await inter.followup.send("OCR call failed guh :cry:", ephemeral=True) 89 | return discord.utils.MISSING 90 | return r.text_value 91 | 92 | async def ocr_ctx_menu(self, i: discord.Interaction[Red], message: discord.Message) -> None: 93 | if not i.client.get_cog("Translate"): 94 | return 95 | hidden = True if message.guild else not i.app_permissions.send_messages 96 | if message.author.system and self.bot.user.id == 830676830419157002: 97 | hidden = False 98 | await i.response.send_message( 99 | file=text_to_file(pprint(message._data, sort_keys=False), 'message.json'), 100 | ephemeral=False, 101 | ) 102 | if not i.response.is_done(): 103 | await i.response.defer(ephemeral=hidden) 104 | text_value = await self._pre_processing(i, message) 105 | if text_value is discord.utils.MISSING: 106 | return 107 | if not text_value: 108 | await i.followup.send("No text content extracted from that image", ephemeral=hidden) 109 | return 110 | if len(text_value) > 1984: 111 | await i.followup.send( 112 | f"{i.user.mention} text output too long so attached as file:", 113 | file=text_to_file(text_value), 114 | ephemeral=hidden, 115 | allowed_mentions=discord.AllowedMentions.none(), 116 | ) 117 | else: 118 | await i.followup.send(box(text_value, "py"), ephemeral=hidden) 119 | return 120 | 121 | async def ocr_translate_ctx_menu(self, i: discord.Interaction[Red], message: discord.Message) -> None: 122 | hidden = True if message.guild else not i.app_permissions.send_messages 123 | await i.response.defer(ephemeral=hidden) 124 | text_value = await self._pre_processing(i, message) 125 | if text_value is discord.utils.MISSING: 126 | return 127 | if not text_value: 128 | await i.followup.send("No text content extracted from that image", ephemeral=True) 129 | return 130 | cog = i.client.get_cog("Translate") 131 | if not cog: 132 | await i.followup.send("Translate module not found wtf", ephemeral=True) 133 | return 134 | 135 | detected_lang = DetectedLanguage(language="auto", confidence=0) 136 | try: 137 | detected_lang = await cog._tr.detect_language(text_value, guild=i.guild) 138 | except Exception: 139 | from_lang = "auto" 140 | else: 141 | from_lang = detected_lang.language 142 | translated_text = await self.run_translate(cog, i, from_lang, "en", text_value) 143 | if not translated_text: 144 | if len(text_value) > 1984: 145 | await i.followup.send(file=text_to_file(text_value), ephemeral=True) 146 | else: 147 | await i.followup.send(box(text_value, "py"), ephemeral=True) 148 | return 149 | user = message.author 150 | _, embed = translated_text.embed(user, from_lang, "en", user, detected_lang.confidence) 151 | await i.followup.send(embed=embed, ephemeral=True) 152 | return 153 | 154 | @staticmethod 155 | async def run_translate( 156 | cog, ctx: Context[Red] | discord.Interaction[Red], from_lang: str, to_language: str, text: str 157 | ): 158 | send = ctx.followup.send if isinstance(ctx, discord.Interaction) else ctx.send 159 | if str(to_language) == from_lang: 160 | ln_from = ISO639_MAP.get(from_lang) or from_lang.upper() 161 | ln_to = ISO639_MAP.get(to_language) or to_language.upper() 162 | await send(f"⚠️ I cannot translate `{ln_from}` to `{ln_to}`! Same language!?") 163 | return None 164 | try: 165 | translated_text = await cog._tr.translate_text(to_language, text, from_lang, guild=ctx.guild) 166 | except Exception as exc: 167 | await send(str(exc)) 168 | return None 169 | if translated_text is None: 170 | await send("Google said there is nothing to be translated /shrug") 171 | return None 172 | else: 173 | return translated_text 174 | 175 | @commands.cooldown(1, 5, commands.BucketType.user) 176 | @commands.bot_has_permissions(read_message_history=True) 177 | @commands.command() 178 | async def ocr( 179 | self, 180 | ctx: Context[Red], 181 | image: Annotated[List[str], ImageFinder] = None, 182 | ) -> None: 183 | """Detect text in an image through Google OCR API. 184 | 185 | Use it on old messages with attachments/image links by replying to said message with `[p]ocr` 186 | 187 | Pass `detect_handwriting` as True or `1` with command to more accurately detect handwriting from target image. 188 | 189 | **Example:** 190 | - `[p]ocr image/attachment/URL` 191 | - # To better detect handwriting in target image do: 192 | - `[p]ocr 1 image/attachment/URL` 193 | """ 194 | await ctx.typing() 195 | if not image: 196 | attached = ctx.message.attachments 197 | mime_type = (attached[0].content_type if attached else None) or "" 198 | if attached and len(attached) == 1 and mime_type.startswith("image"): 199 | img = await attached[0].read(use_cached=True) 200 | r = await do_vision_ocr(ctx, image=img) 201 | if not r: 202 | return 203 | await ctx.send_interactive(pagify(r.text_value or ""), box_lang="", timeout=120) 204 | return 205 | elif ctx.message.reference and (message := ctx.message.reference.resolved): 206 | image = await find_images_in_replies(message) 207 | else: 208 | image = await search_for_images(ctx) 209 | if not image: 210 | await ctx.send("No images or direct image links were detected. 😢") 211 | return 212 | resp = await do_vision_ocr(ctx, image=image[0]) 213 | if not resp: 214 | return 215 | await ctx.send_interactive(pagify(resp.text_value or ""), box_lang="", timeout=120) 216 | return 217 | 218 | @commands.cooldown(1, 5, commands.BucketType.user) 219 | @commands.bot_has_permissions(read_message_history=True) 220 | @commands.command() 221 | async def ocrtr( 222 | self, 223 | ctx: Context[Red], 224 | image: Annotated[List[str], ImageFinder] = None, 225 | ) -> None: 226 | """Do OCR & translate on an image.""" 227 | if not image: 228 | if ctx.message.reference and (message := ctx.message.reference.resolved): 229 | image = await find_images_in_replies(message) 230 | else: 231 | image = await search_for_images(ctx) 232 | if not image: 233 | await ctx.send("No images or direct image links were detected. 😢") 234 | return 235 | await ctx.typing() 236 | resp = await do_vision_ocr(ctx, image=image[0]) 237 | if not resp: 238 | return 239 | 240 | text = resp.text_value or "" 241 | cog = ctx.bot.get_cog("Translate") 242 | if not cog: 243 | await ctx.send("Translate module not found nooooooo") 244 | return 245 | if TYPE_CHECKING: 246 | from translate.translate import Translate 247 | assert isinstance(cog, Translate) 248 | 249 | detected_lang = DetectedLanguage(language="auto", confidence=0) 250 | try: 251 | detected_lang = await cog._tr.detect_language(text, guild=ctx.guild) 252 | except Exception: 253 | # await ctx.send(str(exc)) 254 | from_lang = ft.language_code if (ft := resp.fullTextAnnotation) else "auto" 255 | else: 256 | from_lang = detected_lang.language 257 | translated_text = await self.run_translate(cog, ctx, from_lang, "en", text) 258 | if not translated_text: 259 | await ctx.send_interactive(pagify(text), box_lang="", timeout=120) 260 | return 261 | _, embed = translated_text.embed(ctx.author, from_lang, "en", ctx.author, detected_lang.confidence) 262 | ref = ctx.message.to_reference(fail_if_not_exists=False) 263 | await ctx.send(embed=embed, reference=ref, mention_author=False) 264 | return 265 | 266 | -------------------------------------------------------------------------------- /ocr/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | from typing import Any 4 | 5 | import dacite 6 | from redbot.core.commands import Context 7 | from redbot.core.utils.chat_formatting import box 8 | 9 | from .models import VisionPayload 10 | 11 | log = logging.getLogger("ocr.utils") 12 | 13 | 14 | async def _get_bytes(session, url: str): 15 | if "imgur.com" in url: 16 | url = f"https://proxy.duckduckgo.com/iu/?u={url}" 17 | try: 18 | async with session.get(url) as r: 19 | buf = await r.read() 20 | except Exception: 21 | return None 22 | else: 23 | return base64.b64encode(buf).decode("utf-8") 24 | 25 | 26 | async def free_ocr(session, image_url: str) -> str: 27 | sussy_string = "7d3306461d88957" 28 | file_type = image_url.split(".").pop().upper() 29 | data = { 30 | "url": image_url, 31 | "apikey": sussy_string, 32 | "language": "eng", 33 | "isOverlayRequired": False, 34 | "filetype": file_type, 35 | } 36 | 37 | async with session.post("https://api.ocr.space/parse/image", data=data) as resp: 38 | if resp.status != 200: 39 | return f"https://http.cat/{resp.status}.jpg" 40 | result = await resp.json() 41 | 42 | temp_ = result.get("textAnnotations", [{}]) 43 | if (err := temp_[0].get("error")) and (err_message := err.get("message")): 44 | return f"API returned error: {err_message}" 45 | 46 | if temp_[0].get("description"): 47 | return temp_[0]["description"] 48 | 49 | if not result.get("ParsedResults"): 50 | return box(str(result.get("ErrorMessage")), "json") 51 | 52 | return result["ParsedResults"][0].get("ParsedText") 53 | 54 | 55 | async def vision_ocr(ctx: Context, *, image: str | bytes, detect_handwriting: bool = True) -> VisionPayload | None: 56 | api_key = (await ctx.bot.get_shared_api_tokens("google_vision")).get("api_key") 57 | if not api_key: 58 | # out = await free_ocr(ctx.bot.session, image[0]) 59 | # await ctx.send_interactive(pagify(out)) 60 | return None 61 | 62 | base_url = f"https://vision.googleapis.com/v1/images:annotate?key={api_key}" 63 | headers = {"Content-Type": "application/json;charset=utf-8"} 64 | detect_type = "DOCUMENT_TEXT_DETECTION" if detect_handwriting else "TEXT_DETECTION" 65 | payload = { 66 | "requests": [ 67 | { 68 | "features": [{"model": "builtin/weekly", "type": detect_type}], 69 | "image": {}, 70 | "imageContext": {"textDetectionParams": {"enableTextDetectionConfidenceScore": True}}, 71 | } 72 | ] 73 | } 74 | if isinstance(image, bytes): 75 | payload["requests"][0]["image"]["content"] = base64.b64encode(image).decode() 76 | elif buf := await _get_bytes(ctx.bot.session, url=image): 77 | payload["requests"][0]["image"]["content"] = buf 78 | else: 79 | payload["requests"][0]["image"]["source"]["imageUri"] = image 80 | 81 | try: 82 | async with ctx.bot.session.post(base_url, json=payload, headers=headers) as resp: 83 | if resp.status != 200: 84 | try: 85 | data: dict = await resp.json() 86 | except Exception as error: 87 | if not ctx.interaction: 88 | await ctx.send(f"{error} https://http.cat/{resp.status}") 89 | return None 90 | else: 91 | p = dacite.from_dict(data_class=VisionPayload, data=data) 92 | if not ctx.interaction: 93 | await ctx.send(str(p.error)) 94 | return None 95 | data: dict = await resp.json() 96 | except Exception as exc: 97 | if not ctx.interaction: 98 | await ctx.send(f"Operation timed out: {exc}", ephemeral=True) 99 | return None 100 | 101 | output: list[dict[str, Any]] = data.get("responses", []) 102 | if not output or not output[0]: 103 | if not ctx.interaction: 104 | await ctx.send("No text was detected or extracted from that image.") 105 | return None 106 | obj = dacite.from_dict(data_class=VisionPayload, data=data["responses"][0]) 107 | if obj.error and obj.error.message: 108 | if not ctx.interaction: 109 | await ctx.send(str(obj.error)) 110 | return None 111 | 112 | return obj 113 | -------------------------------------------------------------------------------- /phonefinder/__init__.py: -------------------------------------------------------------------------------- 1 | from discord.utils import maybe_coroutine 2 | 3 | from .phonefinder import PhoneFinder 4 | 5 | __red_end_user_data_statement__ = "This cog does not store any PII data or metadata about users." 6 | 7 | 8 | async def setup(bot): 9 | await maybe_coroutine(bot.add_cog, PhoneFinder()) 10 | -------------------------------------------------------------------------------- /phonefinder/converter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import urllib.parse 3 | from contextlib import suppress 4 | from typing import cast 5 | 6 | import aiohttp 7 | import discord 8 | from bs4 import BeautifulSoup as bsp, element 9 | from redbot.core import commands 10 | 11 | try: 12 | import lxml # type: ignore 13 | PARSER = "lxml" 14 | except ImportError: 15 | PARSER = "html.parser" 16 | 17 | BASE_URL = "https://www.gsmarena.com/results.php3?sQuickSearch=yes&sName={}" 18 | 19 | USER_AGENT = { 20 | "Accept": "text/html,application/xhtml+xml,application/xml", 21 | "Accept-Encoding": "gzip, deflate, br", 22 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" 23 | " (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36", 24 | } 25 | 26 | 27 | class QueryConverter(commands.Converter): 28 | async def convert(self, ctx: commands.Context, argument: str) -> str: 29 | try: 30 | async with aiohttp.ClientSession() as session: 31 | async with session.get( 32 | BASE_URL.format(urllib.parse.quote_plus(argument)), headers=USER_AGENT 33 | ) as resp: 34 | if resp.status != 200: 35 | raise commands.BadArgument( 36 | f"⚠ GSMarena returned status code {resp.status}." 37 | ) 38 | data = await resp.text() 39 | except (asyncio.TimeoutError, aiohttp.ClientError): 40 | raise commands.BadArgument("⚠ Operation timed out!") 41 | 42 | soup = bsp(data, features=PARSER).find("div", {"class": "makers"}) 43 | get_ul_div = cast(element.Tag, soup.find("ul")) 44 | makers = get_ul_div.find_all("li") 45 | if not makers: 46 | raise commands.BadArgument("⚠ No results found.") 47 | 48 | if len(makers) == 1: 49 | return makers[0].a["href"] 50 | 51 | items = [ 52 | f"**`[{i}]`** {x.span.get_text(separator=' ')}" for i, x in enumerate(makers, 1) 53 | ] 54 | 55 | choices = f"Found above {len(makers)} result(s). Choose one in 60 seconds!" 56 | embed = discord.Embed(description="\n".join(items)).set_footer(text=choices) 57 | prompt: discord.Message = await ctx.send(embed=embed) 58 | 59 | def check(msg: discord.Message) -> bool: 60 | return bool( 61 | msg.content and msg.content.isdigit() 62 | and int(msg.content) in range(len(items) + 1) 63 | and msg.author.id == ctx.author.id 64 | and msg.channel.id == ctx.channel.id 65 | ) 66 | 67 | try: 68 | choice = await ctx.bot.wait_for("message", timeout=60.0, check=check) 69 | except asyncio.TimeoutError: 70 | choice = None 71 | 72 | if choice is None or choice.content.strip() == "0": 73 | with suppress(discord.NotFound, discord.HTTPException): 74 | await prompt.delete() 75 | raise commands.BadArgument("‼ You didn't pick a valid choice. Operation cancelled.") 76 | 77 | with suppress(discord.NotFound, discord.HTTPException): 78 | await prompt.delete() 79 | return makers[int(choice.content.strip()) - 1].a["href"] -------------------------------------------------------------------------------- /phonefinder/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PhoneFinder", 3 | "short": "Fetch device specs & metadata on a (smart)phone model.", 4 | "description": "Fetch device specifications & metadata info for a (smart)phone model from GSMArena.", 5 | "end_user_data_statement": "This cog does not store any PII data or metadata about users.", 6 | "install_msg": "**Hope you will find this cog VERY BORING, made by** <@306810730055729152>.", 7 | "author": ["ow0x"], 8 | "required_cogs": {}, 9 | "requirements": ["beautifulsoup4"], 10 | "tags": ["smartphone", "gsmarena", "phone specs", "phone lookup", "phone finder"], 11 | "min_bot_version": "3.4.16", 12 | "hidden": false, 13 | "disabled": false, 14 | "type": "COG" 15 | } 16 | -------------------------------------------------------------------------------- /phonefinder/phonefinder.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from io import BytesIO 3 | from typing import cast 4 | 5 | import aiohttp 6 | import discord 7 | from bs4 import BeautifulSoup as bsp 8 | from bs4 import element 9 | from redbot.core import commands 10 | 11 | from .converter import PARSER, USER_AGENT, QueryConverter 12 | 13 | try: 14 | from playwright.async_api import async_playwright 15 | PLAYWRIGHT = True 16 | except ImportError: 17 | PLAYWRIGHT = False 18 | 19 | 20 | class PhoneFinder(commands.Cog): 21 | """Fetch device specs for a (smart)phone model from GSMArena.""" 22 | 23 | __authors__ = ["ow0x"] 24 | __version__ = "1.3.0" 25 | 26 | def format_help_for_context(self, ctx: commands.Context) -> str: 27 | """Thanks Sinbad.""" 28 | return ( 29 | f"{super().format_help_for_context(ctx)}\n\n" 30 | f"Authors: {', '.join(self.__authors__)}\n" 31 | f"Cog version: v{self.__version__}" 32 | ) 33 | 34 | def __init__(self) -> None: 35 | self.session = aiohttp.ClientSession() 36 | 37 | def cog_unload(self) -> None: 38 | asyncio.create_task(self.session.close()) 39 | 40 | @commands.command() 41 | @commands.bot_has_permissions(embed_links=True) 42 | async def phone(self, ctx: commands.Context, *, query: QueryConverter) -> None: 43 | """Fetch device specs, metadata for a (smart)phone model.""" 44 | async with ctx.typing(): 45 | url = f"https://www.gsmarena.com/{query}" 46 | 47 | try: 48 | async with self.session.get(url, headers=USER_AGENT) as resp: 49 | if resp.status != 200: 50 | return await ctx.send(f"https://http.cat/{resp.status}") 51 | html = await resp.text() 52 | except asyncio.TimeoutError: 53 | return await ctx.send("Operation timed out.") 54 | 55 | soup = bsp(html, features=PARSER) 56 | html_title = soup.find_all("title")[0].text 57 | 58 | # You probably got temporary IP banned by GSM Arena 59 | if "Too" in html_title: 60 | return await ctx.send(html_title) 61 | 62 | def get_spec(query: str, key: str = "data-spec", class_: str = "td"): 63 | result = soup.find(class_, {key: query}) 64 | return result.text if result else "N/A" 65 | 66 | embed = discord.Embed(colour=await ctx.embed_colour()) 67 | embed.title = str(get_spec("specs-phone-name-title", "class", "h1")) 68 | embed.url = url 69 | embed.set_author(name="GSM Arena", icon_url="https://i.imgur.com/lYfT1kn.png") 70 | phone_thumb = cast(element.Tag, soup.find("div", {"class": "specs-photo-main"})) 71 | if phone_thumb: 72 | embed.set_thumbnail(url=str(phone_thumb.img.get("src"))) 73 | 74 | overview = ( 75 | f"🗓 › **{get_spec('released-hl', class_='span')}**\n\n" 76 | f"📱 **OS**: {get_spec('os-hl', class_='span')}\n" 77 | f"• **Body**: {get_spec('body-hl', class_='span')}\n" 78 | f"• **Internal**: {get_spec('internalmemory')}\n" 79 | f"• **Storage Type**: {get_spec('memoryother')}\n\n" 80 | f"• **Chipset:** {get_spec('chipset')}\n" 81 | f"• **CPU**: {get_spec('cpu')}\n" 82 | f"• **GPU**: {get_spec('gpu')}\n" 83 | f"• **Battery**: {get_spec('batdescription1')}\n\n" 84 | ) 85 | display = ( 86 | f"**Type:** {get_spec('displaytype')}\n" 87 | f"**Size:** {get_spec('displaysize')}\n" 88 | f"**Resolution:** {get_spec('displayresolution')}\n" 89 | f"**Protection:** {get_spec('displayprotection')}\n" 90 | ) 91 | mode = get_spec("cam1modules").replace("\n", " » ") 92 | main_camera = ( 93 | f"**Mode**: {mode}\n" 94 | f"**Features**: {get_spec('cam1features')}\n" 95 | f"**Video**: {get_spec('cam1video')}\n\n" 96 | ) 97 | selfie_camera = ( 98 | f"**Mode**: {get_spec('cam2modules')}\n" 99 | f"**Features**: {get_spec('cam2features')}\n" 100 | f"**Video**: {get_spec('cam2video')}\n\n" 101 | ) 102 | misc_comms = ( 103 | f"**WLAN**: {get_spec('wlan')}\n" 104 | f"**Bluetooth**: {get_spec('bluetooth')}\n" 105 | f"**GPS**: {get_spec('gps')}\n" 106 | f"**USB**: {get_spec('usb')}\n" 107 | f"**NFC**: {get_spec('nfc')}\n" 108 | f"**Sensors**: {get_spec('sensors')}\n\n" 109 | ) 110 | sar = f"• **SAR US**: {get_spec('sar-us')}\n" + f"• **SAR EU**: {get_spec('sar-eu')}" 111 | 112 | embed.description = overview + sar 113 | embed.add_field(name="📱 DISPLAY:", value=display, inline=False) 114 | embed.add_field(name="📸 MAIN CAMERA:", value=main_camera, inline=False) 115 | embed.add_field(name="📷 SELFIE CAMERA:", value=selfie_camera, inline=False) 116 | embed.add_field(name="📡 MISC. COMMS:", value=misc_comms, inline=False) 117 | fans = get_spec("help-fans", key="class", class_="li").split("\n")[2] 118 | hits = cast(element.Tag, soup.find("li", {"class": "help-popularity"})) 119 | embed.set_footer( 120 | text=f"Fans: {fans} • Popularity: 📈 +{hits.strong.text} ({hits.span.text})" 121 | ) 122 | 123 | await ctx.send(embed=embed) 124 | 125 | @commands.command(hidden=True) 126 | @commands.check(lambda ctx: PLAYWRIGHT) 127 | @commands.bot_has_permissions(embed_links=True) 128 | @commands.cooldown(1, 90, commands.BucketType.default) 129 | async def phonespecs(self, ctx: commands.Context, *, query: QueryConverter) -> None: 130 | """Fetch device specs for a (smart)phone model in fancy image mode.""" 131 | async with ctx.typing(): 132 | url = f"https://www.gsmarena.com/{query}" 133 | async with async_playwright() as playwright: 134 | browser = await playwright.chromium.launch(channel="chrome") 135 | page = await browser.new_page( 136 | color_scheme="dark", 137 | screen={"width": 1920, "height": 1080}, 138 | viewport={"width": 1920, "height": 1080}, 139 | ) 140 | await page.goto(url) 141 | img_bytes = await page.locator('//*[@id="body"]/div/div[1]/div').screenshot() 142 | temp_1 = BytesIO(img_bytes) 143 | temp_1.seek(0) 144 | file_1 = discord.File(temp_1, "header.png") 145 | temp_1.close() 146 | 147 | specs_bytes = await page.locator('//*[@id="specs-list"]').screenshot() 148 | temp_2 = BytesIO(specs_bytes) 149 | temp_2.seek(0) 150 | file_2 = discord.File(temp_2, "main_specs.png") 151 | temp_2.close() 152 | 153 | await browser.close() 154 | 155 | em_1 = discord.Embed(colour=discord.Colour.random()) 156 | em_1.set_image(url="attachment://header.png") 157 | await ctx.send(embed=em_1, file=file_1) 158 | em_2 = discord.Embed(colour=discord.Colour.random()) 159 | em_2.set_image(url="attachment://main_specs.png") 160 | return await ctx.send(embed=em_2, file=file_2) 161 | -------------------------------------------------------------------------------- /pokebase/__init__.py: -------------------------------------------------------------------------------- 1 | from discord.utils import maybe_coroutine 2 | 3 | from .pokebase import Pokebase 4 | 5 | __red_end_user_data_statement__ = "This cog does not persistently store data about users." 6 | 7 | 8 | async def setup(bot): 9 | await maybe_coroutine(bot.add_cog, Pokebase()) 10 | -------------------------------------------------------------------------------- /pokebase/data/template.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owocado/cogs/4d829f151b8d2d1455329720110cf3d1e332e949/pokebase/data/template.webp -------------------------------------------------------------------------------- /pokebase/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Pokebase", 3 | "short": "Your personal Pokédex with fun games/commands.", 4 | "description": "Fetch various info about a Pokémon and some fun commands.", 5 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 6 | "install_msg": "Hi, thanks for installing Pokebase cog. Buy me a coffee if you enjoy it. /s", 7 | "author": [ 8 | "ow0x (<@306810730055729152>)", 9 | "phalt" 10 | ], 11 | "required_cogs": {}, 12 | "requirements": ["aiocache", "beautifulsoup4", "jmespath", "msgpack", "pillow", "ujson"], 13 | "tags": [ 14 | "pokemon", 15 | "pokedex", 16 | "pokebase", 17 | "pokeapi", 18 | "whosthatpokemon", 19 | "pokemontcg", 20 | "pokemon card game", 21 | "pokemon card", 22 | "trainer card", 23 | "pokemon trainer card", 24 | "pokecord" 25 | ], 26 | "min_bot_version": "3.4.8", 27 | "hidden": false, 28 | "disabled": false, 29 | "type": "COG" 30 | } 31 | -------------------------------------------------------------------------------- /pokebase/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from redbot.core import commands 4 | 5 | BADGES = { 6 | "kanto": [2, 3, 4, 5, 6, 7, 8, 9], 7 | "johto": [10, 11, 12, 13, 14, 15, 16, 17], 8 | "hoenn": [18, 19, 20, 21, 22, 23, 24, 25], 9 | "sinnoh": [26, 27, 28, 29, 30, 31, 32, 33], 10 | "unova": [34, 35, 36, 37, 38, 39, 40, 41], 11 | "kalos": [44, 45, 46, 47, 48, 49, 50, 51], 12 | } 13 | 14 | GENERATIONS = { 15 | "na": "Unknown", 16 | "rb": "Red/Blue\n(Gen. 1)", 17 | "gs": "Gold/Silver\n(Gen. 2)", 18 | "rs": "Ruby/Sapphire\n(Gen. 3)", 19 | "dp": "Diamond/Pearl\n(Gen. 4)", 20 | "bw": "Black/White\n(Gen. 5)", 21 | "xy": "X/Y\n(Gen. 6)", 22 | "sm": "Sun/Moon\n(Gen. 7)", 23 | "ss": "Sword/Shield\n(Gen. 8)", 24 | } 25 | GEN_KEYS = list(GENERATIONS.keys()) 26 | 27 | STYLES = {"default": 3, "black": 50, "collector": 96, "dp": 5, "purple": 43} 28 | TRAINERS = { 29 | "ash": 13, 30 | "red": 922, 31 | "ethan": 900, 32 | "lyra": 901, 33 | "brendan": 241, 34 | "may": 255, 35 | "lucas": 747, 36 | "dawn": 856, 37 | } 38 | 39 | 40 | def get_generation(pokemon_id: int) -> str: 41 | if pokemon_id >= 1 and pokemon_id <= 151: 42 | generation = 1 43 | elif pokemon_id >= 152 and pokemon_id <= 251: 44 | generation = 2 45 | elif pokemon_id >= 252 and pokemon_id <= 386: 46 | generation = 3 47 | elif pokemon_id >= 387 and pokemon_id <= 493: 48 | generation = 4 49 | elif pokemon_id >= 494 and pokemon_id <= 649: 50 | generation = 5 51 | elif pokemon_id >= 650 and pokemon_id <= 721: 52 | generation = 6 53 | elif pokemon_id >= 722 and pokemon_id <= 809: 54 | generation = 7 55 | elif pokemon_id >= 810 and pokemon_id <= 898: 56 | generation = 8 57 | else: 58 | generation = 0 59 | 60 | return GENERATIONS[GEN_KEYS[generation]] 61 | 62 | 63 | class Generation(commands.Converter): 64 | 65 | async def convert(self, ctx: commands.Context, argument: str) -> int: 66 | allowed_gens = [f"gen{x}" for x in range(1, 9)] 67 | if argument.lower() not in allowed_gens: 68 | ctx.command.reset_cooldown(ctx) 69 | raise commands.BadArgument("Only `gen1` to `gen8` values are allowed.") 70 | 71 | if argument == "gen1": 72 | return random.randint(1, 151) 73 | elif argument == "gen2": 74 | return random.randint(152, 251) 75 | elif argument == "gen3": 76 | return random.randint(252, 386) 77 | elif argument == "gen4": 78 | return random.randint(387, 493) 79 | elif argument == "gen5": 80 | return random.randint(494, 649) 81 | elif argument == "gen6": 82 | return random.randint(650, 721) 83 | elif argument == "gen7": 84 | return random.randint(722, 809) 85 | elif argument == "gen8": 86 | return random.randint(810, 898) 87 | else: 88 | return random.randint(1, 898) -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pyright] 2 | exclude = [ 3 | "**/__pycache__", 4 | "data", 5 | ] 6 | reportGeneralTypeIssues = "none" 7 | reportOptionalMemberAccess = "none" 8 | reportUnnecessaryTypeIgnoreComment = "warning" 9 | reportUnusedImport = "warning" 10 | pythonVersion = "3.9" 11 | typeCheckingMode = "basic" -------------------------------------------------------------------------------- /redditinfo/__init__.py: -------------------------------------------------------------------------------- 1 | from discord.utils import maybe_coroutine 2 | 3 | from .redditinfo import RedditInfo 4 | 5 | __red_end_user_data_statement__ = "This cog does not persistently store any PII data or metadata about users." 6 | 7 | 8 | async def setup(bot): 9 | await maybe_coroutine(bot.add_cog, RedditInfo(bot)) 10 | -------------------------------------------------------------------------------- /redditinfo/handles.py: -------------------------------------------------------------------------------- 1 | MEME_REDDITS = [ 2 | "memes", 3 | "dankmemes", 4 | "meirl", 5 | "programmeranimemes", 6 | "bikinibottomtwitter", 7 | "2meirl4meirl", 8 | ] 9 | 10 | INTERESTING_SUBS = [ 11 | "interestingasfuck", 12 | "mildlyinteresting", 13 | "damnthatsinteresting", 14 | ] 15 | -------------------------------------------------------------------------------- /redditinfo/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "RedditInfo", 3 | "short": "Fetch hot meme or basic info about Reddit user accounts and subreddits.", 4 | "description": "Fetch hot meme or basic info about Reddit user accounts and subreddits.", 5 | "install_msg": "Fetch or autopost random hot meme or basic info about Reddit user accounts and existing subreddits.", 6 | "end_user_data_statement": "This cog does not persistently store any PII data or metadata about users.", 7 | "author": ["ow0x (<@306810730055729152>)"], 8 | "tags": [ 9 | "meme", 10 | "hot meme", 11 | "auto meme", 12 | "auto post meme", 13 | "meme autopost", 14 | "reddit meme", 15 | "reddit", 16 | "redditinfo", 17 | "subreddit" 18 | ], 19 | "min_bot_version": "3.5.0.dev0", 20 | "disabled": false, 21 | "hidden": false, 22 | "type": "COG" 23 | } 24 | -------------------------------------------------------------------------------- /roleplay/__init__.py: -------------------------------------------------------------------------------- 1 | from discord.utils import maybe_coroutine 2 | 3 | from .roleplay import Roleplay 4 | 5 | __red_end_user_data_statement__ = "This cog does not persistently store any PII data or metadata about users." 6 | 7 | 8 | async def setup(bot): 9 | await maybe_coroutine(bot.add_cog, Roleplay(bot)) 10 | -------------------------------------------------------------------------------- /roleplay/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Roleplay", 3 | "short": "Roleplay with friends on Discord with count stats.", 4 | "description": "Roleplay with friends (or strangers) on Discord with count stats, (hug, pat, nom, cry and 12+ more commands).", 5 | "end_user_data_statement": "This cog does not persistently store any PII data or metadata about users.", 6 | "install_msg": "I hope you will enjoy this Roleplay cog, or not.", 7 | "author": ["ow0x"], 8 | "required_cogs": {}, 9 | "requirements": ["tabulate"], 10 | "tags": [ 11 | "baka", 12 | "bully", 13 | "cry", 14 | "cuddle", 15 | "feed", 16 | "highfive", 17 | "hug", 18 | "kill", 19 | "kiss", 20 | "lick", 21 | "nom", 22 | "pat", 23 | "poke", 24 | "punch", 25 | "slap", 26 | "smug", 27 | "tickle", 28 | "roleplay" 29 | ], 30 | "min_bot_version": "3.4.12", 31 | "hidden": false, 32 | "disabled": false, 33 | "type": "COG" 34 | } 35 | -------------------------------------------------------------------------------- /steamcog/__init__.py: -------------------------------------------------------------------------------- 1 | from discord.utils import maybe_coroutine 2 | from redbot.core.bot import Red 3 | 4 | from .steamcog import SteamCog 5 | 6 | __red_end_user_data_statement__ = "This cog does not persistently store data about users." 7 | 8 | 9 | async def setup(bot: Red): 10 | await maybe_coroutine(bot.add_cog, SteamCog(bot)) 11 | -------------------------------------------------------------------------------- /steamcog/converter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | from typing import Any, Dict, Optional, Union 4 | 5 | import aiohttp 6 | import discord 7 | from redbot.core import commands 8 | from redbot.core.utils.chat_formatting import humanize_number as nfmt 9 | 10 | from .stores import AVAILABLE_REGIONS 11 | 12 | USER_AGENT = { 13 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" 14 | " (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36" 15 | } 16 | 17 | 18 | async def request(url: str, **kwargs) -> Union[int, Dict[str, Any]]: 19 | params = kwargs.get("params") 20 | try: 21 | async with aiohttp.ClientSession() as session: 22 | async with session.get(url, headers=USER_AGENT, params=params) as resp: 23 | if resp.status != 200: 24 | return resp.status 25 | return await resp.json() 26 | except (asyncio.TimeoutError, aiohttp.ClientError): 27 | return 408 28 | 29 | 30 | class RegionConverter(commands.Converter): 31 | 32 | async def convert(self, ctx: commands.Context, argument: str) -> str: 33 | if AVAILABLE_REGIONS.get(argument): 34 | return AVAILABLE_REGIONS[argument] 35 | elif argument.upper() in AVAILABLE_REGIONS.values(): 36 | return argument.upper() 37 | else: 38 | raise commands.BadArgument( 39 | "❌ You provided either an invalid country name or" 40 | " an incorrect 2 letter ISO3166 region code.\n" 41 | "" 42 | ) 43 | 44 | 45 | class QueryConverter(commands.Converter): 46 | 47 | async def convert(self, ctx: commands.Context, argument: str) -> int: 48 | # TODO: remove this temp fix once game is released on Steam for all regions 49 | if argument.lower() == "lost ark": 50 | return 1599340 51 | 52 | cog = ctx.bot.get_cog("SteamCog") 53 | user_region = (await cog.config.user(ctx.author).region()) or "US" 54 | data = await request( 55 | "https://store.steampowered.com/api/storesearch", 56 | params={"cc": user_region, "l": "en", "term": argument.lower()} 57 | ) 58 | if type(data) == int: 59 | raise commands.BadArgument(f"⚠ API sent response code: https://http.cat/{data}") 60 | if not data: 61 | raise commands.BadArgument("❌ No results found from your query.") 62 | if data.get("total", 0) == 0: 63 | raise commands.BadArgument("❌ No results found from your query.") 64 | elif data.get("total") == 1: 65 | return data.get("items", [{}])[0].get("id") 66 | 67 | def format_price(price_obj: Dict[str, Any], metascore: str) -> str: 68 | if not price_obj: 69 | return "" 70 | currency: str = price_obj.get("currency") 71 | initial: Optional[int] = price_obj.get("initial") 72 | final: Optional[int] = price_obj.get("final") 73 | msg = [] 74 | if initial is not None and final is not None: 75 | if initial != final: 76 | msg.append(f"💵 {currency} ~~{nfmt(initial / 100)}~~ {nfmt(final / 100)}") 77 | else: 78 | msg.append(f"💵 {currency} {nfmt(final / 100)}") 79 | if metascore: 80 | msg.append(f"{metascore}% metascore") 81 | return f" ({', '.join(msg)})" if msg else "" 82 | 83 | # https://github.com/Sitryk/sitcogsv3/blob/master/lyrics/lyrics.py#L142 84 | items = [ 85 | f"**`[{i:>2}]` {app.get('name')}**" 86 | f"{format_price(app.get('price', {}), app.get('metascore'))}" 87 | for i, app in enumerate(data.get("items"), start=1) 88 | ] 89 | choices = f"Found below **{len(items)}** results. Choose one in 60 seconds:\n\n" 90 | prompt: discord.Message = await ctx.send(choices + "\n".join(items)) 91 | 92 | def check(msg: discord.Message) -> bool: 93 | return bool( 94 | msg.content and msg.content.isdigit() 95 | and int(msg.content) in range(len(items) + 1) 96 | and msg.author.id == ctx.author.id 97 | and msg.channel.id == ctx.channel.id 98 | ) 99 | 100 | try: 101 | choice = await ctx.bot.wait_for("message", timeout=60.0, check=check) 102 | except asyncio.TimeoutError: 103 | choice = None 104 | if choice is None or choice.content.strip() == "0": 105 | with contextlib.suppress(discord.NotFound, discord.HTTPException): 106 | await prompt.delete() 107 | raise commands.BadArgument("‼ You didn't pick a valid choice. Operation cancelled.") 108 | 109 | with contextlib.suppress(discord.NotFound, discord.HTTPException): 110 | await prompt.delete() 111 | return data["items"][int(choice.content.strip()) - 1].get("id") 112 | 113 | 114 | class GamedealsConverter(commands.Converter): 115 | 116 | async def convert(self, ctx: commands.Context, argument: str) -> int: 117 | url = f"https://www.cheapshark.com/api/1.0/games?title={argument.lower()}" 118 | data = await request(url) 119 | if type(data) == int: 120 | raise commands.BadArgument(f"⚠ API sent response code: https://http.cat/{data}") 121 | if not data or len(data) == 0: 122 | raise commands.BadArgument("❌ No results found.") 123 | if len(data) == 1: 124 | return data[0].get("cheapestDealID") 125 | 126 | # https://github.com/Sitryk/sitcogsv3/blob/master/lyrics/lyrics.py#L142 127 | items = "\n".join( 128 | f"**`[{i:>2}]`** {x.get('external')}" for i, x in enumerate(data[:20], 1) 129 | ) 130 | count = len(data) if len(data) < 20 else 20 131 | choices = f"Choose one in 60 seconds:\n\n{items}" 132 | prompt: discord.Message = await ctx.send(f"Found below **{count}** results. {choices}") 133 | 134 | def check(msg: discord.Message) -> bool: 135 | return bool( 136 | msg.content and msg.content.isdigit() 137 | and int(msg.content) in range(count + 1) 138 | and msg.author.id == ctx.author.id 139 | and msg.channel.id == ctx.channel.id 140 | ) 141 | 142 | try: 143 | choice = await ctx.bot.wait_for("message", timeout=60.0, check=check) 144 | except asyncio.TimeoutError: 145 | choice = None 146 | 147 | if choice is None or choice.content.strip() == "0": 148 | with contextlib.suppress(discord.NotFound, discord.HTTPException): 149 | await prompt.delete() 150 | raise commands.BadArgument("‼ You didn't pick a valid choice. Operation cancelled.") 151 | 152 | with contextlib.suppress(discord.NotFound, discord.HTTPException): 153 | await prompt.delete() 154 | return data[int(choice.content.strip()) - 1].get("cheapestDealID") 155 | -------------------------------------------------------------------------------- /steamcog/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SteamCog", 3 | "short": "Fetch various useful info about a Steam game.", 4 | "description": "Fetch various useful info about a Steam game all from the comfort of your Discord home.", 5 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 6 | "install_msg": "Thank you for installing this MEH cog.", 7 | "author": ["ow0x"], 8 | "required_cogs": {}, 9 | "requirements": ["html2text"], 10 | "tags": ["steam", "steamcog"], 11 | "min_bot_version": "3.4.12", 12 | "hidden": false, 13 | "disabled": false, 14 | "type": "COG" 15 | } 16 | -------------------------------------------------------------------------------- /steamcog/stores.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | STORES: Dict[str, str] = { 4 | "1": "Steam", 5 | "2": "GamersGate", 6 | "3": "GreenManGaming", 7 | "4": "Amazon", 8 | "5": "GameStop", 9 | "6": "Direct2Drive", 10 | "7": "GOG", 11 | "8": "Origin", 12 | "9": "Get Games", 13 | "10": "Shiny Loot", 14 | "11": "Humble Store", 15 | "12": "Desura", 16 | "13": "Uplay", 17 | "14": "IndieGameStand", 18 | "15": "Fanatical", 19 | "16": "Gamesrocket", 20 | "17": "Games Republic", 21 | "18": "SilaGames", 22 | "19": "Playfield", 23 | "20": "ImperialGames", 24 | "21": "WinGameStore", 25 | "22": "FunStockDigital", 26 | "23": "GameBillet", 27 | "24": "Voidu", 28 | "25": "Epic Games Store", 29 | "26": "Razer Game Store", 30 | "27": "Gamesplanet", 31 | "28": "Gamesload", 32 | "29": "2Game", 33 | "30": "IndieGala", 34 | "31": "Blizzard Shop", 35 | "32": "AllYouPlay" 36 | } 37 | 38 | AVAILABLE_REGIONS: Dict[str, str] = { 39 | "aruba": "AW", 40 | "afghanistan": "AF", 41 | "angola": "AO", 42 | "anguilla": "AI", 43 | "albania": "AL", 44 | "andorra": "AD", 45 | "united_arab_emirates": "AE", 46 | "argentina": "AR", 47 | "armenia": "AM", 48 | "american_samoa": "AS", 49 | "antarctica": "AQ", 50 | "french_southern_territories": "TF", 51 | "antigua_and_barbuda": "AG", 52 | "australia": "AU", 53 | "austria": "AT", 54 | "azerbaijan": "AZ", 55 | "burundi": "BI", 56 | "belgium": "BE", 57 | "benin": "BJ", 58 | "burkina_faso": "BF", 59 | "bangladesh": "BD", 60 | "bulgaria": "BG", 61 | "bahrain": "BH", 62 | "bahamas": "BS", 63 | "bosnia_and_herzegovina": "BA", 64 | "belarus": "BY", 65 | "belize": "BZ", 66 | "bermuda": "BM", 67 | "bolivia": "BO", 68 | "brazil": "BR", 69 | "barbados": "BB", 70 | "brunei_darussalam": "BN", 71 | "bhutan": "BT", 72 | "botswana": "BW", 73 | "central_african_republic": "CF", 74 | "canada": "CA", 75 | "switzerland": "CH", 76 | "chile": "CL", 77 | "china": "CN", 78 | "cameroon": "CM", 79 | "congo_democratic": "CD", 80 | "congo": "CG", 81 | "cook_islands": "CK", 82 | "colombia": "CO", 83 | "comoros": "KM", 84 | "cabo_verde": "CV", 85 | "costa_rica": "CR", 86 | "cuba": "CU", 87 | "christmas_island": "CX", 88 | "cayman_islands": "KY", 89 | "cyprus": "CY", 90 | "czechia": "CZ", 91 | "germany": "DE", 92 | "djibouti": "DJ", 93 | "dominica": "DM", 94 | "denmark": "DK", 95 | "dominican_republic": "DO", 96 | "algeria": "DZ", 97 | "ecuador": "EC", 98 | "egypt": "EG", 99 | "eritrea": "ER", 100 | "western_sahara": "EH", 101 | "spain": "ES", 102 | "estonia": "EE", 103 | "ethiopia": "ET", 104 | "finland": "FI", 105 | "fiji": "FJ", 106 | "france": "FR", 107 | "faroe_islands": "FO", 108 | "micronesia": "FM", 109 | "gabon": "GA", 110 | "united_kingdom": "GB", 111 | "georgia": "GE", 112 | "guernsey": "GG", 113 | "ghana": "GH", 114 | "gibraltar": "GI", 115 | "guinea": "GN", 116 | "guadeloupe": "GP", 117 | "gambia": "GM", 118 | "guinea_bissau": "GW", 119 | "equatorial_guinea": "GQ", 120 | "greece": "GR", 121 | "grenada": "GD", 122 | "greenland": "GL", 123 | "guatemala": "GT", 124 | "french_guiana": "GF", 125 | "guam": "GU", 126 | "guyana": "GY", 127 | "hong_kong": "HK", 128 | "honduras": "HN", 129 | "croatia": "HR", 130 | "haiti": "HT", 131 | "hungary": "HU", 132 | "indonesia": "ID", 133 | "isle_of_man": "IM", 134 | "india": "IN", 135 | "ireland": "IE", 136 | "iran": "IR", 137 | "iraq": "IQ", 138 | "iceland": "IS", 139 | "israel": "IL", 140 | "italy": "IT", 141 | "jamaica": "JM", 142 | "jersey": "JE", 143 | "jordan": "JO", 144 | "japan": "JP", 145 | "kazakhstan": "KZ", 146 | "kenya": "KE", 147 | "kyrgyzstan": "KG", 148 | "cambodia": "KH", 149 | "kiribati": "KI", 150 | "saint_kitts_and_nevis": "KN", 151 | "south_korea": "KR", 152 | "kuwait": "KW", 153 | "laos": "LA", 154 | "lebanon": "LB", 155 | "liberia": "LR", 156 | "libya": "LY", 157 | "saint_lucia": "LC", 158 | "liechtenstein": "LI", 159 | "sri_lanka": "LK", 160 | "lesotho": "LS", 161 | "lithuania": "LT", 162 | "luxembourg": "LU", 163 | "latvia": "LV", 164 | "macao": "MO", 165 | "morocco": "MA", 166 | "monaco": "MC", 167 | "moldova": "MD", 168 | "madagascar": "MG", 169 | "maldives": "MV", 170 | "mexico": "MX", 171 | "marshall_islands": "MH", 172 | "north_macedonia": "MK", 173 | "mali": "ML", 174 | "malta": "MT", 175 | "myanmar": "MM", 176 | "montenegro": "ME", 177 | "mongolia": "MN", 178 | "northern_mariana_islands": "MP", 179 | "mozambique": "MZ", 180 | "mauritania": "MR", 181 | "montserrat": "MS", 182 | "martinique": "MQ", 183 | "mauritius": "MU", 184 | "malawi": "MW", 185 | "malaysia": "MY", 186 | "mayotte": "YT", 187 | "namibia": "NA", 188 | "new_caledonia": "NC", 189 | "niger": "NE", 190 | "norfolk_island": "NF", 191 | "nigeria": "NG", 192 | "nicaragua": "NI", 193 | "niue": "NU", 194 | "netherlands": "NL", 195 | "norway": "NO", 196 | "nepal": "NP", 197 | "nauru": "NR", 198 | "new_zealand": "NZ", 199 | "oman": "OM", 200 | "pakistan": "PK", 201 | "panama": "PA", 202 | "pitcairn": "PN", 203 | "peru": "PE", 204 | "philippines": "PH", 205 | "palau": "PW", 206 | "papua_new_guinea": "PG", 207 | "poland": "PL", 208 | "puerto_rico": "PR", 209 | "north_korea": "KP", 210 | "portugal": "PT", 211 | "paraguay": "PY", 212 | "palestine": "PS", 213 | "french_polynesia": "PF", 214 | "qatar": "QA", 215 | "romania": "RO", 216 | "russian_federation": "RU", 217 | "rwanda": "RW", 218 | "saudi_arabia": "SA", 219 | "sudan": "SD", 220 | "senegal": "SN", 221 | "singapore": "SG", 222 | "saint_helena": "SH", 223 | "svalbard_and_jan_mayen": "SJ", 224 | "solomon_islands": "SB", 225 | "sierra_leone": "SL", 226 | "el_salvador": "SV", 227 | "san_marino": "SM", 228 | "somalia": "SO", 229 | "serbia": "RS", 230 | "south_sudan": "SS", 231 | "sao_tome_and_principe": "ST", 232 | "suriname": "SR", 233 | "slovakia": "SK", 234 | "slovenia": "SI", 235 | "sweden": "SE", 236 | "eswatini": "SZ", 237 | "sint_maarten": "SX", 238 | "seychelles": "SC", 239 | "syrian_arab_republic": "SY", 240 | "chad": "TD", 241 | "togo": "TG", 242 | "thailand": "TH", 243 | "tajikistan": "TJ", 244 | "tokelau": "TK", 245 | "turkmenistan": "TM", 246 | "tonga": "TO", 247 | "trinidad_and_tobago": "TT", 248 | "tunisia": "TN", 249 | "turkey": "TR", 250 | "tuvalu": "TV", 251 | "taiwan": "TW", 252 | "tanzania": "TZ", 253 | "uganda": "UG", 254 | "ukraine": "UA", 255 | "uruguay": "UY", 256 | "united_states": "US", 257 | "uzbekistan": "UZ", 258 | "vatican_city": "VA", 259 | "venezuela": "VE", 260 | "viet_nam": "VN", 261 | "vanuatu": "VU", 262 | "wallis_and_futuna": "WF", 263 | "samoa": "WS", 264 | "yemen": "YE", 265 | "south_africa": "ZA", 266 | "zambia": "ZM", 267 | "zimbabwe": "ZW" 268 | } 269 | -------------------------------------------------------------------------------- /yugioh/__init__.py: -------------------------------------------------------------------------------- 1 | from discord.utils import maybe_coroutine 2 | 3 | from .yugioh import YGO 4 | 5 | __red_end_user_data_statement__ = "This cog does not persistently store any PII data about users." 6 | 7 | 8 | async def setup(bot): 9 | await maybe_coroutine(bot.add_cog, YGO()) 10 | -------------------------------------------------------------------------------- /yugioh/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from dataclasses import dataclass, field 5 | from typing import Any, Dict, Optional, Sequence 6 | 7 | import aiohttp 8 | 9 | 10 | @dataclass 11 | class BanList: 12 | ban_tcg: str = "" 13 | ban_ocg: str = "" 14 | ban_goat: str = "" 15 | 16 | 17 | @dataclass 18 | class CardImage: 19 | image_url: str 20 | image_url_small: str 21 | id: Optional[int] = None 22 | 23 | 24 | @dataclass 25 | class CardPrice: 26 | cardmarket_price: str = "" 27 | tcgplayer_price: str = "" 28 | ebay_price: str = "" 29 | amazon_price: str = "" 30 | coolstuffinc_price: str = "" 31 | 32 | 33 | @dataclass 34 | class CardSet: 35 | set_name: str = "" 36 | set_code: str = "" 37 | set_rarity: str = "" 38 | set_rarity_code: str = "" 39 | set_price: str = "" 40 | 41 | 42 | @dataclass 43 | class NotFound: 44 | error: str 45 | http_code: int 46 | message: str = "" 47 | 48 | def __str__(self) -> str: 49 | return self.error or f"https://http.cat/{self.http_code}.jpg" 50 | 51 | 52 | @dataclass 53 | class YuGiOhData: 54 | id: int 55 | name: str 56 | type: str 57 | desc: str 58 | attack: int 59 | defense: int 60 | level: int 61 | race: str 62 | archetype: Optional[str] = None 63 | attribute: Optional[str] = None 64 | scale: Optional[int] = None 65 | linkval: Optional[int] = None 66 | linkmarkers: Sequence[str] = field(default_factory=list) 67 | banlist_info: Optional[BanList] = field(default_factory=dict) 68 | card_images: Sequence[CardImage] = field(default_factory=list) 69 | card_prices: Sequence[CardPrice] = field(default_factory=list) 70 | card_sets: Sequence[CardSet] = field(default_factory=list) 71 | 72 | @classmethod 73 | def from_dict(cls, data: Dict[str, Any]) -> YuGiOhData: 74 | attack = data.pop("atk", 0) 75 | defense = data.pop("def", 0) 76 | level = data.pop("level", 0) 77 | banlist_info = BanList(**data.pop("banlist_info", {})) 78 | card_images = [CardImage(**img) for img in data.pop("card_images", [])] 79 | card_prices = [CardPrice(**price) for price in data.pop("card_prices", [])] 80 | card_sets = [CardSet(**set_) for set_ in data.pop("card_sets", [])] 81 | return cls( 82 | attack=attack, 83 | defense=defense, 84 | level=level, 85 | banlist_info=banlist_info, 86 | card_images=card_images, 87 | card_prices=card_prices, 88 | card_sets=card_sets, 89 | **data 90 | ) 91 | 92 | @classmethod 93 | async def request( 94 | cls, session: aiohttp.ClientSession, url: str 95 | ) -> NotFound | Sequence[YuGiOhData] | YuGiOhData: 96 | try: 97 | async with session.get(url) as resp: 98 | if resp.status != 200: 99 | if 'json' in resp.headers.get('Content-Type', ''): 100 | data = await resp.json() 101 | if data.get('error'): 102 | return NotFound(http_code=resp.status, **data) 103 | return NotFound("", http_code=resp.status) 104 | 105 | ygo_data = await resp.json() 106 | except asyncio.TimeoutError: 107 | return NotFound("Error: 408! Operation timed out.", http_code=408) 108 | 109 | if not ygo_data.get("data"): 110 | return cls.from_dict(ygo_data) 111 | return [cls.from_dict(card) for card in ygo_data['data']] 112 | -------------------------------------------------------------------------------- /yugioh/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "YuGiOh", 3 | "short": "Fetch some info on a Yu-Gi-Oh! card.", 4 | "description": "Fetch some info on a Yu-Gi-Oh! card.", 5 | "end_user_data_statement": "This cog does not persistently store any PII data about users.", 6 | "install_msg": "Thanks for installing Yu-Gi-Oh cog. Have fun!", 7 | "author": ["ow0x", "dragonfire535"], 8 | "required_cogs": {}, 9 | "requirements": [], 10 | "tags": ["ygo", "ygo card", "yu gi oh", "yugioh"], 11 | "min_bot_version": "3.4.14", 12 | "hidden": false, 13 | "disabled": false, 14 | "type": "COG" 15 | } 16 | -------------------------------------------------------------------------------- /yugioh/yugioh.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiohttp 4 | import discord 5 | from redbot.core import commands 6 | from redbot.core.utils.chat_formatting import humanize_number 7 | from redbot.core.utils.menus import DEFAULT_CONTROLS, menu 8 | 9 | from .api import NotFound, YuGiOhData 10 | 11 | 12 | class YGO(commands.Cog): 13 | """Get nerdy info on a Yu-Gi-Oh! card or pull a random card.""" 14 | 15 | __authors__ = ["ow0x", "dragonfire535"] 16 | __version__ = "2.0.1" 17 | 18 | def format_help_for_context(self, ctx: commands.Context) -> str: 19 | """Thanks Sinbad.""" 20 | return ( 21 | f"{super().format_help_for_context(ctx)}\n\n" 22 | f"**Authors:** {', '.join(self.__authors__)}\n" 23 | f"**Cog version:** v{self.__version__}" 24 | ) 25 | 26 | session = aiohttp.ClientSession() 27 | 28 | async def red_delete_data_for_user(self, **kwargs) -> None: 29 | """Nothing to delete""" 30 | pass 31 | 32 | def cog_unload(self) -> None: 33 | if self.session: 34 | asyncio.create_task(self.session.close()) 35 | 36 | @staticmethod 37 | def generate_embed(data: YuGiOhData, colour: discord.Colour, footer: str) -> discord.Embed: 38 | embed = discord.Embed(colour=colour, title=data.name, description=data.desc) 39 | embed.url = f"https://db.ygoprodeck.com/card/?search={data.id}" 40 | embed.set_image(url=data.card_images[0].image_url) 41 | if "Monster" in data.type: 42 | embed.add_field(name="Attribute", value=str(data.attribute)) 43 | embed.add_field(name="Attack (ATK)", value=humanize_number(data.attack)) 44 | is_monster = data.type == "Link Monster" 45 | link_name = "Link Value" if is_monster else "Defense (DEF)" 46 | link_value = data.linkval if is_monster else humanize_number(data.defense) 47 | embed.add_field(name=link_name, value=str(link_value)) 48 | if data.card_sets: 49 | card_sets = "\n".join( 50 | f"`[{i:>2}]` {card.set_name} @ **${card.set_price}** {card.set_rarity_code}" 51 | for i, card in enumerate(data.card_sets, 1) 52 | ) 53 | embed.add_field(name="Card Sets", value=card_sets, inline=False) 54 | if data.card_prices: 55 | card_prices = ( 56 | f"Cardmarket: **€{data.card_prices[0].cardmarket_price}**\n" 57 | f"TCGPlayer: **${data.card_prices[0].tcgplayer_price}**\n" 58 | f"eBay: **${data.card_prices[0].ebay_price}**\n" 59 | f"Amazon: **${data.card_prices[0].amazon_price}**\n" 60 | ) 61 | embed.add_field(name="Prices", value=card_prices, inline=False) 62 | embed.set_footer(text=footer, icon_url="https://i.imgur.com/AJNBflD.png") 63 | return embed 64 | 65 | @commands.command(aliases=("ygo", "yugioh")) 66 | @commands.cooldown(1, 5, commands.BucketType.user) 67 | @commands.bot_has_permissions(add_reactions=True, embed_links=True) 68 | async def ygocard(self, ctx: commands.Context, *, card_name: str): 69 | """Search for a Yu-Gi-Oh! card.""" 70 | async with ctx.typing(): 71 | card = card_name.replace(" ", "%20") 72 | base_url = f"https://db.ygoprodeck.com/api/v7/cardinfo.php?fname={card}" 73 | card_data = await YuGiOhData.request(self.session, base_url) 74 | if isinstance(card_data, NotFound): 75 | return await ctx.send(str(card_data)) 76 | 77 | pages = [] 78 | for i, data in enumerate(card_data, start=1): 79 | colour = await ctx.embed_colour() 80 | page_meta = f"Page {i} of {len(card_data)} | Card Level: " 81 | race_or_spell = "Race" if "Monster" in data.type else "Spell Type" 82 | footer = f"{page_meta}{data.level} | {race_or_spell}: {data.race}" 83 | embed = self.generate_embed(data, colour, footer) 84 | pages.append(embed) 85 | 86 | await menu(ctx, pages, DEFAULT_CONTROLS, timeout=90.0) 87 | 88 | @commands.command() 89 | @commands.bot_has_permissions(embed_links=True) 90 | async def randomcard(self, ctx: commands.Context): 91 | """Fetch a random Yu-Gi-Oh! card.""" 92 | async with ctx.typing(): 93 | base_url = "https://db.ygoprodeck.com/api/v7/randomcard.php" 94 | data = await YuGiOhData.request(self.session, base_url) 95 | if isinstance(data, NotFound): 96 | return await ctx.send(str(data)) 97 | 98 | colour = await ctx.embed_colour() 99 | race_or_spell = "Race" if "Monster" in data.type else "Spell Type" 100 | page_meta = f"ID: {data.id} | Card Level: " 101 | footer = f"{page_meta}{data.level} | {race_or_spell}: {data.race}" 102 | embed = self.generate_embed(data, colour, footer) 103 | return await ctx.send(embed=embed) 104 | --------------------------------------------------------------------------------