diff options
author | 2018-07-31 19:59:58 +0000 | |
---|---|---|
committer | 2018-07-31 19:59:58 +0000 | |
commit | f3ae9840409ee942fdc494feff23d1e54e32e323 (patch) | |
tree | 74ea0054871c0d342bbfecf2d760adf3167b72a2 | |
parent | Merge branch 'better-modlogs' into 'master' (diff) | |
parent | Relay subreddit posts to the #reddit channel (diff) |
Merge branch 'feature/reddit-channel' into 'master'
Relay subreddit posts to the #reddit channel
See merge request python-discord/projects/bot!23
-rw-r--r-- | bot/__main__.py | 1 | ||||
-rw-r--r-- | bot/cogs/reddit.py | 291 | ||||
-rw-r--r-- | bot/constants.py | 8 | ||||
-rw-r--r-- | bot/converters.py | 26 | ||||
-rw-r--r-- | config-default.yml | 7 |
5 files changed, 333 insertions, 0 deletions
diff --git a/bot/__main__.py b/bot/__main__.py index c5c8b8909..a2c10476c 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -64,6 +64,7 @@ bot.load_extension("bot.cogs.hiphopify") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.moderation") bot.load_extension("bot.cogs.off_topic_names") +bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.snakes") bot.load_extension("bot.cogs.snekbox") bot.load_extension("bot.cogs.tags") diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py new file mode 100644 index 000000000..952fa4682 --- /dev/null +++ b/bot/cogs/reddit.py @@ -0,0 +1,291 @@ +import asyncio +import logging +import random +import textwrap +from datetime import datetime, timedelta + +from discord import Colour, Embed, TextChannel +from discord.ext.commands import Bot, Context, group + +from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, Roles +from bot.converters import Subreddit +from bot.decorators import with_role +from bot.pagination import LinePaginator + +log = logging.getLogger(__name__) + + +class Reddit: + """ + Track subreddit posts and show detailed statistics about them. + """ + + HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"} + URL = "https://www.reddit.com" + + def __init__(self, bot: Bot): + self.bot = bot + + self.reddit_channel = None + + self.prev_lengths = {} + self.last_ids = {} + + async def fetch_posts(self, route: str, *, amount: int = 25, params=None): + """ + 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.") + + if params is None: + params = {} + + response = await self.bot.http_session.get( + url=f"{self.URL}/{route}.json", + headers=self.HEADERS, + params=params + ) + + content = await response.json() + posts = content["data"]["children"] + + return posts[:amount] + + async def send_top_posts(self, channel: TextChannel, subreddit: Subreddit, content=None, time="all"): + """ + Create an embed for the top posts, then send it in a given TextChannel. + """ + + # Create the new spicy embed. + embed = Embed() + embed.description = "" + + # Get the posts + posts = await self.fetch_posts( + route=f"{subreddit}/top", + amount=5, + params={ + "t": time + } + ) + + if not posts: + embed.title = random.choice(ERROR_REPLIES) + embed.colour = Colour.red() + embed.description = ( + "Sorry! We couldn't find any posts from that subreddit. " + "If this problem persists, please let us know." + ) + + return await channel.send( + embed=embed + ) + + for post in posts: + data = post["data"] + + text = data["selftext"] + if text: + text = textwrap.shorten(text, width=128, placeholder="...") + text += "\n" # Add newline to separate embed info + + ups = data["ups"] + comments = data["num_comments"] + author = data["author"] + + title = textwrap.shorten(data["title"], width=64, placeholder="...") + link = self.URL + data["permalink"] + + embed.description += ( + f"[**{title}**]({link})\n" + f"{text}" + f"| {ups} upvotes | {comments} comments | u/{author} | {subreddit} |\n\n" + ) + + embed.colour = Colour.blurple() + + return await channel.send( + content=content, + embed=embed + ) + + async def poll_new_posts(self): + """ + Periodically search for new subreddit posts. + """ + + while True: + await asyncio.sleep(RedditConfig.request_delay) + + for subreddit in RedditConfig.subreddits: + # Make a HEAD request to the subreddit + head_response = await self.bot.http_session.head( + url=f"{self.URL}/{subreddit}/new.rss", + headers=self.HEADERS + ) + + content_length = head_response.headers["content-length"] + + # If the content is the same size as before, assume there's no new posts. + if content_length == self.prev_lengths.get(subreddit, None): + continue + + self.prev_lengths[subreddit] = content_length + + # Now we can actually fetch the new data + posts = await self.fetch_posts(f"{subreddit}/new") + new_posts = [] + + # Only show new posts if we've checked before. + if subreddit in self.last_ids: + for post in posts: + data = post["data"] + + # Convert the ID to an integer for easy comparison. + int_id = int(data["id"], 36) + + # If we've already seen this post, finish checking + if int_id <= self.last_ids[subreddit]: + break + + embed_data = { + "title": textwrap.shorten(data["title"], width=64, placeholder="..."), + "text": textwrap.shorten(data["selftext"], width=128, placeholder="..."), + "url": self.URL + data["permalink"], + "author": data["author"] + } + + new_posts.append(embed_data) + + self.last_ids[subreddit] = int(posts[0]["data"]["id"], 36) + + # Send all of the new posts as spicy embeds + for data in new_posts: + embed = Embed() + + embed.title = data["title"] + embed.url = data["url"] + embed.description = data["text"] + embed.set_footer(text=f"Posted by u/{data['author']} in {subreddit}") + embed.colour = Colour.blurple() + + await self.reddit_channel.send(embed=embed) + + log.trace(f"Sent {len(new_posts)} new {subreddit} posts to channel {self.reddit_channel.id}.") + + async def poll_top_weekly_posts(self): + """ + Post a summary of the top posts every week. + """ + + while True: + now = datetime.utcnow() + + # Calculate the amount of seconds until midnight next monday. + monday = now + timedelta(days=7 - now.weekday()) + monday = monday.replace(hour=0, minute=0, second=0) + until_monday = (monday - now).total_seconds() + + await asyncio.sleep(until_monday) + + for subreddit in RedditConfig.subreddits: + # Send and pin the new weekly posts. + message = await self.send_top_posts( + channel=self.reddit_channel, + subreddit=subreddit, + content=f"This week's top {subreddit} posts have arrived!", + time="week" + ) + + if subreddit.lower() == "r/python": + # Remove the oldest pins so that only 5 remain at most. + pins = await self.reddit_channel.pins() + + while len(pins) >= 5: + await pins[-1].unpin() + del pins[-1] + + await message.pin() + + @group(name="reddit", invoke_without_command=True) + async def reddit_group(self, ctx: Context): + """ + View the top posts from various subreddits. + """ + + await ctx.invoke(self.bot.get_command("help"), "reddit") + + @reddit_group.command(name="top") + async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python"): + """ + Send the top posts of all time from a given subreddit. + """ + + await self.send_top_posts( + channel=ctx.channel, + subreddit=subreddit, + content=f"Here are the top {subreddit} posts of all time!", + time="all" + ) + + @reddit_group.command(name="daily") + async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python"): + """ + Send the top posts of today from a given subreddit. + """ + + await self.send_top_posts( + channel=ctx.channel, + subreddit=subreddit, + content=f"Here are today's top {subreddit} posts!", + time="day" + ) + + @reddit_group.command(name="weekly") + async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python"): + """ + Send the top posts of this week from a given subreddit. + """ + + await self.send_top_posts( + channel=ctx.channel, + subreddit=subreddit, + content=f"Here are this week's top {subreddit} posts!", + time="week" + ) + + @with_role(Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) + @reddit_group.command(name="subreddits", aliases=("subs",)) + async def subreddits_command(self, ctx: Context): + """ + 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 + ) + + async def on_ready(self): + self.reddit_channel = self.bot.get_channel(Channels.reddit) + + if self.reddit_channel is not None: + self.bot.loop.create_task(self.poll_new_posts()) + self.bot.loop.create_task(self.poll_top_weekly_posts()) + else: + log.warning("Couldn't locate a channel for subreddit relaying.") + + +def setup(bot): + bot.add_cog(Reddit(bot)) + log.info("Cog loaded: Reddit") diff --git a/bot/constants.py b/bot/constants.py index 756c03027..5e1dc1e39 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -321,6 +321,7 @@ class Channels(metaclass=YAMLGetter): off_topic_2: int off_topic_3: int python: int + reddit: int verification: int @@ -407,6 +408,13 @@ class URLs(metaclass=YAMLGetter): paste_service: str +class Reddit(metaclass=YAMLGetter): + section = "reddit" + + request_delay: int + subreddits: list + + class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' diff --git a/bot/converters.py b/bot/converters.py index f18b2f6c7..3def4b07a 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -172,3 +172,29 @@ class InfractionSearchQuery(Converter): except Exception: return arg return user or arg + + +class Subreddit(Converter): + """ + Forces a string to begin with "r/" and checks if it's a valid subreddit. + """ + + @staticmethod + async def convert(ctx, sub: str): + 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 diff --git a/config-default.yml b/config-default.yml index 415c1fcdf..fd0e33633 100644 --- a/config-default.yml +++ b/config-default.yml @@ -96,6 +96,7 @@ guild: off_topic_1: 463035241142026251 off_topic_2: 463035268514185226 python: 267624335836053506 + reddit: 458224812528238616 staff_lounge: &STAFF_LOUNGE 464905259261755392 verification: 352442727016693763 @@ -291,3 +292,9 @@ anti_spam: role_mentions: interval: 10 max: 3 + + +reddit: + request_delay: 60 + subreddits: + - 'r/Python' |