diff --git a/.docs/.overrides/main.html.disabled b/.docs/.overrides/main.html.disabled new file mode 100644 index 0000000..bace02a --- /dev/null +++ b/.docs/.overrides/main.html.disabled @@ -0,0 +1,5 @@ +{% extends "base.html" %} + +{% block announce %} + Put an announcement here. +{% endblock %} diff --git a/.docs/updatechecker.md b/.docs/updatechecker.md new file mode 100644 index 0000000..24c6c2a --- /dev/null +++ b/.docs/updatechecker.md @@ -0,0 +1,22 @@ +# UpdateChecker + +/// admonition | This project is not ready for production use + type: danger +The UpdateChecker cog is currently in an unfinished state, until it is completed please use [NeuroAssassin's UpdateChecker cog](https://github.com/NeuroAssassin/Toxic-Cogs) instead. +/// + +UpdateChecker will tell when there is an update available for a repository you have added for your bot, and, depending on settings, will auto update or will just notify you. + +This is a fork of [NeuroAssassin's UpdateChecker cog](https://github.com/NeuroAssassin/Toxic-Cogs). +Additional features include support for Gitea/Forgejo RSS feeds, instead of just GitHub. (1) +{ .annotate } + +1. ⚠️ Not yet implemented. + +## Installation + +```bash +[p]repo add seacogs-updatechecker https://coastalcommits.com/SeaswimmerTheFsh/SeaCogs updatechecker +[p]cog install seacogs-updatechecker updatechecker +[p]cog load updatechecker +``` diff --git a/mkdocs.yml b/mkdocs.yml index 3bef56f..2ab3bdb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,8 +1,8 @@ site_name: SeaCogs Documentation -site_url: https://seacogs.coastalcommits.com +site_url: !ENV [SITE_URL, 'https://seacogs.coastalcommits.com'] repo_name: CoastalCommits repo_url: https://coastalcommits.com/SeaswimmerTheFsh/SeaCogs -edit_uri: src/branch/main/.docs +edit_uri: !ENV [EDIT_URI, 'src/branch/main/.docs'] copyright: Copyright © 2024, SeaswimmerTheFsh docs_dir: .docs @@ -12,11 +12,12 @@ site_description: Documentation for my Red-DiscordBot Cogs. nav: - Home: index.md - Aurora: - - Home: aurora/index.md + - aurora/index.md - Moderation Commands: aurora/moderation-commands.md - Case Commands: aurora/case-commands.md - Configuration: aurora/configuration.md - Nerdify: nerdify.md + - UpdateChecker: updatechecker.md plugins: - git-authors @@ -62,19 +63,44 @@ markdown_extensions: theme: name: material + custom_dir: ./.docs/.overrides palette: - scheme: slate + - media: '(prefers-color-scheme: light)' + scheme: default + primary: cyan + accent: light blue + toggle: + icon: material/toggle-switch + name: Switch to dark mode + + - media: '(prefers-color-scheme: dark)' + scheme: slate + primary: cyan + accent: light blue + toggle: + icon: material/toggle-switch-off-outline + name: Switch to light mode features: + - announce.dismiss - content.code.annotate - content.code.copy - navigation.instant + - navigation.instant.progress - navigation.tabs + - navigation.tracking + - navigation.top + - navigation.sections + - navigation.indexes - search.suggest - search.highlight - search.share + - toc.follow logo: img/logo.png favicon: img/logo.png + icon: + repo: simple/forgejo watch: - ./aurora - ./nerdify + - ./updatechecker diff --git a/poetry.lock b/poetry.lock index f3c6846..1d06dd9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiodns" @@ -685,6 +685,20 @@ files = [ {file = "distro-1.8.0.tar.gz", hash = "sha256:02e111d1dc6a50abb8eed6bf31c3e48ed8b0830d1ea2a1b78c61765c2513fdd8"}, ] +[[package]] +name = "feedparser" +version = "6.0.11" +description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" +optional = false +python-versions = ">=3.6" +files = [ + {file = "feedparser-6.0.11-py3-none-any.whl", hash = "sha256:0be7ee7b395572b19ebeb1d6aafb0028dee11169f1c934e0ed67d54992f4ad45"}, + {file = "feedparser-6.0.11.tar.gz", hash = "sha256:c9d0407b64c6f2a065d0ebb292c2b35c01050cc0dc33757461aaabdc4c4184d5"}, +] + +[package.dependencies] +sgmllib3k = "*" + [[package]] name = "frozenlist" version = "1.4.0" @@ -2191,6 +2205,16 @@ files = [ [package.dependencies] contextlib2 = ">=0.5.5" +[[package]] +name = "sgmllib3k" +version = "1.0.0" +description = "Py3k port of sgmllib." +optional = false +python-versions = "*" +files = [ + {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, +] + [[package]] name = "six" version = "1.16.0" @@ -2558,4 +2582,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "b3a94060974d7ece0d58a3625794c721dd84d34c8215296deeb352b0eb49e29b" +content-hash = "6f70528246aa941fb37cc239583217faa49660ccaa6196fe13eb39a9c3605735" diff --git a/pyproject.toml b/pyproject.toml index 3ac495a..ccbf3de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ python = ">=3.9,<3.12" Red-DiscordBot = "^3.5.5" pytimeparse2 = "^1.7.1" humanize = "^4.8.0" +feedparser = "^6.0.11" [tool.poetry.group.dev] optional = true diff --git a/updatechecker/__init__.py b/updatechecker/__init__.py new file mode 100644 index 0000000..97f19f9 --- /dev/null +++ b/updatechecker/__init__.py @@ -0,0 +1,10 @@ +from .updatechecker import UpdateChecker + +__red_end_user_data_statement__ = ( + "This cog does not persistently store data or metadata about users." +) + + +async def setup(bot): + cog = UpdateChecker(bot) + await bot.add_cog(cog) diff --git a/updatechecker/info.json b/updatechecker/info.json new file mode 100644 index 0000000..6054252 --- /dev/null +++ b/updatechecker/info.json @@ -0,0 +1,17 @@ +{ + "author": [ + "SeaswimmerTheFsh", "Neuro Assassin" + ], + "install_msg": "Thank you for downloading this cog.", + "name": "updatechecker", + "short": "Notifies you when an update for a repo is available.", + "description": "This cog will tell when there is an update available for a repository you have added for your bot, and, depending on settings, will auto update or will just notify you.", + "tags": [ + "tools" + ], + "requirements": [ + "feedparser" + ], + "hidden": false, + "disabled": false +} diff --git a/updatechecker/updatechecker.py b/updatechecker/updatechecker.py new file mode 100644 index 0000000..b323c35 --- /dev/null +++ b/updatechecker/updatechecker.py @@ -0,0 +1,460 @@ +""" +MIT License + +Copyright (c) 2018-Present NeuroAssassin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +# Huge thanks to Sinbad for allowing me to copy parts of his RSS cog +# (https://github.com/mikeshardmind/SinbadCogs/tree/v3/rss), which I +# used to grab the latest commits from repositories. + +# Also, the code I use for updating repos I took directly from Red, +# and just took out the message interactions + +import asyncio +import traceback +from datetime import datetime +from urllib.parse import urlparse + +import aiohttp +import discord +from discord.ext import tasks +from redbot.cogs.downloader.repo_manager import Repo +from redbot.core import Config, commands +from redbot.core.utils.chat_formatting import box, humanize_list, inline + +import feedparser + + +class UpdateChecker(commands.Cog): + """Get notices or auto-update cogs when an update is available for its repo""" + + def __init__(self, bot): + self.bot = bot + self.session = aiohttp.ClientSession() + self.conf = Config.get_conf(self, identifier=473541068378341376) + default_global = { + "repos": {}, + "auto": False, + "gochannel": 0, + "embed": True, + "whitelist": [], + "blacklist": [], + } + self.conf.register_global(**default_global) + self.task = self.bot.loop.create_task(self.bg_task()) + + async def cog_unload(self): + self.__unload() + + def __unload(self): + self.task.cancel() + self.session.detach() + + async def red_delete_data_for_user(self, **kwargs): # pylint: disable=unused-argument + """This cog does not store user data""" + return + + @tasks.loop(minutes=1) + async def bg_task(self): + cog = self.bot.get_cog("Downloader") + if cog is not None: + data = await self.conf.all() + repos = data["repos"] + channel = data["gochannel"] + auto = data["auto"] + use_embed = data["embed"] + whitelist = data["whitelist"] + blacklist = data["blacklist"] + if channel: + channel = self.bot.get_channel(channel) + if channel is None: + await self.bot.send_to_owners( + "[Update Checker] It appears that I am no longer allowed to send messages to the designated update channel. " + "From now on, it will DM you." + ) + await self.conf.gochannel.set(0) + send = self.bot.send_to_owners + else: + use_embed = ( + use_embed and channel.permissions_for(channel.guild.me).embed_links + ) + send = channel.send + else: + send = self.bot.send_to_owners + + all_repos = cog._repo_manager.get_all_repo_names() # pylint: disable=protected-access + for repo in all_repos: + if not (repo in list(repos.keys())): + repos[repo] = "--default--" + await self.conf.repos.set(repos) + + saving_dict = {k: v for k, v in repos.items() if k in all_repos} + for repo_name, commit_saved in saving_dict.items(): + repo = cog._repo_manager.get_repo(repo_name) # pylint: disable=protected-access + if not repo: + continue + parsed_url = urlparse(repo.url) + if parsed_url.netloc == "github.com": + url = repo.url + r"/commits/" + repo.branch + ".atom" + response = await self.fetch_feed(url) + try: + commit = response.entries[0]["id"][33:] + chash = "[" + commit + "](" + response.entries[0]["link"] + ")" + cn = response.entries[0]["title"] + " - " + response.entries[0]["author"] + image = response.entries[0]["media_thumbnail"][0]["url"].split("?")[0] + except AttributeError: + continue + else: + url = repo.url + r"/rss/branch/" + repo.branch + response = await self.fetch_feed(url) + try: + commit = response.entries[0]["id"][33:] + chash = "[" + commit + "](" + response.entries[0]["link"] + ")" + cn = response.entries[0]["title"] + " - " + response.entries[0]["author"] + image = await self.fetch_gitea_thumbnail(parsed_url.scheme + "://" + parsed_url.netloc + "/api/v1/repos" + parsed_url.path) + except AttributeError: + continue + saving_dict[repo_name] = commit + if whitelist: + if repo_name not in whitelist: + continue + if repo_name in blacklist: + continue + # CN is used here for backwards compatability, don't want people to get an + # update for each and every one of their cogs when updating this cog + if commit_saved not in (commit, cn, '--default--'): + if use_embed: + e = discord.Embed( + title="Update Checker", + description=f"Update available for repo: {repo.name}", + timestamp=datetime.utcnow(), + color=0x00FF00, + ) + e.add_field(name="URL", value=repo.url) + e.add_field(name="Branch", value=repo.branch) + e.add_field(name="Commit", value=cn) + e.add_field(name="Hash", value=chash) + if image is not None: + e.set_thumbnail(url=image) + else: + e = box( + "[Update Checker]" + f" Repo: {repo.name}\n" + f" URL: {repo.url}\n" + f" Commit: {cn}\n" + f" Hash: {commit}\n" + f" Time: {datetime.utcnow()}", + 'css' + ) + try: + if use_embed: + await send(embed=e) + else: + await send(e) + except discord.Forbidden: + # send_to_owners suppresses Forbidden, logging it to console. + # As a result, this will only happen if a channel was set. + await self.bot.send_to_owners( + "[Update Checker] It appears that I am no longer allowed to send messages to the designated update channel. " + "From now on, it will DM you." + ) + if use_embed: + await self.bot.send_to_owners(embed=e) + else: + await self.bot.send_to_owners(e) + await self.conf.gochannel.set(0) + # Was already inaccessible before I got here, so I'm just gonna leave it and look at it later -- Sea + # try: + # await channel.send( + # f"[Update Checker] Update found for repo: {repo.name}. Updating repos..." + # ) + # except AttributeError: + # owner = (await self.bot.application_info()).owner + # await owner.send( + # "[Update Checker] It appears that the channel for this cog has been deleted. From now on, it will DM you." + # ) + # channel = owner + # await self.conf.gochannel.set(0) + # except discord.errors.Forbidden: + # owner = (await self.bot.application_info()).owner + # await owner.send( + # "[Update Checker] It appears that I am no longer allowed to send messages to the designated update channel. From now on, it will DM you." + # ) + # channel = owner + # await self.conf.gochannel.set(0) + # # Just a copy of `[p]cog update`, but without using ctx things + # try: + # installed_cogs = set(await cog.installed_cogs()) + # updated = await cog._repo_manager.update_all_repos() + # updated_cogs = set( + # cog for repo in updated for cog in repo.available_cogs + # ) + # installed_and_updated = updated_cogs & installed_cogs + # if installed_and_updated: + # await cog._reinstall_requirements(installed_and_updated) + # await cog._reinstall_cogs(installed_and_updated) + # await cog._reinstall_libraries(installed_and_updated) + # cognames = {c.name for c in installed_and_updated} + # message = humanize_list(tuple(map(inline, cognames))) + # except Exception as error: + # exception_log = ( + # "Exception while updating repos in Update Checker \n" + # ) + # exception_log += "".join( + # traceback.format_exception( + # type(error), error, error.__traceback__ + # ) + # ) + # try: + # await channel.send( + # f"[Update Checker]: Error while updating repos.\n\n{exception_log}" + # ) + # except discord.errors.Forbidden: + # pass + # else: + # try: + # await channel.send( + # f"[Update Checker]: Ran cog update. Updated cogs: {message}" + # ) + # except discord.errors.Forbidden: + # pass + await asyncio.sleep(1) + await self.conf.repos.set(saving_dict) + + async def fetch_feed(self, url: str): + # Thank's to Sinbad's rss cog after which I copied this + timeout = aiohttp.client.ClientTimeout(total=15) + try: + async with self.session.get(url, timeout=timeout) as response: + data = await response.read() + except (aiohttp.ClientError, asyncio.TimeoutError): + return None + + ret = feedparser.parse(data) + if ret.bozo: + return None + return ret + + async def fetch_gitea_thumbnail(self, url: str) -> str: + timeout = aiohttp.client.ClientTimeout(total=15) + try: + async with self.session.get(url, timeout=timeout) as response: + data = await response.json() + except (aiohttp.ClientError, asyncio.TimeoutError): + return None + + return data['avatar_url'] + + @commands.is_owner() + @commands.group(name="cogupdater", aliases=["cu"]) + async def update(self, ctx: commands.Context): + """Group command for controlling the update checker cog.""" + + @commands.is_owner() + @update.command() + async def auto(self, ctx: commands.Context): + """Changes automatic cog updates to the opposite setting.""" + # Was already inaccessible before I got here, so I'm just gonna leave it and look at it later -- Sea + # auto = await self.conf.auto() + # await self.conf.auto.set(not auto) + # status = "disabled" if auto else "enabled" + # await ctx.send(f"Auto cog updates are now {status}") + await ctx.send( + "This command is disabled for the time being. Cog updates will not run automatically, however notifications will still send." + ) + + @commands.is_owner() + @update.command() + async def channel(self, ctx: commands.Context, channel: discord.TextChannel = None): + """ + Sets a channel for update messages to go to. + + If argument is not supplied, it will be sent to the default notifications channel(s) specified in `[p]set ownernotifications`. + By default, this goes to owner DMs. + """ + if channel: + await self.conf.gochannel.set(channel.id) + await ctx.send(f"Update messages will now be sent to {channel.mention}") + else: + await self.conf.gochannel.set(0) + await ctx.send("Update messages will now be DMed to you.") + + @commands.is_owner() + @update.command() + async def settings(self, ctx: commands.Context): + """See settings for the Update Checker cog. + + Right now, this shows whether the bot updates cogs automatically and what channel logs are sent to. + """ + auto = await self.conf.auto() + channel = await self.conf.gochannel() + embed = await self.conf.embed() + if embed: + e = discord.Embed(title="Update Checker Settings", color=0x00FF00) + e.add_field(name="Automatic Cog Updates", value=str(auto)) + if channel == 0: + channel = "Direct Messages" + else: + channel = self.bot.get_channel(channel).name + if channel is None: + channel = "Unknown" + e.add_field(name="Update Channel", value=channel) + await ctx.send(embed=e) + else: + if channel == 0: + channel = "Direct Messages" + else: + channel = self.bot.get_channel(channel).name + if channel is None: + channel = "Unknown" + message = ( + "```css\n" + "[Update Checker settings]" + "``````css\n" + f"[Automatic Cog Updates]: {str(auto)}\n" + f" [Update Channel]: {channel}" + "```" + ) + await ctx.send(message) + + @commands.is_owner() + @update.command() + async def embed(self, ctx: commands.Context): + """Toggles whether to use embeds or colorful codeblock messages when sending an update.""" + c = await self.conf.embed() + await self.conf.embed.set(not c) + word = "disabled" if c else "enabled" + await ctx.send(f"Embeds are now {word}") + + @commands.is_owner() + @update.group(name="list") + async def whiteblacklist(self, ctx: commands.Context): + """Whitelist/blacklist certain repositories from which to receive updates.""" + if ctx.invoked_subcommand is None: + data = await self.conf.all() + whitelist = data["whitelist"] + blacklist = data["blacklist"] + await ctx.send( + f"Whitelisted: {humanize_list(tuple(map(inline, whitelist or ['None'])))}\nBlacklisted: {humanize_list(tuple(map(inline, blacklist or ['None'])))}" + ) + + @whiteblacklist.group() + async def whitelist(self, ctx: commands.Context): + """Whitelist certain repos from which to receive updates.""" + + @whitelist.command(name="add") + async def whitelistadd(self, ctx: commands.Context, *repos: Repo): + """Add repos to the whitelist""" + data = await self.conf.whitelist() + ds = set(data) + ns = {r.name for r in repos} + ss = ds | ns + await self.conf.whitelist.set(list(ss)) + await ctx.send(f"Whitelist update successful: {humanize_list(tuple(map(inline, ss)))}") + + @whitelist.command(name="remove") + async def whitelistremove(self, ctx: commands.Context, *repos: Repo): + """Remove repos from the whitelist""" + data = await self.conf.whitelist() + ds = set(data) + ns = {r.name for r in repos} + ss = ds - ns + await self.conf.whitelist.set(list(ss)) + await ctx.send( + f"Whitelist update successful: {humanize_list(tuple(map(inline, ss or ['None'])))}" + ) + + @whitelist.command(name="clear") + async def whitelistclear(self, ctx: commands.Context): + """Removes all repos from the whitelist""" + await self.conf.whitelist.set([]) + await ctx.send("Whitelist update successful") + + @whiteblacklist.group() + async def blacklist(self, ctx: commands.Context): + """Blacklist certain repos from which to receive updates.""" + + @blacklist.command(name="add") + async def blacklistadd(self, ctx: commands.Context, *repos: Repo): + """Add repos to the blacklist""" + data = await self.conf.blacklist() + ds = set(data) + ns = {r.name for r in repos} + ss = ds | ns + await self.conf.blacklist.set(list(ss)) + await ctx.send(f"Backlist update successful: {humanize_list(tuple(map(inline, ss)))}") + + @blacklist.command(name="remove") + async def blacklistremove(self, ctx: commands.Context, *repos: Repo): + """Remove repos from the blacklist""" + data = await self.conf.blacklist() + ds = set(data) + ns = {r.name for r in repos} + ss = ds - ns + await self.conf.blacklist.set(list(ss)) + await ctx.send( + f"Blacklist update successful: {humanize_list(tuple(map(inline, ss or ['None'])))}" + ) + + @blacklist.command(name="clear") + async def blacklistclear(self, ctx: commands.Context): + """Removes all repos from the blacklist""" + await self.conf.blacklist.set([]) + await ctx.send("Blacklist update successful") + + @commands.is_owner() + @update.group(name="task") + async def _group_update_task(self, ctx: commands.Context): + """View the status of the task (the one checking for updates).""" + + @_group_update_task.command() + async def status(self, ctx: commands.Context): + """Get the current status of the update task.""" + message = "Task is currently " + cancelled = self.task.cancelled() + if cancelled: + message += "canceled." + else: + done = self.task.done() + if done: + message += "done." + else: + message += "running." + try: + self.task.exception() + except asyncio.exceptions.InvalidStateError: + message += " No error has been encountered." + else: + message += f" An error has been encountered. Please run `{ctx.prefix}cogupdater task error` and report it to SeaswimmerTheFsh (seasw.) on the help server." + await ctx.send(message) + + @_group_update_task.command() + async def error(self, ctx: commands.Context): + """Gets the latest error of the update task.""" + try: + e = self.task.exception() + except asyncio.exceptions.InvalidStateError: + message = "No error has been encountered." + else: + ex = traceback.format_exception(type(e), e, e.__traceback__) + message = "An error has been encountered:" + box("".join(ex), "py") + await ctx.send(message)