From d764720481136f786593a67f152ac876ef7b151d Mon Sep 17 00:00:00 2001 From: Rohan Date: Wed, 2 Dec 2020 21:52:10 +0530 Subject: Add Reddit class and emojis to constants file. --- bot/constants.py | 18 ++++++ bot/exts/evergreen/reddit.py | 128 ------------------------------------------- 2 files changed, 18 insertions(+), 128 deletions(-) delete mode 100644 bot/exts/evergreen/reddit.py (limited to 'bot') diff --git a/bot/constants.py b/bot/constants.py index 6999f321..d1ffd30f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -17,6 +17,7 @@ __all__ = ( "Roles", "Tokens", "Wolfram", + "Reddit", "RedisConfig", "MODERATION_ROLES", "STAFF_ROLES", @@ -144,6 +145,15 @@ class Emojis: status_dnd = "<:status_dnd:470326272082313216>" status_offline = "<:status_offline:470326266537705472>" + # Reddit emojis + reddit = "<:reddit:676030265734332427>" + reddit_post_text = "<:reddit_post_text:676030265910493204>" + reddit_post_video = "<:reddit_post_video:676030265839190047>" + reddit_post_photo = "<:reddit_post_photo:676030265734201344>" + reddit_upvote = "<:reddit_upvote:755845219890757644>" + reddit_comments = "<:reddit_comments:755845255001014384>" + reddit_users = "<:reddit_users:755845303822974997>" + class Icons: questionmark = "https://cdn.discordapp.com/emojis/512367613339369475.png" @@ -231,6 +241,14 @@ class Source: github_avatar_url = "https://avatars1.githubusercontent.com/u/9919" +class Reddit: + subreddits = ["r/Python"] + + client_id = environ.get("REDDIT_CLIENT_ID") + secret = environ.get("REDDIT_SECRET") + webhook = int(environ.get("REDDIT_WEBHOOK", 635408384794951680)) + + # Default role combinations MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py deleted file mode 100644 index 49127bea..00000000 --- a/bot/exts/evergreen/reddit.py +++ /dev/null @@ -1,128 +0,0 @@ -import logging -import random - -import discord -from discord.ext import commands -from discord.ext.commands.cooldowns import BucketType - -from bot.utils.pagination import ImagePaginator - -log = logging.getLogger(__name__) - - -class Reddit(commands.Cog): - """Fetches reddit posts.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - async def fetch(self, url: str) -> dict: - """Send a get request to the reddit API and get json response.""" - session = self.bot.http_session - params = { - 'limit': 50 - } - headers = { - 'User-Agent': 'Iceman' - } - - async with session.get(url=url, params=params, headers=headers) as response: - return await response.json() - - @commands.command(name='reddit') - @commands.cooldown(1, 10, BucketType.user) - async def get_reddit(self, ctx: commands.Context, subreddit: str = 'python', sort: str = "hot") -> None: - """ - Fetch reddit posts by using this command. - - Gets a post from r/python by default. - Usage: - --> .reddit [subreddit_name] [hot/top/new] - """ - pages = [] - sort_list = ["hot", "new", "top", "rising"] - if sort.lower() not in sort_list: - await ctx.send(f"Invalid sorting: {sort}\nUsing default sorting: `Hot`") - sort = "hot" - - data = await self.fetch(f'https://www.reddit.com/r/{subreddit}/{sort}/.json') - - try: - posts = data["data"]["children"] - except KeyError: - return await ctx.send('Subreddit not found!') - if not posts: - return await ctx.send('No posts available!') - - if posts[1]["data"]["over_18"] is True: - return await ctx.send( - "You cannot access this Subreddit as it is ment for those who " - "are 18 years or older." - ) - - embed_titles = "" - - # Chooses k unique random elements from a population sequence or set. - random_posts = random.sample(posts, k=5) - - # ----------------------------------------------------------- - # This code below is bound of change when the emojis are added. - - upvote_emoji = self.bot.get_emoji(755845219890757644) - comment_emoji = self.bot.get_emoji(755845255001014384) - user_emoji = self.bot.get_emoji(755845303822974997) - text_emoji = self.bot.get_emoji(676030265910493204) - video_emoji = self.bot.get_emoji(676030265839190047) - image_emoji = self.bot.get_emoji(676030265734201344) - reddit_emoji = self.bot.get_emoji(676030265734332427) - - # ------------------------------------------------------------ - - for i, post in enumerate(random_posts, start=1): - post_title = post["data"]["title"][0:50] - post_url = post['data']['url'] - if post_title == "": - post_title = "No Title." - elif post_title == post_url: - post_title = "Title is itself a link." - - # ------------------------------------------------------------------ - # Embed building. - - embed_titles += f"**{i}.[{post_title}]({post_url})**\n" - image_url = " " - post_stats = f"{text_emoji}" # Set default content type to text. - - if post["data"]["is_video"] is True or "youtube" in post_url.split("."): - # This means the content type in the post is a video. - post_stats = f"{video_emoji} " - - elif post_url.endswith("jpg") or post_url.endswith("png") or post_url.endswith("gif"): - # This means the content type in the post is an image. - post_stats = f"{image_emoji} " - image_url = post_url - - votes = f'{upvote_emoji}{post["data"]["ups"]}' - comments = f'{comment_emoji}\u2002{ post["data"]["num_comments"]}' - post_stats += ( - f"\u2002{votes}\u2003" - f"{comments}" - f'\u2003{user_emoji}\u2002{post["data"]["author"]}\n' - ) - embed_titles += f"{post_stats}\n" - page_text = f"**[{post_title}]({post_url})**\n{post_stats}\n{post['data']['selftext'][0:200]}" - - embed = discord.Embed() - page_tuple = (page_text, image_url) - pages.append(page_tuple) - - # ------------------------------------------------------------------ - - pages.insert(0, (embed_titles, " ")) - embed.set_author(name=f"r/{posts[0]['data']['subreddit']} - {sort}", icon_url=reddit_emoji.url) - await ImagePaginator.paginate(pages, ctx, embed) - - -def setup(bot: commands.Bot) -> None: - """Load the Cog.""" - bot.add_cog(Reddit(bot)) -- cgit v1.2.3 From 04f122348eca25dbbf44afdcc6fcd417aa98bf89 Mon Sep 17 00:00:00 2001 From: Rohan Date: Wed, 2 Dec 2020 21:53:29 +0530 Subject: Add Subreddit converter. --- bot/utils/converters.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/utils/converters.py b/bot/utils/converters.py index 228714c9..27804170 100644 --- a/bot/utils/converters.py +++ b/bot/utils/converters.py @@ -1,5 +1,6 @@ import discord -from discord.ext.commands.converter import MessageConverter +from discord.ext.commands import BadArgument, Context +from discord.ext.commands.converter import Converter, MessageConverter class WrappedMessageConverter(MessageConverter): @@ -14,3 +15,32 @@ class WrappedMessageConverter(MessageConverter): argument = argument[1:-1] return await super().convert(ctx, argument) + + +class Subreddit(Converter): + """Forces a string to begin with "r/" and checks if it's a valid subreddit.""" + + @staticmethod + async def convert(ctx: Context, sub: str) -> str: + """ + Force sub to begin with "r/" and check if it's a valid subreddit. + + If sub is a valid subreddit, return it prepended with "r/" + """ + sub = sub.lower() + + if not sub.startswith("r/"): + sub = f"r/{sub}" + + resp = await ctx.bot.http_session.get( + "https://www.reddit.com/subreddits/search.json", + params={"q": sub} + ) + + json = await resp.json() + if not json["data"]["children"]: + raise BadArgument( + f"The subreddit `{sub}` either doesn't exist, or it has no posts." + ) + + return sub -- cgit v1.2.3 From f4e550bc1db9725577459f9a46d02da038e39c32 Mon Sep 17 00:00:00 2001 From: Rohan Date: Wed, 2 Dec 2020 21:54:14 +0530 Subject: Add sub_clyde utility function. --- bot/utils/messages.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 bot/utils/messages.py (limited to 'bot') diff --git a/bot/utils/messages.py b/bot/utils/messages.py new file mode 100644 index 00000000..a6c035f9 --- /dev/null +++ b/bot/utils/messages.py @@ -0,0 +1,19 @@ +import re +from typing import Optional + + +def sub_clyde(username: Optional[str]) -> Optional[str]: + """ + Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "е"/"E" and return the new string. + + Discord disallows "clyde" anywhere in the username for webhooks. It will return a 400. + Return None only if `username` is None. + """ + def replace_e(match: re.Match) -> str: + char = "е" if match[2] == "e" else "Е" + return match[1] + char + + if username: + return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I) + else: + return username # Empty string or None -- cgit v1.2.3 From 6e32bda97aa91af1d100ea46f7efdf7031f87bff Mon Sep 17 00:00:00 2001 From: Rohan Date: Wed, 2 Dec 2020 21:54:20 +0530 Subject: Migrate reddit command from Bot repo and add pagination. --- bot/exts/evergreen/reddit.py | 360 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 bot/exts/evergreen/reddit.py (limited to 'bot') diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py new file mode 100644 index 00000000..fb447cda --- /dev/null +++ b/bot/exts/evergreen/reddit.py @@ -0,0 +1,360 @@ +import asyncio +import logging +import random +import textwrap +from collections import namedtuple +from datetime import datetime, timedelta +from typing import List, Union + +from aiohttp import BasicAuth, ClientError +from discord import Colour, Embed, TextChannel +from discord.ext.commands import Cog, Context, group, has_any_role +from discord.ext.tasks import loop +from discord.utils import escape_markdown, sleep_until + +from bot.bot import Bot +from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES +from bot.utils.converters import Subreddit +from bot.utils.messages import sub_clyde +from bot.utils.pagination import ImagePaginator, LinePaginator + +log = logging.getLogger(__name__) + +AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) + + +class Reddit(Cog): + """Track subreddit posts and show detailed statistics about them.""" + + HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} + URL = "https://www.reddit.com" + OAUTH_URL = "https://oauth.reddit.com" + MAX_RETRIES = 3 + + def __init__(self, bot: Bot): + self.bot = bot + + self.webhook = None + self.access_token = None + self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) + + bot.loop.create_task(self.init_reddit_ready()) + self.auto_poster_loop.start() + + def cog_unload(self) -> None: + """Stop the loop task and revoke the access token when the cog is unloaded.""" + self.auto_poster_loop.cancel() + if self.access_token and self.access_token.expires_at > datetime.utcnow(): + asyncio.create_task(self.revoke_access_token()) + + async def init_reddit_ready(self) -> None: + """Sets the reddit webhook when the cog is loaded.""" + await self.bot.wait_until_guild_available() + if not self.webhook: + self.webhook = await self.bot.fetch_webhook(RedditConfig.webhook) + + @property + def channel(self) -> TextChannel: + """Get the #reddit channel object from the bot's cache.""" + return self.bot.get_channel(Channels.reddit) + + def build_pagination_pages(self, posts: List[dict]) -> List[tuple]: + """Build embed pages required for Paginator.""" + pages = [] + first_page = "" + for i, post in enumerate(posts, start=1): + post_page = "" + image_url = "" + + data = post["data"] + + title = textwrap.shorten(data["title"], width=64, placeholder="...") + + # Normal brackets interfere with Markdown. + title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌") + link = self.URL + data["permalink"] + + first_page += f"**{i}. [{title.replace('*', '')}]({link})**\n" + post_page += f"**{i}. [{title}]({link})**\n\n" + + text = data["selftext"] + if text: + first_page += textwrap.shorten(text, width=128, placeholder="...").replace("*", "") + "\n" + post_page += textwrap.shorten(text, width=252, placeholder="...") + "\n\n" + + ups = data["ups"] + comments = data["num_comments"] + author = data["author"] + + content_type = Emojis.reddit_post_text + if data["is_video"] is True or "youtube" in data["url"].split("."): + # This means the content type in the post is a video. + content_type = f"{Emojis.reddit_post_video}" + + elif any(data["url"].endswith(pic_format) for pic_format in ("jpg", "png", "gif")): + # This means the content type in the post is an image. + content_type = f"{Emojis.reddit_post_photo}" + image_url = data["url"] + + first_page += ( + f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}" + f"\u2002{comments}\u2003{Emojis.reddit_users}{author}\n\n" + ) + post_page += ( + f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}\u2002" + f"{comments}\u2003{Emojis.reddit_users}{author}" + ) + + pages.append((post_page, image_url)) + + pages.insert(0, (first_page, "")) + return pages + + async def get_access_token(self) -> None: + """ + Get a Reddit API OAuth2 access token and assign it to self.access_token. + + A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog + will be unloaded and a ClientError raised if retrieval was still unsuccessful. + """ + for i in range(1, self.MAX_RETRIES + 1): + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/access_token", + headers=self.HEADERS, + auth=self.client_auth, + data={ + "grant_type": "client_credentials", + "duration": "temporary" + } + ) + + if response.status == 200 and response.content_type == "application/json": + content = await response.json() + expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. + self.access_token = AccessToken( + token=content["access_token"], + expires_at=datetime.utcnow() + timedelta(seconds=expiration) + ) + + log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") + return + else: + log.debug( + f"Failed to get an access token: " + f"status {response.status} & content type {response.content_type}; " + f"retrying ({i}/{self.MAX_RETRIES})" + ) + + await asyncio.sleep(3) + + self.bot.remove_cog(self.qualified_name) + raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") + + async def revoke_access_token(self) -> None: + """ + Revoke the OAuth2 access token for the Reddit API. + + For security reasons, it's good practice to revoke the token when it's no longer being used. + """ + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/revoke_token", + headers=self.HEADERS, + auth=self.client_auth, + data={ + "token": self.access_token.token, + "token_type_hint": "access_token" + } + ) + + if response.status == 204 and response.content_type == "application/json": + self.access_token = None + else: + log.warning(f"Unable to revoke access token: status {response.status}.") + + async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: + """A helper method to fetch a certain amount of Reddit posts at a given route.""" + # Reddit's JSON responses only provide 25 posts at most. + if not 25 >= amount > 0: + raise ValueError("Invalid amount of subreddit posts requested.") + + # Renew the token if necessary. + if not self.access_token or self.access_token.expires_at < datetime.utcnow(): + await self.get_access_token() + + url = f"{self.OAUTH_URL}/{route}" + for _ in range(self.MAX_RETRIES): + response = await self.bot.http_session.get( + url=url, + headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, + params=params + ) + if response.status == 200 and response.content_type == 'application/json': + # Got appropriate response - process and return. + content = await response.json() + posts = content["data"]["children"] + + filtered_posts = [post for post in posts if not post["data"]["over_18"]] + + return filtered_posts[:amount] + + await asyncio.sleep(3) + + log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") + return list() # Failed to get appropriate response within allowed number of retries. + + async def get_top_posts( + self, subreddit: Subreddit, time: str = "all", amount: int = 5, paginate: bool = False + ) -> Union[Embed, List[tuple]]: + """ + Get the top amount of posts for a given subreddit within a specified timeframe. + + A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top + weekly posts. + + The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. + """ + embed = Embed(description="") + + posts = await self.fetch_posts( + route=f"{subreddit}/top", + amount=amount, + params={"t": time} + ) + if not posts: + embed.title = random.choice(ERROR_REPLIES) + embed.colour = Colour.red() + embed.description = ( + "Sorry! We couldn't find any SFW posts from that subreddit. " + "If this problem persists, please let us know." + ) + + return embed + + pages = self.build_pagination_pages(posts) + + if paginate: + return pages + + embed.description += pages[0] + embed.colour = Colour.blurple() + return embed + + @loop() + async def auto_poster_loop(self) -> None: + """Post the top 5 posts daily, and the top 5 posts weekly.""" + # once d.py get support for `time` parameter in loop decorator, + # this can be removed and the loop can use the `time=datetime.time.min` parameter + now = datetime.utcnow() + tomorrow = now + timedelta(days=1) + midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) + + await sleep_until(midnight_tomorrow) + + await self.bot.wait_until_guild_available() + if not self.webhook: + await self.bot.fetch_webhook(RedditConfig.webhook) + + if datetime.utcnow().weekday() == 0: + await self.top_weekly_posts() + # if it's a monday send the top weekly posts + + for subreddit in RedditConfig.subreddits: + top_posts = await self.get_top_posts(subreddit=subreddit, time="day") + username = sub_clyde(f"{subreddit} Top Daily Posts") + message = await self.webhook.send(username=username, embed=top_posts, wait=True) + + if message.channel.is_news(): + await message.publish() + + async def top_weekly_posts(self) -> None: + """Post a summary of the top posts.""" + for subreddit in RedditConfig.subreddits: + # Send and pin the new weekly posts. + top_posts = await self.get_top_posts(subreddit=subreddit, time="week") + username = sub_clyde(f"{subreddit} Top Weekly Posts") + message = await self.webhook.send(wait=True, username=username, embed=top_posts) + + if subreddit.lower() == "r/python": + if not self.channel: + log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") + return + + # Remove the oldest pins so that only 12 remain at most. + pins = await self.channel.pins() + + while len(pins) >= 12: + await pins[-1].unpin() + del pins[-1] + + await message.pin() + + if message.channel.is_news(): + await message.publish() + + @group(name="reddit", invoke_without_command=True) + async def reddit_group(self, ctx: Context) -> None: + """View the top posts from various subreddits.""" + await ctx.send_help(ctx.command) + + @reddit_group.command(name="top") + async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of all time from a given subreddit.""" + async with ctx.typing(): + pages = await self.get_top_posts(subreddit=subreddit, time="all", paginate=True) + + embed = Embed( + title=f"{Emojis.reddit} {subreddit} - Top\n\n", + color=Colour.blurple() + ) + + await ImagePaginator.paginate(pages, ctx, embed) + + @reddit_group.command(name="daily") + async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of today from a given subreddit.""" + async with ctx.typing(): + pages = await self.get_top_posts(subreddit=subreddit, time="day", paginate=True) + + embed = Embed( + title=f"{Emojis.reddit} {subreddit} - Daily\n\n", + color=Colour.blurple() + ) + + await ImagePaginator.paginate(pages, ctx, embed) + + @reddit_group.command(name="weekly") + async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of this week from a given subreddit.""" + async with ctx.typing(): + pages = await self.get_top_posts(subreddit=subreddit, time="week", paginate=True) + + embed = Embed( + title=f"{Emojis.reddit} {subreddit} - Weekly\n\n", + color=Colour.blurple() + ) + + await ImagePaginator.paginate(pages, ctx, embed) + + @has_any_role(*STAFF_ROLES) + @reddit_group.command(name="subreddits", aliases=("subs",)) + async def subreddits_command(self, ctx: Context) -> None: + """Send a paginated embed of all the subreddits we're relaying.""" + embed = Embed() + embed.title = "Relayed subreddits." + embed.colour = Colour.blurple() + + await LinePaginator.paginate( + RedditConfig.subreddits, + ctx, embed, + footer_text="Use the reddit commands along with these to view their posts.", + empty=False, + max_lines=15 + ) + + +def setup(bot: Bot) -> None: + """Load the Reddit cog.""" + if not RedditConfig.secret or not RedditConfig.client_id: + log.error("Credentials not provided, cog not loaded.") + return + bot.add_cog(Reddit(bot)) -- cgit v1.2.3 From 94822085a9f3e1677a8c566796fd1655b0b40ebf Mon Sep 17 00:00:00 2001 From: Rohan Date: Wed, 9 Dec 2020 20:55:27 +0530 Subject: Changes to command output. --- bot/exts/evergreen/reddit.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py index fb447cda..ddc0cc27 100644 --- a/bot/exts/evergreen/reddit.py +++ b/bot/exts/evergreen/reddit.py @@ -62,24 +62,24 @@ class Reddit(Cog): """Build embed pages required for Paginator.""" pages = [] first_page = "" - for i, post in enumerate(posts, start=1): + for post in posts: post_page = "" image_url = "" data = post["data"] - title = textwrap.shorten(data["title"], width=64, placeholder="...") + title = textwrap.shorten(data["title"], width=50, placeholder="...") # Normal brackets interfere with Markdown. title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌") link = self.URL + data["permalink"] - first_page += f"**{i}. [{title.replace('*', '')}]({link})**\n" - post_page += f"**{i}. [{title}]({link})**\n\n" + first_page += f"**[{title.replace('*', '')}]({link})**\n" + post_page += f"**[{title}]({link})**\n\n" text = data["selftext"] if text: - first_page += textwrap.shorten(text, width=128, placeholder="...").replace("*", "") + "\n" + first_page += textwrap.shorten(text, width=100, placeholder="...").replace("*", "") + "\n" post_page += textwrap.shorten(text, width=252, placeholder="...") + "\n\n" ups = data["ups"] @@ -107,7 +107,7 @@ class Reddit(Cog): pages.append((post_page, image_url)) - pages.insert(0, (first_page, "")) + pages.insert(0, (first_page, "")) # Using image paginator, hence settings image url to empty string return pages async def get_access_token(self) -> None: @@ -235,6 +235,7 @@ class Reddit(Cog): if paginate: return pages + # Use only starting summary page for #reddit channel posts. embed.description += pages[0] embed.colour = Colour.blurple() return embed @@ -302,8 +303,8 @@ class Reddit(Cog): async with ctx.typing(): pages = await self.get_top_posts(subreddit=subreddit, time="all", paginate=True) + await ctx.send("Here are the top r/Python posts of all time!") embed = Embed( - title=f"{Emojis.reddit} {subreddit} - Top\n\n", color=Colour.blurple() ) @@ -315,8 +316,8 @@ class Reddit(Cog): async with ctx.typing(): pages = await self.get_top_posts(subreddit=subreddit, time="day", paginate=True) + await ctx.send("Here are today's top r/Python posts!") embed = Embed( - title=f"{Emojis.reddit} {subreddit} - Daily\n\n", color=Colour.blurple() ) @@ -328,8 +329,8 @@ class Reddit(Cog): async with ctx.typing(): pages = await self.get_top_posts(subreddit=subreddit, time="week", paginate=True) + await ctx.send("Here are this week's top r/Python posts!") embed = Embed( - title=f"{Emojis.reddit} {subreddit} - Weekly\n\n", color=Colour.blurple() ) -- cgit v1.2.3 From ac68262b8c3ec96f4476db7d4a00ebeb6b4149f8 Mon Sep 17 00:00:00 2001 From: Rohan Date: Tue, 29 Dec 2020 09:40:42 +0530 Subject: Fix bug in auto_poster_loop() regarding embed description. --- bot/exts/evergreen/reddit.py | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py index ddc0cc27..f5134105 100644 --- a/bot/exts/evergreen/reddit.py +++ b/bot/exts/evergreen/reddit.py @@ -58,7 +58,7 @@ class Reddit(Cog): """Get the #reddit channel object from the bot's cache.""" return self.bot.get_channel(Channels.reddit) - def build_pagination_pages(self, posts: List[dict]) -> List[tuple]: + def build_pagination_pages(self, posts: List[dict], paginate) -> Union[List[tuple], str]: """Build embed pages required for Paginator.""" pages = [] first_page = "" @@ -75,19 +75,17 @@ class Reddit(Cog): link = self.URL + data["permalink"] first_page += f"**[{title.replace('*', '')}]({link})**\n" - post_page += f"**[{title}]({link})**\n\n" text = data["selftext"] if text: first_page += textwrap.shorten(text, width=100, placeholder="...").replace("*", "") + "\n" - post_page += textwrap.shorten(text, width=252, placeholder="...") + "\n\n" ups = data["ups"] comments = data["num_comments"] author = data["author"] content_type = Emojis.reddit_post_text - if data["is_video"] is True or "youtube" in data["url"].split("."): + if data["is_video"] is True or {"youtube", "youtu.be"}.issubset(set(data["url"].split("."))): # This means the content type in the post is a video. content_type = f"{Emojis.reddit_post_video}" @@ -100,12 +98,21 @@ class Reddit(Cog): f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}" f"\u2002{comments}\u2003{Emojis.reddit_users}{author}\n\n" ) - post_page += ( - f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}\u2002" - f"{comments}\u2003{Emojis.reddit_users}{author}" - ) - pages.append((post_page, image_url)) + if paginate: + post_page += f"**[{title}]({link})**\n\n" + if text: + post_page += textwrap.shorten(text, width=252, placeholder="...") + "\n\n" + post_page += ( + f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}\u2002" + f"{comments}\u2003{Emojis.reddit_users}{author}" + ) + + pages.append((post_page, image_url)) + + if not paginate: + # Return the first summery page if pagination is not required + return first_page pages.insert(0, (first_page, "")) # Using image paginator, hence settings image url to empty string return pages @@ -213,7 +220,7 @@ class Reddit(Cog): The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. """ - embed = Embed(description="") + embed = Embed() posts = await self.fetch_posts( route=f"{subreddit}/top", @@ -230,13 +237,11 @@ class Reddit(Cog): return embed - pages = self.build_pagination_pages(posts) - if paginate: - return pages + return self.build_pagination_pages(posts, paginate=True) # Use only starting summary page for #reddit channel posts. - embed.description += pages[0] + embed.description = self.build_pagination_pages(posts, paginate=False) embed.colour = Colour.blurple() return embed @@ -303,7 +308,7 @@ class Reddit(Cog): async with ctx.typing(): pages = await self.get_top_posts(subreddit=subreddit, time="all", paginate=True) - await ctx.send("Here are the top r/Python posts of all time!") + await ctx.send(f"Here are the top {subreddit} posts of all time!") embed = Embed( color=Colour.blurple() ) @@ -316,7 +321,7 @@ class Reddit(Cog): async with ctx.typing(): pages = await self.get_top_posts(subreddit=subreddit, time="day", paginate=True) - await ctx.send("Here are today's top r/Python posts!") + await ctx.send(f"Here are today's top {subreddit} posts!") embed = Embed( color=Colour.blurple() ) @@ -329,7 +334,7 @@ class Reddit(Cog): async with ctx.typing(): pages = await self.get_top_posts(subreddit=subreddit, time="week", paginate=True) - await ctx.send("Here are this week's top r/Python posts!") + await ctx.send(f"Here are this week's top {subreddit} posts!") embed = Embed( color=Colour.blurple() ) -- cgit v1.2.3 From c9f0d26601f7d3bf01257fbff9384df76aa381f6 Mon Sep 17 00:00:00 2001 From: Rohan Date: Wed, 27 Jan 2021 13:02:06 +0530 Subject: Fix lint error: Missing type annotation for function arugment `paginate`. --- bot/exts/evergreen/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py index f5134105..1a4f9add 100644 --- a/bot/exts/evergreen/reddit.py +++ b/bot/exts/evergreen/reddit.py @@ -58,7 +58,7 @@ class Reddit(Cog): """Get the #reddit channel object from the bot's cache.""" return self.bot.get_channel(Channels.reddit) - def build_pagination_pages(self, posts: List[dict], paginate) -> Union[List[tuple], str]: + def build_pagination_pages(self, posts: List[dict], paginate: bool) -> Union[List[tuple], str]: """Build embed pages required for Paginator.""" pages = [] first_page = "" -- cgit v1.2.3 From b2ec5813ddc4e7abc38c4143d79a0165fa591cd7 Mon Sep 17 00:00:00 2001 From: Rohan Date: Thu, 15 Apr 2021 19:16:42 +0530 Subject: Use custom help command util for sending command help. --- bot/exts/evergreen/reddit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py index 1a4f9add..7f4ce6a0 100644 --- a/bot/exts/evergreen/reddit.py +++ b/bot/exts/evergreen/reddit.py @@ -15,6 +15,7 @@ from discord.utils import escape_markdown, sleep_until from bot.bot import Bot from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES from bot.utils.converters import Subreddit +from bot.utils.extensions import invoke_help_command from bot.utils.messages import sub_clyde from bot.utils.pagination import ImagePaginator, LinePaginator @@ -300,7 +301,7 @@ class Reddit(Cog): @group(name="reddit", invoke_without_command=True) async def reddit_group(self, ctx: Context) -> None: """View the top posts from various subreddits.""" - await ctx.send_help(ctx.command) + await invoke_help_command(ctx) @reddit_group.command(name="top") async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: -- cgit v1.2.3 From 1670e5a48efb50324096df859e673d30528380e4 Mon Sep 17 00:00:00 2001 From: Rohan Date: Thu, 15 Apr 2021 20:39:50 +0530 Subject: Add reddit channel ID to constants. --- bot/constants.py | 1 + 1 file changed, 1 insertion(+) (limited to 'bot') diff --git a/bot/constants.py b/bot/constants.py index dfb39364..f5baec35 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -111,6 +111,7 @@ class Channels(NamedTuple): voice_chat_0 = 412357430186344448 voice_chat_1 = 799647045886541885 staff_voice = 541638762007101470 + reddit = int(environ.get("CHANNEL_REDDIT", 458224812528238616)) class Categories(NamedTuple): -- cgit v1.2.3 From 541efecc44fffec87f7e9346619dcae0710e2a08 Mon Sep 17 00:00:00 2001 From: Rohan Date: Thu, 15 Apr 2021 20:42:14 +0530 Subject: Apply code review suggestions. --- bot/exts/evergreen/reddit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py index 7f4ce6a0..916563ac 100644 --- a/bot/exts/evergreen/reddit.py +++ b/bot/exts/evergreen/reddit.py @@ -86,11 +86,11 @@ class Reddit(Cog): author = data["author"] content_type = Emojis.reddit_post_text - if data["is_video"] is True or {"youtube", "youtu.be"}.issubset(set(data["url"].split("."))): + if data["is_video"] or {"youtube", "youtu.be"}.issubset(set(data["url"].split("."))): # This means the content type in the post is a video. content_type = f"{Emojis.reddit_post_video}" - elif any(data["url"].endswith(pic_format) for pic_format in ("jpg", "png", "gif")): + elif data["url"].endswith(("jpg", "png", "gif")): # This means the content type in the post is an image. content_type = f"{Emojis.reddit_post_photo}" image_url = data["url"] -- cgit v1.2.3 From c1658ed8a37532bcdcbc6567e35a4ecb7578b237 Mon Sep 17 00:00:00 2001 From: Rohan Date: Tue, 20 Apr 2021 13:42:20 +0530 Subject: Fix Bug:Paginated embed image gets carried to next page --- bot/utils/pagination.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'bot') diff --git a/bot/utils/pagination.py b/bot/utils/pagination.py index a4d0cc56..917275c0 100644 --- a/bot/utils/pagination.py +++ b/bot/utils/pagination.py @@ -4,6 +4,7 @@ from typing import Iterable, List, Optional, Tuple from discord import Embed, Member, Reaction from discord.abc import User +from discord.embeds import EmptyEmbed from discord.ext.commands import Context, Paginator from bot.constants import Emojis @@ -417,9 +418,8 @@ class ImagePaginator(Paginator): await message.edit(embed=embed) embed.description = paginator.pages[current_page] - image = paginator.images[current_page] - if image: - embed.set_image(url=image) + image = paginator.images[current_page] or EmptyEmbed + embed.set_image(url=image) embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") -- cgit v1.2.3 From 39350fb471d9dff954cbecceebf3113b28dad6d9 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 7 May 2021 17:34:25 +0100 Subject: Switch from fetch_member to fetch_user This is because discord.py would populate the user portion of the fetched member with cached information which would cause the avatar url not to be updated as we had intended with that function --- .../evergreen/avatar_modification/avatar_modify.py | 68 +++++++++++----------- 1 file changed, 35 insertions(+), 33 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index 2afc3b74..a84983e0 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -12,7 +12,7 @@ from aiohttp import client_exceptions from discord.ext import commands from discord.ext.commands.errors import BadArgument -from bot.constants import Client, Colours, Emojis +from bot.constants import Colours, Emojis from bot.exts.evergreen.avatar_modification._effects import PfpEffects from bot.utils.extensions import invoke_help_command from bot.utils.halloween import spookifications @@ -66,23 +66,25 @@ class AvatarModify(commands.Cog): def __init__(self, bot: commands.Bot) -> None: self.bot = bot - async def _fetch_member(self, member_id: int) -> t.Optional[discord.Member]: + async def _fetch_user(self, user_id: int) -> t.Optional[discord.Member]: """ - Fetches a member and handles errors. + Fetches a user and handles errors. - This helper funciton is required as the member cache doesn't always have the most up to date + This helper function is required as the member cache doesn't always have the most up to date profile picture. This can lead to errors if the image is delted from the Discord CDN. + fetch_member can't be used due to the avatar url being part of the user object, and + some weird caching that D.py does """ try: - member = await self.bot.get_guild(Client.guild).fetch_member(member_id) + user = await self.bot.fetch_user(user_id) except discord.errors.NotFound: - log.debug(f"Member {member_id} left the guild before we could get their pfp.") + log.debug(f"User {user_id} could not be found.") return None except discord.HTTPException: - log.exception(f"Exception while trying to retrieve member {member_id} from Discord.") + log.exception(f"Exception while trying to retrieve user {user_id} from Discord.") return None - return member + return user @commands.group(aliases=("avatar_mod", "pfp_mod", "avatarmod", "pfpmod")) async def avatar_modify(self, ctx: commands.Context) -> None: @@ -94,13 +96,13 @@ class AvatarModify(commands.Cog): async def eightbit_command(self, ctx: commands.Context) -> None: """Pixelates your avatar and changes the palette to an 8bit one.""" async with ctx.typing(): - member = await self._fetch_member(ctx.author.id) - if not member: - await ctx.send(f"{Emojis.cross_mark} Could not get member info.") + user = await self._fetch_user(ctx.author.id) + if not user: + await ctx.send(f"{Emojis.cross_mark} Could not get user info.") return - image_bytes = await member.avatar_url.read() - file_name = file_safe_name("eightbit_avatar", member.display_name) + image_bytes = await user.avatar_url.read() + file_name = file_safe_name("eightbit_avatar", ctx.author.display_name) file = await in_executor( PfpEffects.apply_effect, @@ -115,7 +117,7 @@ class AvatarModify(commands.Cog): ) embed.set_image(url=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {member.display_name}.", icon_url=member.avatar_url) + embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.avatar_url) await ctx.send(embed=embed, file=file) @@ -140,9 +142,9 @@ class AvatarModify(commands.Cog): return args[0] async with ctx.typing(): - member = await self._fetch_member(ctx.author.id) - if not member: - await ctx.send(f"{Emojis.cross_mark} Could not get member info.") + user = await self._fetch_user(ctx.author.id) + if not user: + await ctx.send(f"{Emojis.cross_mark} Could not get user info.") return egg = None @@ -155,8 +157,8 @@ class AvatarModify(commands.Cog): return ctx.send = send_message # Reassigns ctx.send - image_bytes = await member.avatar_url_as(size=256).read() - file_name = file_safe_name("easterified_avatar", member.display_name) + image_bytes = await user.avatar_url_as(size=256).read() + file_name = file_safe_name("easterified_avatar", ctx.author.display_name) file = await in_executor( PfpEffects.apply_effect, @@ -171,7 +173,7 @@ class AvatarModify(commands.Cog): description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" ) embed.set_image(url=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {member.display_name}.", icon_url=member.avatar_url) + embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.avatar_url) await ctx.send(file=file, embed=embed) @@ -226,11 +228,11 @@ class AvatarModify(commands.Cog): return async with ctx.typing(): - member = await self._fetch_member(ctx.author.id) - if not member: - await ctx.send(f"{Emojis.cross_mark} Could not get member info.") + user = await self._fetch_user(ctx.author.id) + if not user: + await ctx.send(f"{Emojis.cross_mark} Could not get user info.") return - image_bytes = await member.avatar_url_as(size=1024).read() + image_bytes = await user.avatar_url.read() await self.send_pride_image(ctx, image_bytes, pixels, flag, option) @prideavatar.command() @@ -286,13 +288,13 @@ class AvatarModify(commands.Cog): if member is None: member = ctx.author - member = await self._fetch_member(member.id) - if not member: - await ctx.send(f"{Emojis.cross_mark} Could not get member info.") + user = await self._fetch_user(member.id) + if not user: + await ctx.send(f"{Emojis.cross_mark} Could not get user info.") return async with ctx.typing(): - image_bytes = await member.avatar_url.read() + image_bytes = await user.avatar_url.read() file_name = file_safe_name("spooky_avatar", member.display_name) @@ -317,9 +319,9 @@ class AvatarModify(commands.Cog): async def mosaic_command(self, ctx: commands.Context, squares: int = 16) -> None: """Splits your avatar into x squares, randomizes them and stitches them back into a new image!""" async with ctx.typing(): - member = await self._fetch_member(ctx.author.id) - if not member: - await ctx.send(f"{Emojis.cross_mark} Could not get member info.") + user = await self._fetch_user(ctx.author.id) + if not user: + await ctx.send(f"{Emojis.cross_mark} Could not get user info.") return if not 1 <= squares <= MAX_SQUARES: @@ -332,7 +334,7 @@ class AvatarModify(commands.Cog): file_name = file_safe_name("mosaic_avatar", ctx.author.display_name) - img_bytes = await member.avatar_url.read() + img_bytes = await user.avatar_url.read() file = await in_executor( PfpEffects.mosaic_effect, @@ -358,7 +360,7 @@ class AvatarModify(commands.Cog): ) embed.set_image(url=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=user.avatar_url) await ctx.send(file=file, embed=embed) -- cgit v1.2.3 From 7725a022675c6bddad4354283fbad8e56a364eb6 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 7 May 2021 17:35:54 +0100 Subject: Download avatars as size 1024 to avoid very large avatars affecting infra --- bot/exts/evergreen/avatar_modification/avatar_modify.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index a84983e0..1eceaf03 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -101,7 +101,7 @@ class AvatarModify(commands.Cog): await ctx.send(f"{Emojis.cross_mark} Could not get user info.") return - image_bytes = await user.avatar_url.read() + image_bytes = await user.avatar_url_as(size=1024).read() file_name = file_safe_name("eightbit_avatar", ctx.author.display_name) file = await in_executor( @@ -232,7 +232,7 @@ class AvatarModify(commands.Cog): if not user: await ctx.send(f"{Emojis.cross_mark} Could not get user info.") return - image_bytes = await user.avatar_url.read() + image_bytes = await user.avatar_url_as(size=1024).read() await self.send_pride_image(ctx, image_bytes, pixels, flag, option) @prideavatar.command() @@ -294,7 +294,7 @@ class AvatarModify(commands.Cog): return async with ctx.typing(): - image_bytes = await user.avatar_url.read() + image_bytes = await user.avatar_url_as(size=1024).read() file_name = file_safe_name("spooky_avatar", member.display_name) @@ -334,7 +334,7 @@ class AvatarModify(commands.Cog): file_name = file_safe_name("mosaic_avatar", ctx.author.display_name) - img_bytes = await user.avatar_url.read() + img_bytes = await user.avatar_url_as(size=1024).read() file = await in_executor( PfpEffects.mosaic_effect, -- cgit v1.2.3 From 94a9550f3729d7c266c3437554b36d8b52fc84fb Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 7 May 2021 17:36:56 +0100 Subject: Add the number of squares that were used by .mosaic This is useful for when we round up the number of squares used, users can still see how many were actually used. --- bot/exts/evergreen/avatar_modification/avatar_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index 1eceaf03..b604b3e4 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -351,7 +351,7 @@ class AvatarModify(commands.Cog): description = "What a masterpiece. :star:" else: title = "Your mosaic avatar" - description = "Here is your avatar. I think it looks a bit *puzzling*" + description = f"Here is your avatar. I think it looks a bit *puzzling*\nMade with {squares} squares" embed = discord.Embed( title=title, -- cgit v1.2.3 From 28f0c10eaf8880995d4eccfe3df92e3b707d95d5 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 7 May 2021 17:43:56 +0100 Subject: Correct grammer in the new mosaic command output --- bot/exts/evergreen/avatar_modification/avatar_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index b604b3e4..9cc2e6b4 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -351,7 +351,7 @@ class AvatarModify(commands.Cog): description = "What a masterpiece. :star:" else: title = "Your mosaic avatar" - description = f"Here is your avatar. I think it looks a bit *puzzling*\nMade with {squares} squares" + description = f"Here is your avatar. I think it looks a bit *puzzling*\nMade with {squares} squares." embed = discord.Embed( title=title, -- cgit v1.2.3 From e929d6b07ee5676d6f73fe7fc9d86db8214f6d22 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Fri, 7 May 2021 17:51:40 +0100 Subject: Update return type hint to reflect new behaviour. Co-authored-by: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> --- bot/exts/evergreen/avatar_modification/avatar_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index 9cc2e6b4..693d15c7 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -66,7 +66,7 @@ class AvatarModify(commands.Cog): def __init__(self, bot: commands.Bot) -> None: self.bot = bot - async def _fetch_user(self, user_id: int) -> t.Optional[discord.Member]: + async def _fetch_user(self, user_id: int) -> t.Optional[discord.User]: """ Fetches a user and handles errors. -- cgit v1.2.3