From 3e73f97291e59d654e18e6cb4a761c90e0cc7629 Mon Sep 17 00:00:00 2001 From: Matthew Flegg Date: Tue, 26 Apr 2022 22:09:31 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=8C=9F=20removed=20twitch=20api=20+?= =?UTF-8?q?=20refactoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - removed twitch api - switched to toml for config files - simplified the client's start process --- .setup.toml | 14 +++ config.json | 6 - core/apis/__init__.py | 10 -- core/apis/twitch/__init__.py | 7 -- core/apis/twitch/decorators.py | 59 --------- core/apis/twitch/twitch_broadcast.py | 99 --------------- core/apis/twitch/twitch_client.py | 178 --------------------------- core/discord_client.py | 109 ++++++++-------- core/{apis => }/mongo/__init__.py | 0 core/{apis => }/mongo/collection.py | 0 ext/commands/info.py | 7 +- ext/commands/mod.py | 53 +++++--- main.py | 78 ++++++------ requirements.txt | 1 - 14 files changed, 144 insertions(+), 477 deletions(-) create mode 100644 .setup.toml delete mode 100644 config.json delete mode 100644 core/apis/__init__.py delete mode 100644 core/apis/twitch/__init__.py delete mode 100644 core/apis/twitch/decorators.py delete mode 100644 core/apis/twitch/twitch_broadcast.py delete mode 100644 core/apis/twitch/twitch_client.py rename core/{apis => }/mongo/__init__.py (100%) rename core/{apis => }/mongo/collection.py (100%) diff --git a/.setup.toml b/.setup.toml new file mode 100644 index 0000000..fb5fd9a --- /dev/null +++ b/.setup.toml @@ -0,0 +1,14 @@ +# This can be public if needed. +# +# Loads configuration variables that are used +# to load extensions and set colors, etc. +theme = "004AAD" +guilds = [ + 953054451999072276 +] + +status = "/" +extensions = [ + "ext.commands.mod", + "ext.commands.info" +] \ No newline at end of file diff --git a/config.json b/config.json deleted file mode 100644 index 4b6356e..0000000 --- a/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extension_filepaths": [ - "ext.commands.mod", - "ext.commands.info" - ] -} \ No newline at end of file diff --git a/core/apis/__init__.py b/core/apis/__init__.py deleted file mode 100644 index 1f22d77..0000000 --- a/core/apis/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Module `apis` contains classes that are used to interact with -external applications more easily. This includes a Twitch API -wrapper, and a MongoDB document API. - -This module is separate from module `source` because `apis` is -not dependent on any functionality from discord.py. -""" -from .mongo import Collection -from .twitch import TwitchClient, TwitchBroadcast \ No newline at end of file diff --git a/core/apis/twitch/__init__.py b/core/apis/twitch/__init__.py deleted file mode 100644 index 8e5a087..0000000 --- a/core/apis/twitch/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Module `apis.twitch` contains classes and functions used used to -interact with the Twitch API. -""" -from .decorators import * -from .twitch_broadcast import TwitchBroadcast -from .twitch_client import TwitchClient \ No newline at end of file diff --git a/core/apis/twitch/decorators.py b/core/apis/twitch/decorators.py deleted file mode 100644 index c67ca8a..0000000 --- a/core/apis/twitch/decorators.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -Module `apis.twitch.decorators` contains wrapper functions to be used on -methods in the `Twitch` class, that assist its functionality. -""" -import asyncio -import functools - -from typing import Coroutine, Callable - - -def session_check(coroutine) -> Coroutine: - """ - Decorator function for `Twitch` methods that ensures that - Twitch.require_session has been called before calling the method. - """ - async def __inner(self, *args, **kwargs) -> Coroutine: - """ - Internal wrapper function for the `Twitch` method - decorated by @session_check. - """ - await self.__require_session() - return await coroutine(self, *args, **kwargs) - - return __inner - - -def authorization_check(coroutine) -> Coroutine: - """ - Decorator function for `Twitch` methods that ensures that - Twitch.authorize has been called before calling the method. - """ - async def __inner(self, *args, **kwargs) -> Coroutine: - """ - Internal wrapper function for the `Twitch` method - decorated by @authorization_check. - """ - await self.__authorize() - return await coroutine(self, *args, **kwargs) - - return __inner - - -def executor(function) -> Callable: - """ - Decorator function for `Twitch` methods that ensures that - the method is called using loop.run_in_executor. - """ - def __inner(*args, **kwargs) -> Callable: - """ - Internal wrapper function for the `Twitch` method - decorated by @executor. - """ - loop = asyncio.get_event_loop() - partial = functools.partial(function, *args, **kwargs) - - return loop.run_in_executor(None, partial) - - return __inner - diff --git a/core/apis/twitch/twitch_broadcast.py b/core/apis/twitch/twitch_broadcast.py deleted file mode 100644 index 4079222..0000000 --- a/core/apis/twitch/twitch_broadcast.py +++ /dev/null @@ -1,99 +0,0 @@ -from __future__ import annotations - -""" -Module `aps.twitch.twitch_broadcast` includes class `TwitchBroadcast`, -which is used to store a Twitch API response in a way that makes it easier -to work with, by accessing data as an attribute of the class instead of -working with a Dictionary. -""" -import datetime - -from io import BytesIO -from dataclasses import dataclass, field - - -@dataclass(slots=True, repr=True, kw_only=True) -class TwitchBroadcast: - """ - Class `TwitchBroadcast` is a @dataclass used to store data - returned from a Twitch API response. This class should not be - instantiated directly with TwitchBroadcast.from_dictionary. - - Instead, call Twitch.create_twitch_broadcast. This will ensure - the class has been initialized correctly. - - Attributes: - - user_name (str): The name of the broadcaster. - - user_id (str): The ID of the broadcaster. - - thumbnail (str): The processed thumbnail with an overlay. - - game_name (str): The name of the game being streamed. - - title: (str): The title of the stream. - - start: (datetime): The stream's start time. - - url: (str): A URL link to the stream. - - viewer_count (int): The number of viewers the stream has. - - is_mature (bool): Whether the stream is 18+. - """ - user_name: str - user_id: int - thumbnail: BytesIO - game_name: str - game_image: str - title: str - start: datetime.datetime - url: str - viewer_count: int - is_mature: bool - - @classmethod - def from_dictionary(cls, response: dict, image: BytesIO) -> TwitchBroadcast: - """ - Creates an instance of `TwitchBroadcast` from a JSON document returned - from a Twitch API response. This method acts as a constructor for the - class, and is used as such: TwitchBroadcast.from_dictionary. - - Params: - - response (dict): The JSON document the Twitch API returned. - - image (BytesIO): The processed image to be used in a Discord embed. - - Returns: - - A `TwitchBroadcast` instance. - """ - response = response["data"][0] - arguments = {} - - # For all of the items in the response that are also attributes of this - # class, simply assign the attributes the values of the items. - for key, value in vars(cls).iteritems(): - if key in response: - arguments[key] = value - - # Some attributes need to be processed first, so do that here. - arguments["thumbnail"] = cls.__generate_thumbnail(response["game_id"]) - arguments["stream_link"] = cls.__generate_stream_url(response["user_name"]) - arguments["stream_start"] = cls.__generate_stream_start(response["started_at"]) - - return cls(**arguments) - - @staticmethod - def __generate_stream_url(user_name: str) -> str: - """ - Internal method that returns a URL that users can use to watch the - stream described by an instance of this class. - """ - return "https://www.twitch.tv/" + user_name - - @staticmethod - def __generate_stream_start(started_at: str) -> datetime: - """ - Internal method that processes the start time for the stream returned - by the Twitch API in ISO format. Returns a datetime object. - """ - return datetime.datetime.fromisoformat(started_at[:-1]) - - @staticmethod - def __generate_thumbnail(game_id: str) -> str: - """ - Internal method that returns a URL where the game image is hosted. We - can use this image to generate an image in a Discord message or embed. - """ - return "https://static-cdn.jtvnw.net/ttv-boxart/" + game_id + "-285x380.jpg" \ No newline at end of file diff --git a/core/apis/twitch/twitch_client.py b/core/apis/twitch/twitch_client.py deleted file mode 100644 index 697b7db..0000000 --- a/core/apis/twitch/twitch_client.py +++ /dev/null @@ -1,178 +0,0 @@ -from __future__ import annotations - -""" -Module `apis.twitch.twitch_client` contains the `TwitchClient` class, which is -used to interact with the Twitch API to fetch and process data from it. -""" -import aiohttp -import datetime - -from core.apis import twitch -from io import BytesIO -from PIL import Image -from typing import ( - Any, - Optional, - Final -) - - -class TwitchClient: - """ - Class `Twitch` is a wrapper for the Twitch API. It provides methods that - can connect to a Twitch API endpoint and return API responses as an object. - """ - AUTH_URL: Final = "https://id.twitch.tv/" - BASE_URL: Final = "https://api.twitch.tv/" - - def __init__( - self, - client_id: str, - client_secret: str, - *, - session: Optional[aiohttp.ClientSession]=None, - ) -> None: - """ - Creates an instance of `Twitch` that can be used to interact with - the Twitch API. Connects to a Twitch API endpoint and returns data - about streams using a TwitchBroadcast instance. - - Params: - - client_id (str): The Twitch API application's ID. - - client_secret (str): The Twitch API application's secret. - - session (aiohttp.ClientSession): The client session to use (Optional). - - Returns: - - A `Twitch` instance. - """ - self.client_id = client_id - self.client_secret = client_secret - self.authorized: dict[str, Any] = None - self._session = session - - @twitch.decorators.session_check - @twitch.decorators.authorization_check - async def connect(self, endpoint: str, **params: dict) -> dict: - """ - Connects to a Twitch API endpoint that we can use to get data - about twitch broadcasts. - - Params: - - endpoint (str): The Twitch API endpoint to connect to. - - **params (dict): The parameters to pass to the HTTP get request. - - Returns: - - A JSON document containing the data returned from the HTTP request. - """ - url = self.BASE_URL + endpoint - authorize = " ".join(self.authorized.get("token")) - - header = { - "Authorization": authorize, - "Client-Id": self.client_id - } - - response = await self._session.get(url, headers=header, params=params) - json = await response.json() - - return json - - @twitch.decorators.session_check - async def get_broadcast(self, json: dict) -> twitch.TwitchBroadcast: - """ - Creates a `TwitchBroadcast` instance containing information returned - from a Twitch API response. See the documentation for `TwitchBroadcast` - for more information. - - Params: - - json (dict): The Twitch response to create a `TwitchBroadcast` instance from. - """ - url = json["data"][0]["thumbnail_url"].format(width=1890, height=1050) - - image = await self._session.get(url) - bytes = BytesIO(await image.read()) - image = await self.__process_image(bytes) - - return twitch.TwitchBroadcast.from_dictionary(json, image) - - @twitch.decorators.session_check - async def __aenter__(self) -> TwitchClient: - """ - Dunder method that defines what happens when an async with - statement using an instance of this class is entered. - """ - return self - - async def __aexit__(self, *excinfo) -> None: - """ - Dunder method that defines what happens when an async with - statement using an instance of this class is exited. - """ - await self._session.close() - - async def __require_session(self) -> None: - """ - Internal method that creates an aiohttp.ClientSession and assigns - it to self._session. This method is not called in this class, and - is instead called by @decorators.session_check. - """ - if not self._session: - self._session = aiohttp.ClientSession() - - @twitch.decorators.executor - def __process_image(self, image: BytesIO) -> BytesIO: - """ - Internal method that processes a Twitch stream's thumbnail by - pasting it on top of an overlay image and then centering it. - - Params: - - image (BytesIO): The image to paste on top of the overlay. - - Returns: - - A `BytesIO` object containing the image pasted onto the overlay. - """ - OVERLAY_FILEPATH: Final = "./assets/twitch_overlay.png" - - overlay = Image.open(OVERLAY_FILEPATH) - thumbnail = Image.open(image) - - buffer = BytesIO() - overlay.paste(thumbnail, (15, 10)) - overlay.save(buffer, "PNG") - - return buffer - - @twitch.decorators.session_check - async def __authorize(self) -> None: - """ - Internal method that uses the `TwitchClient` instance's API application - ID and secret to authenticate HTTP get requests. - """ - params = { - "client_id": self.client_id, - "client_secret": self.client_secret, - "grant_type": "client_credentials", - } - - url = self.AUTH_URL + "oauth2/token" - response = await self._session.post(url, params=params) - json = await response.json() - - await self.__organize_authorized(json) - - async def __organize_authorized(self, json: dict) -> None: - """ - Internal method that sets the value of self.authorized to a dictionary - containing the access token, token type, and the date/time when the - authorization expires. - """ - access_token = json["access_token"] - token_type = json["token_type"] - expires_in = json["expires_in"] - - authorization_information = { - "datetime": datetime.datetime.utcnow() + datetime.timedelta(seconds=expires_in), - "token": (token_type.title(), access_token), - } - - self.authorized = authorization_information \ No newline at end of file diff --git a/core/discord_client.py b/core/discord_client.py index 8aa6793..d7305a4 100644 --- a/core/discord_client.py +++ b/core/discord_client.py @@ -3,15 +3,16 @@ responsible for loading extensions, syncing application commands against the Discord API, and starting the bot. """ +from __future__ import annotations + import aiohttp import discord import itertools -import logging -import sys +import twitchio +import toml from core.utils.aliases import MongoClient from discord.ext import commands -from core.apis import TwitchClient from typing import ( List, Dict, @@ -25,13 +26,13 @@ class DiscordClient(commands.Bot): extensions, syncs application commands, and starts the bot. """ def __init__( - self, - possible_statuses: itertools.cycle, - extension_filepaths: List[str], - testing_guild_ids: List[int], - twitch_client_id: str, - twitch_client_secret: str, - mongo_connection_url: str, + self, + status: str, + theme: int, + extension_filepaths: List[str], + test_guild_ids: List[int], + mongo_client: MongoClient, + twitch_client: twitchio.Client, **kwargs: Dict[str, Any] ) -> None: """ @@ -39,12 +40,12 @@ def __init__( Discord, and is responsible for loading & starting the bot, and syncing slash commands. - Params: + Params:return - possible_statuses (itertools.Cycle): A list of statuses the bot will cycle through. - extension_filepaths (list[str]): The filepaths of extensions the bot will use. - testing_guild_ids (list[int]): The IDs of testing guilds the bot is in. - mongo_connection_url (str): The URL used to connect to a MongoDB database. - - twitch_client_id (str): The Twitch API application's ID. + - twitch_client_id (str): The Twitch API return application's ID. - twitch_client_secret (str): The Twitch API application's secret. Returns: @@ -52,33 +53,37 @@ def __init__( """ super().__init__(**kwargs) - self.possible_statuses = possible_statuses - self.session: aiohttp.ClientSession = None - - # Read-only attributes + self.theme = int(theme, 16) + self._status = status + self._session: aiohttp.ClientSession = None self._extension_filepaths = extension_filepaths - self._twitch_client: TwitchClient = None - self._mongo_client: MongoClient = None + self._test_guild_ids = test_guild_ids + self._twitch_client = mongo_client + self._mongo_client = mongo_client - # Strictly private attributes - self.__testing_guild_ids = testing_guild_ids - self.__twitch_client_id = twitch_client_id - self.__twitch_client_secret = twitch_client_secret - self.__mongo_connection_url = mongo_connection_url - - logging.basicConfig( - handlers=[logging.StreamHandler(sys.stdout)], - format="%(levelname)s %(asctime)s - %(message)s", - level=logging.INFO - ) + @property + def session(self) -> aiohttp.ClientSession: + """ + Returns the value of self._session. This stops the value being set + from outside the `Client` class. + """ + return self._session @property - def extension_filepaths(self) -> list[str]: + def extension_filepaths(self) -> List[str]: """ Returns the value of self._extension_filepaths. This stops the value being set from outside the `Client` class. """ return self._extension_filepaths + + @property + def test_guild_ids(self) -> List[int]: + """ + Returns the value of self._twitch_client. This stops the value + being set from outside the `Client` class. + """ + return self._test_guild_ids @property def mongo_client(self) -> MongoClient: @@ -93,25 +98,35 @@ def mongo_client(self) -> MongoClient: return self._mongo_client @property - def twitch_client(self) -> TwitchClient: + def twitch_client(self) -> twitchio.Client: """ Returns the value of self._twitch_client. This stops the value being set from outside the `Client` class. """ return self._twitch_client - async def start(self, *args, **kwargs) -> None: + async def start_pipeline(self, token: str) -> None: + """ + Calls `.__load()`, `.__sync()`, and `.__start()`. Starts the pipeline of processes + that initialise, sync, and log in to the bot. + + Params: + - token (str): The Discord token used to log into a bot account. + """ + async with self: + await self.__load() + self.loop.create_task(self.__sync()) + await self.__start(token) + + async def __start(self, *args, **kwargs) -> None: """ Overrides commands.Bot.start to create a re-usable aiohttp.ClientSession, and to create TwitchClient and MongoClient instances. Use this method to start the bot. """ - async with aiohttp.ClientSession() as self.__session: - self._twitch_client = self.__create_twitch_client() - self._mongo_client = self.__create_mongo_client() - - return await super().start(*args, **kwargs) + async with aiohttp.ClientSession() as self._session: + await super().start(*args, **kwargs) - async def sync(self) -> None: + async def __sync(self) -> None: """ Synchronizes application commands the bot uses with Discord. For any server/guild with an ID in `self.__testing_guild_ids`, slash commands will be synced instantly. @@ -119,28 +134,16 @@ async def sync(self) -> None: """ await self.wait_until_ready() - for test_guild_id in self.__testing_guild_ids: + for test_guild_id in self._test_guild_ids: await self.tree.sync(guild=discord.Object(id=test_guild_id)) await self.tree.sync() - async def load(self) -> None: + async def __load(self) -> None: """ Initializes the cogs, handlers, and slash cogs that the bot will use. Any files that are extensions must have a class that inherits from commands.Cog, which must be instantiated in a `setup` function. """ for module in self._extension_filepaths: - await self.load_extension(module) - - async def __create_twitch_client(self) -> TwitchClient: - """ - Internal method that returns an instance of TwitchClient. - """ - return TwitchClient(self.__twitch_client_id, self.__twitch_client_secret) - - async def __create_mongo_client(self) -> MongoClient: - """ - Internal method that returns an instance of AsyncIOMotorClient. - """ - return MongoClient(self.__mongo_connection_url) \ No newline at end of file + await self.load_extension(module) \ No newline at end of file diff --git a/core/apis/mongo/__init__.py b/core/mongo/__init__.py similarity index 100% rename from core/apis/mongo/__init__.py rename to core/mongo/__init__.py diff --git a/core/apis/mongo/collection.py b/core/mongo/collection.py similarity index 100% rename from core/apis/mongo/collection.py rename to core/mongo/collection.py diff --git a/ext/commands/info.py b/ext/commands/info.py index fdc8d2c..0a5eea7 100644 --- a/ext/commands/info.py +++ b/ext/commands/info.py @@ -11,7 +11,7 @@ from typing import Optional -class Info(commands.Cog, app_commands.Group, name="info"): +class Info(commands.Cog, name="Information"): """ 💡 Provides infomation about servers, members, and more. """ @@ -35,6 +35,7 @@ async def joined(self, interaction: discord.Interaction, *, member: Optional[dis title="💡 Join Date", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .add_field(name="⏳ Time", value=f"{time}.") \ .add_field(name="📅 Date", value=f"{date}.") \ @@ -55,6 +56,7 @@ async def toprole(self, interaction: discord.Interaction, *, member: Optional[di title="💡 Top Role", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .add_field(name="🏷️ Role", value=f"*@{member.top_role.name}*") \ .set_author(icon_url=member.avatar.url or None, name=member.name) @@ -74,6 +76,7 @@ async def perms(self, interaction: discord.Interaction, *, member: Optional[disc title="💡 Permissions", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .set_author(icon_url=member.avatar.url or None, name=member.name) \ .add_field( @@ -96,6 +99,7 @@ async def avatar(self, interaction: discord.Interaction, *, member: Optional[dis title="💡 Avatar", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .set_image(url=member.avatar.url or None) @@ -110,6 +114,7 @@ async def servericon(self, interaction: discord.Interaction) -> None: title="💡 Server Icon", description=f"**`@{interaction.guild.name}`**.", timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .set_image(url=interaction.guild.icon.url) diff --git a/ext/commands/mod.py b/ext/commands/mod.py index 3ff1163..b5dc360 100644 --- a/ext/commands/mod.py +++ b/ext/commands/mod.py @@ -26,10 +26,13 @@ class TimeUnit(enum.Enum): Days = 4 -class Mod(commands.Cog, app_commands.Group, name="mod"): +class Mod(commands.Cog, name="Moderation"): """ ⚙️ Lets server admins & mods manage their server. """ + mute = app_commands.Group(name="mute", description="❓ Mutes a member of a server.") + ban = app_commands.Group(name="ban", description="❓ Bans a member of a server.") + def __init__(self, client: core.DiscordClient) -> None: self.client = client super().__init__() @@ -50,12 +53,13 @@ async def purge(self, interaction: discord.Interaction, *, amount: int=6) -> Non embed = discord.Embed( title="⚙️ Messages Purged", description=f"💡 Number of Messages: **{amount}**.", - timestamp=datetime.datetime.utcnow() - ) + timestamp=datetime.datetime.utcnow(), + color=self.client.theme, + ) await interaction.response.send_message(embed=embed) - @app_commands.command() + @mute.command() @app_commands.describe( member="❓ The member to temporarily mute.", timeunit="❓ Whether you want to mute them for a certain number of seconds, minutes, hours, or days.", @@ -64,7 +68,7 @@ async def purge(self, interaction: discord.Interaction, *, amount: int=6) -> Non ) @app_commands.checks.has_permissions(manage_messages=True) @app_commands.checks.cooldown(1, 5, key=lambda interaction: interaction.guild.id) - async def tempmute( + async def temporarily( self, interaction: discord.Interaction, *, @@ -96,7 +100,8 @@ async def tempmute( muted_embed = discord.Embed( title="⚙️ Member Muted", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", - timestamp=datetime.datetime.utcnow() + timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) \ .add_field(name="⏳ Time", value=f"**{amount}** {str(timeunit.name)}.") \ @@ -118,20 +123,21 @@ async def tempmute( unmuted_embed = discord.Embed( title="⚙️ Member Unmuted", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", - timestamp=datetime.datetime.utcnow() + timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) await interaction.followup.send(embed=unmuted_embed) - @app_commands.command() + @mute.command() @app_commands.describe( member="❓ The member to mute.", reason="❓ The reason for muting the member." ) @app_commands.checks.has_permissions(manage_messages=True) @app_commands.checks.cooldown(1, 5, key=lambda interaction: interaction.guild.id) - async def mute( + async def permanently( self, interaction: discord.Interaction, *, @@ -159,7 +165,8 @@ async def mute( muted_embed = discord.Embed( title="⚙️ Member Muted", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", - timestamp=datetime.datetime.utcnow() + timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) \ .add_field(name="🖊️ Reason", value=reason or "...") @@ -181,7 +188,8 @@ async def unmute(self, interaction: discord.Interaction, *, member: discord.Memb unmuted_embed = discord.Embed( title="⚙️ Member Unmuted", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", - timestamp=datetime.datetime.utcnow() + timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) @@ -209,7 +217,8 @@ async def kick( kicked_embed = discord.Embed( title="⚙️ Member Kicked", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", - timestamp=datetime.datetime.utcnow() + timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) \ .add_field(name="🖊️ Reason", value=reason or "...") \ @@ -217,7 +226,7 @@ async def kick( await interaction.response.send_message(embed=kicked_embed) await guild.kick(member) - @app_commands.command() + @ban.command() @app_commands.describe( member="❓ The member to temporarily ban.", timeunit="❓ Whether you want to ban them for a certain number of seconds, minutes, hours, or days.", @@ -226,7 +235,7 @@ async def kick( ) @app_commands.checks.has_permissions(ban_members=True) @app_commands.checks.cooldown(1, 5, key=lambda interaction: interaction.guild.id) - async def tempban( + async def temporarily( self, interaction: discord.Interaction, *, @@ -243,7 +252,8 @@ async def tempban( banned_embed = discord.Embed( title="⚙️ Member Banned", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", - timestamp=datetime.datetime.utcnow() + timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) \ .add_field(name="⏳ Time", value=f"**{amount}** {str(timeunit.name)}.") \ @@ -266,20 +276,21 @@ async def tempban( unbanned_embed = discord.Embed( title="⚙️ Member Unbanned", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", - timestamp=datetime.datetime.utcnow() + timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) await interaction.followup.send(embed=unbanned_embed) - @app_commands.command() + @ban.command() @app_commands.describe( member="❓ The member to ban.", reason="❓ The reason for banning the member." ) @app_commands.checks.has_permissions(ban_members=True) @app_commands.checks.cooldown(1, 5, key=lambda interaction: interaction.guild.id) - async def ban( + async def permanently( self, interaction: discord.Interaction, *, @@ -294,7 +305,8 @@ async def ban( banned_embed = discord.Embed( title="⚙️ Member Banned", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", - timestamp=datetime.datetime.utcnow() + timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) \ .add_field(name="🖊️ Reason", value=reason or "...") @@ -315,7 +327,8 @@ async def unban(self, interaction: discord.Interaction, *, member: discord.Membe unbanned_embed = discord.Embed( title="⚙️ Member Unbanned", description=f"**`@{member.name}`** | **`{member.discriminator}`**.", - timestamp=datetime.datetime.utcnow() + timestamp=datetime.datetime.utcnow(), + color=self.client.theme, ) \ .set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) diff --git a/main.py b/main.py index 6b58c8d..b3847e0 100644 --- a/main.py +++ b/main.py @@ -6,16 +6,18 @@ __version__ = "v2.0.0-alpha.1" import asyncio -import itertools -import json +import twitchio import os import dotenv import discord +import toml +from core.utils import aliases from core import DiscordClient +from typing import Dict -def main(): +async def main(): """ This is the main entry point of the application. This is where filepaths are loaded, environment variables and retrieved from @@ -23,47 +25,37 @@ def main(): """ dotenv.load_dotenv(".env") - with open("config.json", "r") as config: - extension_filepaths = json.load(config)["extension_filepaths"] - - testing_guild_ids = [953054451999072276] - twitch_client_id = os.getenv("TWITCH_CLIENT_ID") - twitch_client_secret = os.getenv("TWITCH_CLIENT_SECRET") - mongo_connection_url = os.getenv("MONGO_CONNECTION_URL") - - intents = discord.Intents.all() - status = itertools.cycle(["/"]) - - client = DiscordClient( + with open(".setup.toml", "r") as setup, open(".secrets.toml", "r") as secrets: + theme, \ + guilds, \ + status, \ + extensions = toml.load(setup, _dict=dict).values() + + token, \ + twitch_id, \ + twitch_secret,\ + mongo_url = toml.load(secrets, _dict=dict).values() + + mongo_client = aliases.MongoClient(mongo_url) + twitch_client = twitchio.Client.from_client_credentials(twitch_id, twitch_secret) + + kwargs = { + "command_prefix": '~', + "help_command": None, + "intents": discord.Intents.all(), + } + + await DiscordClient( status, - extension_filepaths, - testing_guild_ids, - mongo_connection_url, - twitch_client_id, - twitch_client_secret, - command_prefix='~', - help_command=None, - intents=intents - ) - - asyncio.run(start_application(client)) - - -async def start_application(client: DiscordClient): - """ - This function starts the application by calling the relevant - methods of a `Client` instance. - - Params: - - client (Client): The Discord client to start. - """ - async with client: - await client.load() - client.loop.create_task(client.sync()) - - TOKEN = os.getenv("TOKEN") - await client.start(TOKEN) + theme, + extensions, + guilds, + mongo_client, + twitch_client, + **kwargs + ) \ + .start_pipeline(token) if __name__ == "__main__": - main() \ No newline at end of file + asyncio.run(main()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 46a6315..42612f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,6 @@ GitPython==3.1.27 humanize==4.0.0 idna==3.3 import-expression==1.1.4 -jishaku==2.4.0a403+g8bc4ed4.master motor==2.5.1 multidict==6.0.2 mypy-extensions==0.4.3 From 3ccda2ca0cf6cb7ab83f2a036ed784c464204afc Mon Sep 17 00:00:00 2001 From: Matthew Flegg Date: Tue, 26 Apr 2022 23:10:17 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8C=9F=20added=20/twitch=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .setup.toml | 3 +- core/discord_client.py | 2 +- ext/commands/misc.py | 76 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 ext/commands/misc.py diff --git a/.setup.toml b/.setup.toml index fb5fd9a..e934072 100644 --- a/.setup.toml +++ b/.setup.toml @@ -10,5 +10,6 @@ guilds = [ status = "/" extensions = [ "ext.commands.mod", - "ext.commands.info" + "ext.commands.info", + "ext.commands.misc" ] \ No newline at end of file diff --git a/core/discord_client.py b/core/discord_client.py index d7305a4..e82b393 100644 --- a/core/discord_client.py +++ b/core/discord_client.py @@ -58,7 +58,7 @@ def __init__( self._session: aiohttp.ClientSession = None self._extension_filepaths = extension_filepaths self._test_guild_ids = test_guild_ids - self._twitch_client = mongo_client + self._twitch_client = twitch_client self._mongo_client = mongo_client @property diff --git a/ext/commands/misc.py b/ext/commands/misc.py new file mode 100644 index 0000000..73a969e --- /dev/null +++ b/ext/commands/misc.py @@ -0,0 +1,76 @@ +import datetime +import discord +import humanize +import twitchio +import core + +from ext import utils +from discord import app_commands +from discord.ext import commands + + +class Misc(commands.Cog, name="Miscellaneous"): + """ + 🎲 Contains miscellaneous commands. + """ + + def __init__(self, client: core.DiscordClient): + self.client = client + super().__init__() + + @app_commands.command() + @app_commands.describe(broadcaster="❓ The Twitch streamer's username.") + async def twitch(self, interaction: discord.Interaction, *, broadcaster: str) -> None: + """ + 🎲 Shows information about a Twitch stream. + """ + streams = await self.client.twitch_client.fetch_streams(user_logins=[broadcaster]) + stream: twitchio.Stream = streams[0] or None + + if not stream: + error_embed = utils.create_error_embed(f"The streamer **`{broadcaster}`** is not currently live.") + return await interaction.response.send_message(embed=error_embed) + + current_time = datetime.datetime.utcnow() + stream_time = humanize.precisedelta( + current_time-stream.started_at.replace(tzinfo=None), + format="%0.0f" + ) + + streamer = stream.user.name + stream_embed = discord.Embed( + title="🎲 Twitch Stream", + description=f"**[{stream.title}](https://www.twitch.tv/{streamer})**", + timestamp=current_time, + color=self.client.theme, + ) \ + .set_image(url=stream.thumbnail_url.format(width=1890, height=1050)) \ + .add_field(name="⏳ Stream Time", value=stream_time, inline=False) \ + .add_field(name="🖥️ Streamer Name", value=stream.user.name, inline=False) \ + .add_field(name="🚀 Viewer Count", value=stream.viewer_count, inline=False) \ + .add_field(name="❓ Category", value=stream.game_name, inline=False) + + await interaction.response.send_message(embed=stream_embed) + + + +async def setup(client: core.DiscordClient) -> None: + """ + Registers the command group/cog with the discord client. + All extensions must have a setup function. + + Params: + - client: (DiscordClient): The client to register the cog with. + """ + await client.add_cog(Misc(client)) + + +async def teardown(client: core.DiscordClient) -> None: + """ + De-registers the command group/cog with the discord client. + This is not usually needed, but is useful to have. + + Params: + - client: (DiscordClient): The client to de-register the cog with. + """ + await client.remove_cog(Misc(client)) \ No newline at end of file