diff --git a/.gitignore b/.gitignore index 15cbfe1..7686201 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .vscode/* log.log -.env \ No newline at end of file +.env +seen_posts.txt diff --git a/requirements.in b/requirements.in index 85de664..28b2b20c 100644 --- a/requirements.in +++ b/requirements.in @@ -1,2 +1,5 @@ python-dotenv discord.py +feedparser +beautifulsoup4 +types-beautifulsoup4 diff --git a/requirements.txt b/requirements.txt index af0553f..f3d3012 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,8 +14,12 @@ async-timeout==4.0.3 # via aiohttp attrs==24.1.0 # via aiohttp +beautifulsoup4==4.12.3 + # via -r requirements.in discord-py==2.4.0 # via -r requirements.in +feedparser==6.0.11 + # via -r requirements.in frozenlist==1.4.1 # via # aiohttp @@ -28,5 +32,13 @@ multidict==6.0.5 # yarl python-dotenv==1.0.1 # via -r requirements.in +sgmllib3k==1.0.0 + # via feedparser +soupsieve==2.5 + # via beautifulsoup4 +types-beautifulsoup4==4.12.0.20240511 + # via -r requirements.in +types-html5lib==1.1.11.20240806 + # via types-beautifulsoup4 yarl==1.9.4 # via aiohttp diff --git a/script/requirements.txt b/script/requirements.txt index 1cf361f..d68b71f 100644 --- a/script/requirements.txt +++ b/script/requirements.txt @@ -24,12 +24,16 @@ attrs==24.1.0 # via # -r script/../requirements.txt # aiohttp +beautifulsoup4==4.12.3 + # via -r script/../requirements.txt build==1.0.3 # via pip-tools click==8.1.7 # via pip-tools discord-py==2.4.0 # via -r script/../requirements.txt +feedparser==6.0.11 + # via -r script/../requirements.txt flake8==6.1.0 # via # -r script/requirements.in @@ -93,14 +97,28 @@ pyproject-hooks==1.0.0 # via build python-dotenv==1.0.1 # via -r script/../requirements.txt +sgmllib3k==1.0.0 + # via + # -r script/../requirements.txt + # feedparser six==1.16.0 # via flake8-tuple +soupsieve==2.5 + # via + # -r script/../requirements.txt + # beautifulsoup4 tomli==2.0.1 # via # build # mypy # pip-tools # pyproject-hooks +types-beautifulsoup4==4.12.0.20240511 + # via -r script/../requirements.txt +types-html5lib==1.1.11.20240806 + # via + # -r script/../requirements.txt + # types-beautifulsoup4 typing-extensions==4.7.1 # via mypy wheel==0.41.2 diff --git a/setup.cfg b/setup.cfg index d3f12e2..a6569d5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,3 +52,6 @@ strict_equality = True scripts_are_modules = True warn_unused_configs = True + +[mypy-feedparser.*] +ignore_missing_imports = True diff --git a/src/bot.py b/src/bot.py index 37d00a3..9f2f402 100644 --- a/src/bot.py +++ b/src/bot.py @@ -5,12 +5,16 @@ import discord from discord import app_commands +from discord.ext import tasks +from src.rss import check_posts from src.constants import ( SPECIAL_ROLE, VERIFIED_ROLE, CHANNEL_PREFIX, VOLUNTEER_ROLE, + FEED_CHANNEL_NAME, + FEED_CHECK_INTERVAL, ANNOUNCE_CHANNEL_NAME, WELCOME_CATEGORY_NAME, PASSWORDS_CHANNEL_NAME, @@ -35,6 +39,7 @@ class BotClient(discord.Client): welcome_category: discord.CategoryChannel announce_channel: discord.TextChannel passwords_channel: discord.TextChannel + feed_channel: discord.TextChannel def __init__( self, @@ -48,7 +53,7 @@ def __init__( self.tree = app_commands.CommandTree(self) guild_id = os.getenv('DISCORD_GUILD_ID') if guild_id is None or not guild_id.isnumeric(): - logger.error("Invalid guild ID") + self.logger.error("Invalid guild ID") exit(1) self.guild = discord.Object(id=int(guild_id)) team = Team() @@ -64,6 +69,7 @@ async def setup_hook(self) -> None: # This copies the global commands over to your guild. self.tree.copy_global_to(guild=self.guild) await self.tree.sync(guild=self.guild) + self.check_for_new_blog_posts.start() async def on_ready(self) -> None: self.logger.info(f"{self.user} has connected to Discord!") @@ -79,6 +85,7 @@ async def on_ready(self) -> None: welcome_category = discord.utils.get(guild.categories, name=WELCOME_CATEGORY_NAME) announce_channel = discord.utils.get(guild.text_channels, name=ANNOUNCE_CHANNEL_NAME) passwords_channel = discord.utils.get(guild.text_channels, name=PASSWORDS_CHANNEL_NAME) + feed_channel = discord.utils.get(guild.text_channels, name=FEED_CHANNEL_NAME) if ( verified_role is None @@ -87,6 +94,7 @@ async def on_ready(self) -> None: or welcome_category is None or announce_channel is None or passwords_channel is None + or feed_channel is None ): logging.error("Roles and channels are not set up") exit(1) @@ -97,6 +105,7 @@ async def on_ready(self) -> None: self.welcome_category = welcome_category self.announce_channel = announce_channel self.passwords_channel = passwords_channel + self.feed_channel = feed_channel async def on_member_join(self, member: discord.Member) -> None: name = member.display_name @@ -132,6 +141,15 @@ async def on_member_remove(self, member: discord.Member) -> None: await channel.delete() self.logger.info(f"Deleted channel '{channel.name}', because it has no users.") + @tasks.loop(seconds=FEED_CHECK_INTERVAL) + async def check_for_new_blog_posts(self) -> None: + self.logger.info("Checking for new blog posts") + await check_posts(self.feed_channel) + + @check_for_new_blog_posts.before_loop + async def before_check_for_new_blog_posts(self) -> None: + await self.wait_until_ready() + async def load_passwords(self) -> AsyncGenerator[Tuple[str, str], None]: """ Returns a mapping from role name to the password for that role. diff --git a/src/constants.py b/src/constants.py index d129ec4..a8c41d3 100644 --- a/src/constants.py +++ b/src/constants.py @@ -23,3 +23,7 @@ TEAM_CATEGORY_NAME = "Team Channels" TEAM_VOICE_CATEGORY_NAME = "Team Voice Channels" TEAM_LEADER_ROLE = "Team Supervisor" + +FEED_URL = "https://studentrobotics.org/feed.xml" +FEED_CHANNEL_NAME = "blog" +FEED_CHECK_INTERVAL = 10 # seconds diff --git a/src/rss.py b/src/rss.py new file mode 100644 index 0000000..90e4e33 --- /dev/null +++ b/src/rss.py @@ -0,0 +1,51 @@ +import os +from typing import List + +import discord +import feedparser +from bs4 import BeautifulSoup +from feedparser import FeedParserDict + +from src.constants import FEED_URL + + +def get_seen_posts() -> List[str]: + if os.path.exists('seen_posts.txt'): + with open('seen_posts.txt', 'r') as f: + return f.readlines() + + return [] + + +def add_seen_post(post_id: str) -> None: + with open('seen_posts.txt', 'a') as f: + f.write(post_id + '\n') + + +async def check_posts(channel: discord.TextChannel) -> None: + feed = feedparser.parse(FEED_URL) + post = feed.entries[0] + + if post.id + "\n" not in get_seen_posts(): + await channel.send(embed=create_embed(post)) + add_seen_post(post.id) + + +def create_embed(post: FeedParserDict) -> discord.Embed: + soup = BeautifulSoup(post.content[0].value, 'html.parser') + text = "" + + if soup.p: + text = soup.p.text + + embed = discord.Embed( + title=post.title, + type="article", + url=post.link, + description=text, + ) + + if len(post.media_thumbnail) > 0: + embed.set_image(url=post.media_thumbnail[0]['url']) + + return embed