From eed370e6b034acdfb39cc127b982755dbcd79928 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Apr 2024 13:43:51 -0400 Subject: [PATCH] feat(discord): add dynamic slash commands --- .gitattributes | 3 + Dockerfile | 14 ++- README.md | 47 ++++------ requirements.txt | 2 + src/common.py | 12 +++ src/discord/cogs/support_commands.py | 133 ++++++++++++++++++++++++++- src/reddit/bot.py | 10 +- 7 files changed, 181 insertions(+), 40 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ccee08 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# ensure dockerfiles are checked out with LF line endings +Dockerfile text eol=lf +*.dockerfile text eol=lf diff --git a/Dockerfile b/Dockerfile index 81e8344..f23a642 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1.4 # artifacts: false # platforms: linux/amd64 -FROM python:3.11.3-slim-bullseye +FROM python:3.11-slim-bookworm # Basic config ARG DAILY_TASKS=true @@ -37,6 +37,18 @@ ENV DISCORD_WEBHOOK=$DISCORD_WEBHOOK ENV GRAVATAR_EMAIL=$GRAVATAR_EMAIL ENV REDIRECT_URI=$REDIRECT_URI +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +# install dependencies +RUN <<_DEPS +#!/bin/bash +set -e +apt-get update -y +apt-get install -y --no-install-recommends \ + git +apt-get clean +rm -rf /var/lib/apt/lists/* +_DEPS + VOLUME /data WORKDIR /app/ diff --git a/README.md b/README.md index 1771ab8..2d1f458 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,9 @@ platforms such as GitHub discussions/issues could be added. ### Discord Slash Commands -| command | description | argument 1 | -|----------|---------------------------------------------------|---------------------| -| /help | Return help message | | -| /channel | Suggest to move discussion to a different channel | recommended_channel | -| /docs | Return the specified docs page | user | -| /donate | Return donation links | user | -| /random | Return a random video game quote | | +| command | description | +|----------|----------------------------------------------------------| +| /help | Return help message, for a list of all possible commands | ## Instructions @@ -32,16 +28,18 @@ platforms such as GitHub discussions/issues could be added. :exclamation: if using Docker these can be arguments. :warning: Never publicly expose your tokens, secrets, or ids. -| variable | required | default | description | -|----------------------|----------|---------|---------------------------------------------------------------| -| DISCORD_BOT_TOKEN | True | None | Token from Bot page on discord developer portal. | -| DAILY_TASKS | False | true | Daily tasks on or off. | -| DAILY_RELEASES | False | true | Send a message for each game released on this day in history. | -| DAILY_CHANNEL_ID | False | None | Required if daily_tasks is enabled. | -| DAILY_TASKS_UTC_HOUR | False | 12 | The hour to run daily tasks. | -| GRAVATAR_EMAIL | False | None | Gravatar email address for bot avatar. | -| IGDB_CLIENT_ID | False | None | Required if daily_releases is enabled. | -| IGDB_CLIENT_SECRET | False | None | Required if daily_releases is enabled. | +| variable | required | default | description | +|-------------------------|----------|------------------------------------------------------|---------------------------------------------------------------| +| DISCORD_BOT_TOKEN | True | `None` | Token from Bot page on discord developer portal. | +| DAILY_TASKS | False | `true` | Daily tasks on or off. | +| DAILY_RELEASES | False | `true` | Send a message for each game released on this day in history. | +| DAILY_CHANNEL_ID | False | `None` | Required if daily_tasks is enabled. | +| DAILY_TASKS_UTC_HOUR | False | `12` | The hour to run daily tasks. | +| GRAVATAR_EMAIL | False | `None` | Gravatar email address for bot avatar. | +| IGDB_CLIENT_ID | False | `None` | Required if daily_releases is enabled. | +| IGDB_CLIENT_SECRET | False | `None` | Required if daily_releases is enabled. | +| SUPPORT_COMMANDS_REPO | False | `https://github.com/LizardByte/support-bot-commands` | Repository for support commands. | +| SUPPORT_COMMANDS_BRANCH | False | `master` | Branch for support commands. | * Running bot: * `python -m src` @@ -52,9 +50,7 @@ platforms such as GitHub discussions/issues could be added. ### Reddit * Set up an application at [reddit apps](https://www.reddit.com/prefs/apps/). - * The redirect uri must be publicly accessible. - * If using Replit, enter `https://..repl.co` - * Otherwise, it is recommended to use [Nginx Proxy Manager](https://nginxproxymanager.com/) and [Duck DNS](https://www.duckdns.org/) + * The redirect uri should be https://localhost:8080 * Take note of the `client_id` and `client_secret` * Enter the following as environment variables @@ -65,13 +61,8 @@ platforms such as GitHub discussions/issues could be added. | PRAW_SUBREDDIT | True | None | Subreddit to monitor (reddit user should be moderator of the subreddit) | | DISCORD_WEBHOOK | False | None | URL of webhook to send discord notifications to | | GRAVATAR_EMAIL | False | None | Gravatar email address to get avatar from | - | REDIRECT_URI | True | None | The redirect URI entered during the reddit application setup | + | REDDIT_USERNAME | True | None | Reddit username | +* | REDDIT_PASSWORD | True | None | Reddit password | -* First run (or manually get a new refresh token): - * Delete `./data/refresh_token` file if needed - * `python -m src` - * Open browser and login to reddit account to use with bot - * Navigate to URL printed in console and accept - * `./data/refresh_token` file is written -* Running after refresh_token already obtained: +* Running bot: * `python -m src` diff --git a/requirements.txt b/requirements.txt index c72e578..e42e741 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ beautifulsoup4==4.12.3 Flask==3.0.3 +GitPython==3.1.43 igdb-api-v4==0.3.2 libgravatar==1.0.4 +mistletoe==1.3.0 praw==7.7.1 py-cord==2.5.0 python-dotenv==1.0.1 diff --git a/src/common.py b/src/common.py index 1c16875..9780f3d 100644 --- a/src/common.py +++ b/src/common.py @@ -36,8 +36,20 @@ def get_avatar_bytes(): return avatar_img +def get_data_dir(): + # parent directory name of this file, not full path + parent_dir = os.path.dirname(os.path.abspath(__file__)).split(os.sep)[-1] + if parent_dir == 'app': # running in Docker container + d = '/data' + else: # running locally + d = os.path.join(os.getcwd(), 'data') + os.makedirs(d, exist_ok=True) + return d + + # constants avatar = get_bot_avatar(gravatar=os.environ['GRAVATAR_EMAIL']) org_name = 'LizardByte' bot_name = f'{org_name}-Bot' bot_url = 'https://app.lizardbyte.dev' +data_dir = get_data_dir() diff --git a/src/discord/cogs/support_commands.py b/src/discord/cogs/support_commands.py index 5b04d4c..edb1502 100644 --- a/src/discord/cogs/support_commands.py +++ b/src/discord/cogs/support_commands.py @@ -1,16 +1,145 @@ +# standard imports +import datetime +import os + # lib imports import discord from discord.commands import Option +from discord.ext import tasks +import git +import mistletoe +from mistletoe.markdown_renderer import MarkdownRenderer # local imports -from src.common import avatar, bot_name +from src.common import avatar, bot_name, data_dir from src.discord.views import DocsCommandView from src.discord import cogs_common class SupportCommandsCog(discord.Cog): def __init__(self, bot): - self.bot = bot + self.bot: discord.Bot = bot + + self.commands = {} + self.commands_for_removal = [] + + self.repo_url = os.getenv("SUPPORT_COMMANDS_REPO", "https://github.com/LizardByte/support-bot-commands") + self.repo_branch = os.getenv("SUPPORT_COMMANDS_BRANCH", "master") + self.local_dir = os.path.join(data_dir, "support-bot-commands") + self.commands_dir = os.path.join(self.local_dir, "docs") + self.relative_commands_dir = os.path.relpath(self.commands_dir, self.local_dir) + + @discord.Cog.listener() + async def on_ready(self): + # Clone/update the repository + self.update_repo() + + # Create commands + self.create_commands() + + # Start the self update task + self.self_update.start() + + @tasks.loop(minutes=15.0) + async def self_update(self): + self.update_repo() + self.create_commands() + await self.bot.sync_commands() + + def update_repo(self): + # Clone or pull the repository + if not os.path.exists(self.local_dir): + repo = git.Repo.clone_from(self.repo_url, self.local_dir) + else: + repo = git.Repo(self.local_dir) + origin = repo.remotes.origin + + # Fetch the latest changes from the upstream + origin.fetch() + + # Reset the local branch to match the upstream + repo.git.reset('--hard', f'origin/{self.repo_branch}') + + for f in repo.untracked_files: + # remove untracked files + os.remove(os.path.join(self.local_dir, f)) + + # Checkout the branch + repo.git.checkout(self.repo_branch) + + def get_project_commands(self): + projects = [] + for project in os.listdir(self.commands_dir): + project_dir = os.path.join(self.commands_dir, project) + if os.path.isdir(project_dir): + projects.append(project) + return projects + + def create_commands(self): + for project in self.get_project_commands(): + project_dir = os.path.join(self.commands_dir, project) + if os.path.isdir(project_dir): + self.create_project_commands(project=project, project_dir=project_dir) + + def create_project_commands(self, project, project_dir): + # Get the list of commands in the project directory + command_choices = [] + for cmd in os.listdir(project_dir): + cmd_path = os.path.join(project_dir, cmd) + if os.path.isfile(cmd_path) and cmd.endswith('.md'): + cmd_name = os.path.splitext(cmd)[0] + command_choices.append(discord.OptionChoice(name=cmd_name, value=cmd_name)) + + # Check if a command with the same name already exists + if project in self.commands: + # Update the command options + project_command = self.commands[project] + project_command.options = [ + Option( + name='command', + description='The command to run', + type=discord.SlashCommandOptionType.string, + choices=command_choices, + required=True, + ) + ] + else: + # Create a slash command for the project + @self.bot.slash_command(name=project, description=f"Commands for the {project} project.", + options=[ + Option( + name='command', + description='The command to run', + type=discord.SlashCommandOptionType.string, + choices=command_choices, + required=True, + ) + ]) + async def project_command(ctx: discord.ApplicationContext, command: str): + # Determine the command file path + command_file = os.path.join(project_dir, f"{command}.md") + + # Read the command file + with open(command_file, "r", encoding='utf-8') as file: + with MarkdownRenderer( + max_line_length=4096, # this must be set to reflow the text + normalize_whitespace=True) as renderer: + description = renderer.render(mistletoe.Document(file)) + + source_url = (f"{self.repo_url}/blob/{self.repo_branch}/{self.relative_commands_dir}/" + f"{project}/{command}.md") + + embed = discord.Embed( + color=0xF1C232, + description=description, + timestamp=datetime.datetime.now(tz=datetime.timezone.utc), + title="See on GitHub", + url=source_url, + ) + embed.set_footer(text=f"Requested by {ctx.author.display_name}") + await ctx.respond(embed=embed, ephemeral=False) + + self.commands[project] = project_command @discord.slash_command( name="docs", diff --git a/src/reddit/bot.py b/src/reddit/bot.py index f0f1915..64cf07d 100644 --- a/src/reddit/bot.py +++ b/src/reddit/bot.py @@ -42,15 +42,7 @@ def __init__(self, **kwargs): self.redirect_uri = kwargs['redirect_uri'] # directories - # parent directory name of this file, not full path - parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))).split(os.sep)[-1] - print(f'PARENT_DIR: {parent_dir}') - if parent_dir == 'app': # running in Docker container - self.data_dir = '/data' - else: # running locally - self.data_dir = os.path.join(os.getcwd(), 'data') - print(F'DATA_DIR: {self.data_dir}') - os.makedirs(self.data_dir, exist_ok=True) + self.data_dir = common.data_dir self.last_online_file = os.path.join(self.data_dir, 'last_online') self.reddit = praw.Reddit(