From 500c4c42f343f20a0e82dcf3fe2f7df61a6fc824 Mon Sep 17 00:00:00 2001 From: Mike Tung Date: Sun, 18 Feb 2024 13:38:46 -0500 Subject: [PATCH 1/4] Release/v1.0.0 (#10) * added github workflow to build docker images on PR file - added `.github` folder and `workflow` folder to house github actions - setup workflow to build docker image for jainy_bot with date * added github action for building and publishing jobs - added workflow for publishing tagged docker images - added workflow for testing builds with git commit hash (short variant) * changing secrets path to see if path was wrong - removed secret name and used env var defined in secret instead * changing secrets path to see if path was wrong - added back secret name and used env var defined in secret instead * changing secrets path to see if path was wrong - removed secret name, and in github settings made each secret separate input * added new bot command to track uptime and tidied up some code (#5) - fixed type hint for ctx in borb.py - added new uptime class cog - updated commands package __init__.py to include cogs list for use in auto registration with bot.py - updated bot.py to auto register cogs * added new github action to handle tagging (#6) - added github-tag-action to handle tagging version - removed env declaration and tag variable * Create LICENSE * added feature request template for issues, and updated LICENSE template with name and copyright year (#7) - added LICENSE template details - added git issue template for features * Adding moderation cog and commands (#8) * added moderation cog with kick command, added new configuration properties to borb_bot config - updated commands cog list to include new cog - added moderation cog with kick command and embed message helper - updated borb_bot config to include moderator roles and audit channel id * added ban, unban, clean, invite commands, and added custom exception - added ban, unban, clean, invite commands to moderation cog - updated doc strings - added helper functions to make embed messages for auditing - created custom exception for when user unauthorized to use command * refactored clean command to properly purge msg by user and correct amount - refactored clean command to correctly purge messages by user - set default limit of 1000 messages to look through - added bot reply for cleaned messages * added docstrings, refactored helper functions in to util file to be shared across cogs - added docstrings to methods and functions - refactored helpers from moderation cog to util file for shared use * General Cleanup (#9) * tidied up code to match return signatures and doc strings - updated return signatures to match doc string and type hints - removed unused imports * tidied up code to match return signatures and doc strings - updated return signatures to match doc string and type hints - removed unused imports * updated configuration and corrected null check - fixed null check to check for correct env var for MOD_AUDIT_CHANNEL_ID and made moderator_roles into env var * added changelog with notes on release * fixed casing on moderator_roles to MODERATOR_ROLES * updated readme - updated `.env` file variables * removed logging.py in favor of env var setup for loguru logger, added more exception handling - removed logging.py in favor of using loguru environment variables to set the logging level - updated kick command to check for UserNotFound exception thrown by commands object * updated readme - added loguru env var for setting log level * fixed small error being thrown when initial bot command msg gets deleted before cleanup * cleaned up unused variables, and added more logging - removed unused os.environ from react_roles since var isn't used - added logging to uptime command --- .github/ISSUE_TEMPLATE/feature_request.yml | 13 ++ .../build-docker-image-pull-requests.yml | 25 +++ .../workflows/build-publish-docker-image.yml | 29 +++ CHANGELOG.md | 11 + LICENSE | 201 ++++++++++++++++++ README.md | 5 +- config/__init__.py | 1 - config/borb_bot.py | 10 +- config/logging.py | 7 - jainy_bot/bot.py | 7 +- jainy_bot/commands/__init__.py | 6 + jainy_bot/commands/borb.py | 2 +- jainy_bot/commands/moderation.py | 191 +++++++++++++++++ jainy_bot/commands/uptime.py | 22 ++ jainy_bot/commands/util.py | 84 ++++++++ jainy_bot/exceptions/__init__.py | 3 + .../exceptions/unauthorized_user_exception.py | 3 + jainy_bot/react_roles.py | 6 - 18 files changed, 606 insertions(+), 20 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/workflows/build-docker-image-pull-requests.yml create mode 100644 .github/workflows/build-publish-docker-image.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE delete mode 100644 config/logging.py create mode 100644 jainy_bot/commands/moderation.py create mode 100644 jainy_bot/commands/uptime.py create mode 100644 jainy_bot/commands/util.py create mode 100644 jainy_bot/exceptions/__init__.py create mode 100644 jainy_bot/exceptions/unauthorized_user_exception.py diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..c50928c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,13 @@ +name: Feature Request +description: Request a new feature +title: "[Feature]: " +labels: ["feature"] +assignees: + - seekheart +body: + - type: textarea + attributes: + label: What feature are you requesting + description: what problem are you hoping to solve and how should the feature be used? + validations: + required: true diff --git a/.github/workflows/build-docker-image-pull-requests.yml b/.github/workflows/build-docker-image-pull-requests.yml new file mode 100644 index 0000000..a40bdbe --- /dev/null +++ b/.github/workflows/build-docker-image-pull-requests.yml @@ -0,0 +1,25 @@ +name: build-docker-image-pull-requests +on: + pull_request: + branches: + - develop + - master +jobs: + build-docker-image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - id: commit + uses: prompt/actions-commit-hash@v3 + - uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: false + tags: ${{ steps.commit.outputs.short }} \ No newline at end of file diff --git a/.github/workflows/build-publish-docker-image.yml b/.github/workflows/build-publish-docker-image.yml new file mode 100644 index 0000000..0dc3bb7 --- /dev/null +++ b/.github/workflows/build-publish-docker-image.yml @@ -0,0 +1,29 @@ +name: build-publish-docker-image +on: + push: + branches: + - master +jobs: + publish-docker-image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + - id: commit + uses: prompt/actions-commit-hash@v3 + - uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.tag_version.outputs.new_tag }} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eda73ef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Jainy Bot Changelog + +## Version v1.0.0 +**Features** +- Moderation commands like kick, ban, unban, etc with audit log posted in a channel (configurable) +- Borb command for fun +- Clean command to purge messages by a user +- Uptime command to determine how long bot has been running +- Role assignment by emoji reaction +- Help command to see what commands are available and what they do +- Configuration via environment variables or `.env` file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..60f5f2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [2024] [SeekHeart (Mike Tung)] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index ae42a43..081e0f2 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ or you will have to reset token again) 1. Create a `.env` file in this directory and set the following variables ```bash DISCORD_BOT_TOKEN= -LOG_LEVEL=DEBUG +MOD_AUDIT_CHANNEL_ID= +ROLE_MESSAGE_ID= +MODERATOR_ROLES= +LOGURU_LEVEL= ``` 2. Source the env file you created and run the `app.py` \ No newline at end of file diff --git a/config/__init__.py b/config/__init__.py index 7bab4cc..b11b6bb 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1,2 +1 @@ from .borb_bot import * -from .logging import * \ No newline at end of file diff --git a/config/borb_bot.py b/config/borb_bot.py index fe11433..4d1c05e 100644 --- a/config/borb_bot.py +++ b/config/borb_bot.py @@ -13,6 +13,14 @@ BOT_INTENTS.message_content = True BOT_ROLE_MESSAGE_ID = int(os.environ.get('ROLE_MESSAGE_ID')) +BOT_MOD_AUDIT_CHANNEL_ID = int(os.environ.get('MOD_AUDIT_CHANNEL_ID')) if not BOT_ROLE_MESSAGE_ID: - raise AttributeError(f'Bot_ROLE_ID cannot be null') \ No newline at end of file + raise AttributeError(f'BOT_ROLE_MESSAGE_ID cannot be null') +if not BOT_MOD_AUDIT_CHANNEL_ID: + raise AttributeError(f'BOT_MOD_AUDIT_CHANNEL_ID cannot be null') + +MODERATOR_ROLES = os.environ.get('MODERATOR_ROLES').split(',') + +if not MODERATOR_ROLES: + raise AttributeError(f'MODERATOR_ROLES is not set!') \ No newline at end of file diff --git a/config/logging.py b/config/logging.py deleted file mode 100644 index 280190f..0000000 --- a/config/logging.py +++ /dev/null @@ -1,7 +0,0 @@ -import os - -from loguru import logger - -LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') - -logger.level(LOG_LEVEL) diff --git a/jainy_bot/bot.py b/jainy_bot/bot.py index 6c6d5e1..281c24f 100644 --- a/jainy_bot/bot.py +++ b/jainy_bot/bot.py @@ -3,7 +3,7 @@ from loguru import logger from config import BOT_PREFIX, BOT_INTENTS, BOT_ROLE_MESSAGE_ID from .react_roles import emoji_to_role -from jainy_bot.commands import Borb +from jainy_bot.commands import cogs class JainyBot(commands.Bot): @@ -14,8 +14,9 @@ def __init__(self, *args, **kwargs): self.guild = None async def setup_hook(self): - logger.info(f'{Borb.__name__}') - await self.add_cog(Borb(self)) + for c in cogs: + logger.info(f"Setting up cog: {c.__name__}") + await self.add_cog(c(self)) async def on_ready(self): logger.info(f'Logged in as {self.user}') diff --git a/jainy_bot/commands/__init__.py b/jainy_bot/commands/__init__.py index 515c4fc..c7f8ce0 100644 --- a/jainy_bot/commands/__init__.py +++ b/jainy_bot/commands/__init__.py @@ -1 +1,7 @@ from .borb import * +from .uptime import * +from .moderation import * + +cogs = [Borb, Uptime, Moderation] + +__all__ = [Borb, Uptime, Moderation, cogs] \ No newline at end of file diff --git a/jainy_bot/commands/borb.py b/jainy_bot/commands/borb.py index d7efcd2..c85b7c5 100644 --- a/jainy_bot/commands/borb.py +++ b/jainy_bot/commands/borb.py @@ -6,7 +6,7 @@ def __init__(self, bot: commands): self.bot = bot @commands.command() - async def borb(self, ctx, name): + async def borb(self, ctx: commands, name): """Borb a person""" await ctx.send(f'Borbing {name}') diff --git a/jainy_bot/commands/moderation.py b/jainy_bot/commands/moderation.py new file mode 100644 index 0000000..a6de33f --- /dev/null +++ b/jainy_bot/commands/moderation.py @@ -0,0 +1,191 @@ +from discord.ext import commands +from loguru import logger +from config import MODERATOR_ROLES +from jainy_bot.exceptions import UnauthorizedUserException +from .util import make_general_card, make_offender_card, send_audit_message, send_reply_message +import discord + + +class Moderation(commands.Cog, name="Moderation"): + """ + Moderation Cog representing all things moderator + """ + + def __init__(self, bot: commands.bot): + """ + constructor for the cog to load into the bot when bot calls load_cog + :param bot: discord bot of interest + """ + self.bot = bot + self.allowed_roles = MODERATOR_ROLES + + def _is_allowed(self, user_roles: list[discord.Role]) -> bool: + """ + Checks if user is allowed to use this command based on roles + :param user_roles: list of roles from the user calling the command + :return: boolean indicating whether user is allowed to use this command + """ + for role in user_roles: + if role.name in self.allowed_roles: + return True + return False + + async def _check_if_allowed(self, ctx: commands.Context): + """ + Checks if the caller of the command is allowed to execute command + :param ctx: discord context of the call + :return: bool if allowed else raises UnauthorizedUserException + """ + is_allowed = self._is_allowed(ctx.author.roles) + + if not is_allowed: + await ctx.send(f'{ctx.author.mention} you are not authorized to use mod commands!') + logger.error(f'{ctx.author} tried to use mod commands') + + raise UnauthorizedUserException(f'{ctx.author} unauthorized to use mod commands!') + return True + + @commands.command() + async def kick(self, ctx: commands.Context, user: discord.User, reason: str) -> None: + """ + Kicks a user from the server + :param ctx: discord context of the call + :param user: user to kick + :param reason: reason for kicking the user to be logged in audit channel + :return None + """ + await self._check_if_allowed(ctx) + + embed = make_offender_card( + title=f'Kicked user from {ctx.guild.name}', + offender=user, + moderator=ctx.author, + details=reason + ) + + try: + await ctx.guild.kick(user=user, reason=reason) + except discord.HTTPException or commands.UserNotFound as err: + logger.error(err.text) + await ctx.send(f'could not kick user {user.display_name}') + return + await send_reply_message(ctx, f'kicked user {user.display_name}') + await send_audit_message(guild=ctx.guild, embed=embed) + + @commands.command() + async def ban(self, ctx: commands.Context, user: discord.User, reason: str): + """ + Bans a user from server + :param ctx: discord calling context + :param user to kick + :param reason: reason for banning the user to be logged in audit channel + :return: + """ + await self._check_if_allowed(ctx) + + embed = make_offender_card( + title=f'Banned user from {ctx.guild.name}', + offender=user, + moderator=ctx.author, + details=reason + ) + + try: + await ctx.guild.ban(user) + except discord.HTTPException or discord.Forbidden as e: + logger.error(e.text) + return await ctx.send(f'Could not ban user {user.display_name}') + + await send_reply_message(ctx, f'banned user {user.display_name}') + await send_audit_message(guild=ctx.guild, embed=embed) + + @commands.command() + async def unban(self, ctx: commands.Context, user: discord.User, reason: str): + """ + Unbans a user from the server + :param ctx: discord calling context + :param user: user to unban + :param reason: reason for unbanning user to be logged in audit channel + :return: + """ + await self._check_if_allowed(ctx) + + embed = make_offender_card( + title=f'Unbanned user from {ctx.guild.name}', + offender=user, + moderator=ctx.author, + details=reason + ) + + try: + await ctx.guild.unban(user) + except discord.HTTPException or discord.Forbidden or discord.NotFound as e: + logger.error(e.text) + return ctx.send(f'Unable to unban user {user.display_name}') + + await send_reply_message(ctx, f'unbanned user {user.display_name}') + await send_audit_message(guild=ctx.guild, embed=embed) + + @commands.command() + async def invite(self, ctx: commands.Context) -> None: + """ + Creates invite one time use invite link set to expire in 30 mins + :param ctx: discord calling context + :return: None + """ + await self._check_if_allowed(ctx) + + embed = make_general_card( + title=f'{ctx.author.display_name} created invite link', + author=ctx.author, + thumbnail_url=ctx.author.avatar.url + ) + + try: + invite = await ctx.channel.create_invite( + max_age=1800, + max_uses=1, + unique=True, + ) + embed.add_field( + name=f'Invite Link', + value=invite.url, + inline=False + ).add_field( + name=f'Invite created timestamp', + value=invite.created_at + ).add_field( + name=f'Invite expire timestamp', + value=invite.expires_at + ) + except discord.HTTPException or discord.Forbidden as e: + logger.error(e.text) + return ctx.send(f'Unable to create invite link') + + await send_reply_message(ctx, f'Invite link created: {invite.url}') + await send_audit_message(guild=ctx.guild, embed=embed) + + @commands.command() + async def clean(self, ctx: commands.Context, user: discord.Member, num_msg: int) -> None: + """ + Cleans up last N messages from user declared in command + :param ctx: discord calling context + :param user: user to clean messages from + :param num_msg: number of messages to delete + :return: None + """ + await self._check_if_allowed(ctx) + logger.info(f'Cleaning {num_msg} messages by user = {user.display_name}') + + deleted = [] + async for msg in ctx.channel.history(limit=1000): + if msg.id == ctx.message.id: + logger.debug(f'Skipping initial bot command message: {msg.content}') + continue + if len(deleted) == num_msg: + break + if msg.author == user: + deleted.append(msg) + await msg.delete() + + await send_reply_message(ctx, f'Deleted last {len(deleted)} messages by user = {user.display_name}') diff --git a/jainy_bot/commands/uptime.py b/jainy_bot/commands/uptime.py new file mode 100644 index 0000000..3f037ec --- /dev/null +++ b/jainy_bot/commands/uptime.py @@ -0,0 +1,22 @@ +from datetime import datetime, timedelta +from discord.ext import commands +from loguru import logger + + +def format_time(t: timedelta) -> str: + logger.debug(f'Got timedelta = {t}') + h, m, s = str(t).split(':') + return f'{h} Hours {m} Minutes {s} Seconds' + + +class Uptime(commands.Cog, name="Uptime"): + def __init__(self, bot: commands): + self.bot = bot + self.start_time = datetime.now() + + @commands.command() + async def uptime(self, ctx: commands): + """Get the uptime for bot""" + logger.info(f'Generating uptime information') + delta = datetime.now() - self.start_time + await ctx.send(f"Uptime: {format_time(delta)}") \ No newline at end of file diff --git a/jainy_bot/commands/util.py b/jainy_bot/commands/util.py new file mode 100644 index 0000000..138a1a1 --- /dev/null +++ b/jainy_bot/commands/util.py @@ -0,0 +1,84 @@ +import discord + +from datetime import datetime, timezone +from loguru import logger +from discord.ext import commands +from config import BOT_MOD_AUDIT_CHANNEL_ID + + +def make_general_card(title: str, author: discord.User, thumbnail_url: str | None = None) -> discord.Embed: + """ + Makes a general discord embed card + :param title: title of card + :param author: discord user who triggered the event + :param thumbnail_url: author's avatar + :return: discord.Embed object + """ + base = discord.Embed( + title=title, + timestamp=datetime.now(timezone.utc) + ).set_author( + name=author.display_name, + icon_url=author.avatar.url + ) + + if thumbnail_url: + base.set_thumbnail(url=thumbnail_url) + + return base + + +def make_offender_card(title: str, offender: discord.User, moderator: discord.User, details: str) -> discord.Embed: + """ + Makes a discord embed card for moderation events and general server events like invite creation + :param title: title of card, usually the event + :param offender: person who is on the receiving end of command usually from kick/ban/unban + :param moderator: the moderator who performed the action + :param details: log information about what happened to cause the event + :return: discord.Embed object + """ + return make_general_card( + title=title, + author=moderator, + thumbnail_url=offender.avatar.url + ).add_field( + name=f'User Name', + value=f'{offender.name}' + ).add_field( + name=f'User ID', + value=f'{offender.id}' + ).add_field( + name=f'Details', + value=f'{details}', + inline=False + ) + + +async def send_audit_message(guild: discord.Guild, embed: discord.Embed) -> None: + """ + Sends embed message to audit channel for moderators to view + :param guild: discord server + :param embed: the embed message constructed for moderator auditing + :return: None + """ + logger.info(f'Sending audit message to guild {guild} channel = {guild.get_channel(BOT_MOD_AUDIT_CHANNEL_ID)}') + await guild.get_channel(BOT_MOD_AUDIT_CHANNEL_ID).send(embed=embed) + + +async def send_reply_message(ctx: commands.Context, message: str) -> None: + """ + Sends reply to user who issued the command and cleans up their last message (usually the command) + :param ctx: discord context object representing the context of the command call + :param message: bot reply message + :return: None + """ + logger.info(f'Sending reply message to {ctx.author.display_name} in channel {ctx.channel.name}') + await ctx.send(message) + try: + await ctx.message.delete() + except discord.ext.commands.errors.CommandInvokeError as e: + logger.warning(f'Original bot command invoked could be deleted') + logger.error(e) + except discord.ext.commands.UserNotFound or discord.NotFound as e: + logger.error(f'User = {ctx.author.display_name} who invoked command is no longer found') + logger.error(e) diff --git a/jainy_bot/exceptions/__init__.py b/jainy_bot/exceptions/__init__.py new file mode 100644 index 0000000..9b99793 --- /dev/null +++ b/jainy_bot/exceptions/__init__.py @@ -0,0 +1,3 @@ +from .unauthorized_user_exception import UnauthorizedUserException + +__all__ = [UnauthorizedUserException] \ No newline at end of file diff --git a/jainy_bot/exceptions/unauthorized_user_exception.py b/jainy_bot/exceptions/unauthorized_user_exception.py new file mode 100644 index 0000000..62ca7d8 --- /dev/null +++ b/jainy_bot/exceptions/unauthorized_user_exception.py @@ -0,0 +1,3 @@ +class UnauthorizedUserException(Exception): + def __init__(self, msg: str) -> None: + super().__init__(f'{msg}') diff --git a/jainy_bot/react_roles.py b/jainy_bot/react_roles.py index f3c7d19..246eac9 100644 --- a/jainy_bot/react_roles.py +++ b/jainy_bot/react_roles.py @@ -1,11 +1,5 @@ -import os import discord -role_message_id = int(os.environ.get("ROLE_MESSAGE_ID")) -if not role_message_id: - raise AttributeError("ROLE_MESSAGE_ID not set!") - - emoji_to_role = { discord.PartialEmoji(name='🍖'): 1197959991315927101, discord.PartialEmoji(name='🪦'): 800444883897942086, From a3a66bb72f439e341823710319630923b3c985d3 Mon Sep 17 00:00:00 2001 From: Mike Tung Date: Sat, 4 May 2024 17:34:14 -0400 Subject: [PATCH 2/4] Release v1.1.0 (#16) * added github workflow to build docker images on PR file - added `.github` folder and `workflow` folder to house github actions - setup workflow to build docker image for jainy_bot with date * added github action for building and publishing jobs - added workflow for publishing tagged docker images - added workflow for testing builds with git commit hash (short variant) * changing secrets path to see if path was wrong - removed secret name and used env var defined in secret instead * changing secrets path to see if path was wrong - added back secret name and used env var defined in secret instead * changing secrets path to see if path was wrong - removed secret name, and in github settings made each secret separate input * added new bot command to track uptime and tidied up some code (#5) - fixed type hint for ctx in borb.py - added new uptime class cog - updated commands package __init__.py to include cogs list for use in auto registration with bot.py - updated bot.py to auto register cogs * added new github action to handle tagging (#6) - added github-tag-action to handle tagging version - removed env declaration and tag variable * Create LICENSE * added feature request template for issues, and updated LICENSE template with name and copyright year (#7) - added LICENSE template details - added git issue template for features * Adding moderation cog and commands (#8) * added moderation cog with kick command, added new configuration properties to borb_bot config - updated commands cog list to include new cog - added moderation cog with kick command and embed message helper - updated borb_bot config to include moderator roles and audit channel id * added ban, unban, clean, invite commands, and added custom exception - added ban, unban, clean, invite commands to moderation cog - updated doc strings - added helper functions to make embed messages for auditing - created custom exception for when user unauthorized to use command * refactored clean command to properly purge msg by user and correct amount - refactored clean command to correctly purge messages by user - set default limit of 1000 messages to look through - added bot reply for cleaned messages * added docstrings, refactored helper functions in to util file to be shared across cogs - added docstrings to methods and functions - refactored helpers from moderation cog to util file for shared use * General Cleanup (#9) * tidied up code to match return signatures and doc strings - updated return signatures to match doc string and type hints - removed unused imports * tidied up code to match return signatures and doc strings - updated return signatures to match doc string and type hints - removed unused imports * updated configuration and corrected null check - fixed null check to check for correct env var for MOD_AUDIT_CHANNEL_ID and made moderator_roles into env var * added changelog with notes on release * fixed casing on moderator_roles to MODERATOR_ROLES * updated readme - updated `.env` file variables * removed logging.py in favor of env var setup for loguru logger, added more exception handling - removed logging.py in favor of using loguru environment variables to set the logging level - updated kick command to check for UserNotFound exception thrown by commands object * Release/v1.0.0 (#11) * updated readme - added loguru env var for setting log level * fixed small error being thrown when initial bot command msg gets deleted before cleanup * cleaned up unused variables, and added more logging - removed unused os.environ from react_roles since var isn't used - added logging to uptime command * Bump aiohttp from 3.9.3 to 3.9.4 (#13) Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.9.3 to 3.9.4. - [Release notes](https://github.com/aio-libs/aiohttp/releases) - [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst) - [Commits](https://github.com/aio-libs/aiohttp/compare/v3.9.3...v3.9.4) --- updated-dependencies: - dependency-name: aiohttp dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump idna from 3.6 to 3.7 (#12) Bumps [idna](https://github.com/kjd/idna) from 3.6 to 3.7. - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst) - [Commits](https://github.com/kjd/idna/compare/v3.6...v3.7) --- updated-dependencies: - dependency-name: idna dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Dalle Feature (#15) * updated requirements.txt and added dalle command and config. - added dalle command to generate random images - added config file for dalle api communication - added requests dep for network api calls * cleaned up dockerfile - removed env vars since they get defined in compose * cleaned up moderation py to use audit channel and updated dalle to let user know prompt successful - dalle command lets user know prompt was received now - moderation command uses the audit channel in addition to native discord audit of mod commands --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 +++++++ Dockerfile | 4 --- config/__init__.py | 1 + config/dalle_api_config.py | 6 ++++ jainy_bot/commands/__init__.py | 5 ++-- jainy_bot/commands/dalle.py | 48 ++++++++++++++++++++++++++++++++ jainy_bot/commands/moderation.py | 13 +++++++-- requirements.txt | 9 +----- 8 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 config/dalle_api_config.py create mode 100644 jainy_bot/commands/dalle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index eda73ef..5aca2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Jainy Bot Changelog +## Version v1.1.0 + +**Features** + +- Added dalle command to generate images via ai + +**Dev Deps** + +- added requests for api calls + ## Version v1.0.0 **Features** - Moderation commands like kick, ban, unban, etc with audit log posted in a channel (configurable) diff --git a/Dockerfile b/Dockerfile index ca0c709..6f869b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,10 +7,6 @@ COPY requirements.txt . COPY app.py . COPY ./jainy_bot ./jainy_bot -ENV DISCORD_BOT_TOKEN=DISCORD_TOKEN_HERE -ENV LOG_LEVEL=LOG_LEVEL_HERE -ENV BOT_GUILD_ID=BOT_GUILD_ID_HERE - RUN pip install -r requirements.txt RUN rm requirements.txt diff --git a/config/__init__.py b/config/__init__.py index b11b6bb..8c8928d 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1 +1,2 @@ from .borb_bot import * +from .dalle_api_config import * diff --git a/config/dalle_api_config.py b/config/dalle_api_config.py new file mode 100644 index 0000000..298b776 --- /dev/null +++ b/config/dalle_api_config.py @@ -0,0 +1,6 @@ +import os + +DALLE_API_URL = os.environ.get('DALLE_API_URL') + +if not DALLE_API_URL: + raise AttributeError('DALLE_API_URL environment variable not set!') diff --git a/jainy_bot/commands/__init__.py b/jainy_bot/commands/__init__.py index c7f8ce0..d84b5f7 100644 --- a/jainy_bot/commands/__init__.py +++ b/jainy_bot/commands/__init__.py @@ -1,7 +1,8 @@ from .borb import * -from .uptime import * +from .dalle import * from .moderation import * +from .uptime import * -cogs = [Borb, Uptime, Moderation] +cogs = [Borb, Uptime, Moderation, Dalle] __all__ = [Borb, Uptime, Moderation, cogs] \ No newline at end of file diff --git a/jainy_bot/commands/dalle.py b/jainy_bot/commands/dalle.py new file mode 100644 index 0000000..7901c6d --- /dev/null +++ b/jainy_bot/commands/dalle.py @@ -0,0 +1,48 @@ +import base64 +from io import BytesIO + +import discord +import requests +from discord.ext import commands +from loguru import logger + +from config import DALLE_API_URL + + +class Dalle(commands.Cog, name="Dalle"): + def __init__(self, bot: commands.bot): + self.bot = bot + + @commands.command() + async def dalle(self, ctx: commands.Context, prompt: str): + """Create a Dalle generated image with a prompt""" + logger.info(f"Creating a Dalle image with prompt: {prompt} requested by user {ctx.author}") + + payload = { + 'prompt': prompt + } + + await ctx.send(f'{ctx.author.mention} hang tight I\'m checking with Dall-E') + response = requests.post(DALLE_API_URL, json=payload) + + if response.status_code == 200: + logger.info(f"Dalle image created successfully") + await ctx.send(f'{ctx.author.mention} generating image please wait') + data = response.json() + images = data['images'] + files = [] + + for idx, img in enumerate(images): + file_bytes = BytesIO(base64.b64decode(img)) + file = discord.File(file_bytes, filename=f"{prompt}_{idx}.jpg") + files.append(file) + + await ctx.send( + files=files, + mention_author=True, + embed=discord.Embed(title=f'{prompt} requested by {ctx.author}') + ) + else: + logger.error( + f'Error creating a Dalle image with prompt: {prompt} server responded with status = {response.status_code}') + await ctx.send('Could not generate images') diff --git a/jainy_bot/commands/moderation.py b/jainy_bot/commands/moderation.py index a6de33f..24cc73c 100644 --- a/jainy_bot/commands/moderation.py +++ b/jainy_bot/commands/moderation.py @@ -1,9 +1,10 @@ +import discord from discord.ext import commands from loguru import logger + from config import MODERATOR_ROLES from jainy_bot.exceptions import UnauthorizedUserException from .util import make_general_card, make_offender_card, send_audit_message, send_reply_message -import discord class Moderation(commands.Cog, name="Moderation"): @@ -160,7 +161,8 @@ async def invite(self, ctx: commands.Context) -> None: ) except discord.HTTPException or discord.Forbidden as e: logger.error(e.text) - return ctx.send(f'Unable to create invite link') + await ctx.send(f'Unable to create invite link') + return await send_reply_message(ctx, f'Invite link created: {invite.url}') await send_audit_message(guild=ctx.guild, embed=embed) @@ -188,4 +190,11 @@ async def clean(self, ctx: commands.Context, user: discord.Member, num_msg: int) deleted.append(msg) await msg.delete() + embed = make_general_card( + title=f'{ctx.author.display_name} cleaned up last {num_msg} messages by user = {user.display_name}', + author=ctx.author, + thumbnail_url=ctx.author.avatar.url + ) + await send_reply_message(ctx, f'Deleted last {len(deleted)} messages by user = {user.display_name}') + await send_audit_message(guild=ctx.guild, embed=embed) diff --git a/requirements.txt b/requirements.txt index 6370d7f..2400404 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,3 @@ -aiohttp==3.9.3 -aiosignal==1.3.1 -attrs==23.2.0 discord==2.3.2 -discord.py==2.3.2 -frozenlist==1.4.1 -idna==3.6 loguru==0.7.2 -multidict==6.0.5 -yarl==1.9.4 +requests~=2.31.0 From add490184f2c973a514c1e61852fa129b3f5b6b0 Mon Sep 17 00:00:00 2001 From: Mike Tung Date: Tue, 7 May 2024 17:48:22 -0400 Subject: [PATCH 3/4] fixed issue with synchronous code killing discord healthcheck and crashing bot. - removed requests library in favor of aiohttp for asynchronous requests - updated dalle logic to work with change - updated dependencies --- jainy_bot/commands/dalle.py | 22 ++++++++++++++++------ requirements.txt | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/jainy_bot/commands/dalle.py b/jainy_bot/commands/dalle.py index 7901c6d..72329a6 100644 --- a/jainy_bot/commands/dalle.py +++ b/jainy_bot/commands/dalle.py @@ -1,14 +1,26 @@ import base64 from io import BytesIO +import aiohttp import discord -import requests from discord.ext import commands from loguru import logger from config import DALLE_API_URL +async def make_api_request(payload: dict) -> dict or None: + async with aiohttp.ClientSession() as session: + async with session.post(DALLE_API_URL, json=payload) as response: + if response.status == 200: + logger.info(f'Successfully contacted Dalle Api') + payload = await response.json() + return payload + else: + logger.error(f'Dalle Server responded with status = {response.status}') + return None + + class Dalle(commands.Cog, name="Dalle"): def __init__(self, bot: commands.bot): self.bot = bot @@ -23,12 +35,12 @@ async def dalle(self, ctx: commands.Context, prompt: str): } await ctx.send(f'{ctx.author.mention} hang tight I\'m checking with Dall-E') - response = requests.post(DALLE_API_URL, json=payload) + response = await make_api_request(payload) - if response.status_code == 200: + if response: logger.info(f"Dalle image created successfully") await ctx.send(f'{ctx.author.mention} generating image please wait') - data = response.json() + data = response images = data['images'] files = [] @@ -43,6 +55,4 @@ async def dalle(self, ctx: commands.Context, prompt: str): embed=discord.Embed(title=f'{prompt} requested by {ctx.author}') ) else: - logger.error( - f'Error creating a Dalle image with prompt: {prompt} server responded with status = {response.status_code}') await ctx.send('Could not generate images') diff --git a/requirements.txt b/requirements.txt index 2400404..b970d70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,18 @@ +aiohttp==3.9.4 +aiosignal==1.3.1 +anyio==4.3.0 +attrs==23.2.0 +certifi==2024.2.2 +charset-normalizer==3.3.2 discord==2.3.2 +discord.py==2.3.2 +frozenlist==1.4.1 +h11==0.14.0 +httpcore==1.0.5 +httpx==0.27.0 +idna==3.7 loguru==0.7.2 -requests~=2.31.0 +multidict==6.0.5 +sniffio==1.3.1 +urllib3==2.2.1 +yarl==1.9.4 From 7fd61493770dc6270dcff2578ae0ddf705c6a7e7 Mon Sep 17 00:00:00 2001 From: Mike Tung Date: Tue, 7 May 2024 17:49:54 -0400 Subject: [PATCH 4/4] fixed issue with synchronous code killing discord healthcheck and crashing bot. - removed requests library in favor of aiohttp for asynchronous requests - updated dalle logic to work with change - updated dependencies --- CHANGELOG.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aca2a1..8b4dd40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,20 @@ # Jainy Bot Changelog -## Version v1.1.0 +## Version v1.1.1 -**Features** +**Fixes** -- Added dalle command to generate images via ai +- Fixed synchronous code blocking discord health check **Dev Deps** +- removed requests lib for aiohttp to handle async requests + +## Version v1.1.0 +**Features** +- Added dalle command to generate images via ai + +**Dev Deps** - added requests for api calls ## Version v1.0.0