Skip to content

Commit ad71781

Browse files
feat(discord): add dynamic slash commands (#277)
1 parent a19e9a6 commit ad71781

File tree

7 files changed

+181
-40
lines changed

7 files changed

+181
-40
lines changed

.gitattributes

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# ensure dockerfiles are checked out with LF line endings
2+
Dockerfile text eol=lf
3+
*.dockerfile text eol=lf

Dockerfile

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# syntax=docker/dockerfile:1.4
22
# artifacts: false
33
# platforms: linux/amd64
4-
FROM python:3.11.3-slim-bullseye
4+
FROM python:3.11-slim-bookworm
55

66
# Basic config
77
ARG DAILY_TASKS=true
@@ -37,6 +37,18 @@ ENV DISCORD_WEBHOOK=$DISCORD_WEBHOOK
3737
ENV GRAVATAR_EMAIL=$GRAVATAR_EMAIL
3838
ENV REDIRECT_URI=$REDIRECT_URI
3939

40+
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
41+
# install dependencies
42+
RUN <<_DEPS
43+
#!/bin/bash
44+
set -e
45+
apt-get update -y
46+
apt-get install -y --no-install-recommends \
47+
git
48+
apt-get clean
49+
rm -rf /var/lib/apt/lists/*
50+
_DEPS
51+
4052
VOLUME /data
4153

4254
WORKDIR /app/

README.md

Lines changed: 19 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,9 @@ platforms such as GitHub discussions/issues could be added.
1010

1111
### Discord Slash Commands
1212

13-
| command | description | argument 1 |
14-
|----------|---------------------------------------------------|---------------------|
15-
| /help | Return help message | |
16-
| /channel | Suggest to move discussion to a different channel | recommended_channel |
17-
| /docs | Return the specified docs page | user |
18-
| /donate | Return donation links | user |
19-
| /random | Return a random video game quote | |
13+
| command | description |
14+
|----------|----------------------------------------------------------|
15+
| /help | Return help message, for a list of all possible commands |
2016

2117

2218
## Instructions
@@ -32,16 +28,18 @@ platforms such as GitHub discussions/issues could be added.
3228
:exclamation: if using Docker these can be arguments.
3329
:warning: Never publicly expose your tokens, secrets, or ids.
3430

35-
| variable | required | default | description |
36-
|----------------------|----------|---------|---------------------------------------------------------------|
37-
| DISCORD_BOT_TOKEN | True | None | Token from Bot page on discord developer portal. |
38-
| DAILY_TASKS | False | true | Daily tasks on or off. |
39-
| DAILY_RELEASES | False | true | Send a message for each game released on this day in history. |
40-
| DAILY_CHANNEL_ID | False | None | Required if daily_tasks is enabled. |
41-
| DAILY_TASKS_UTC_HOUR | False | 12 | The hour to run daily tasks. |
42-
| GRAVATAR_EMAIL | False | None | Gravatar email address for bot avatar. |
43-
| IGDB_CLIENT_ID | False | None | Required if daily_releases is enabled. |
44-
| IGDB_CLIENT_SECRET | False | None | Required if daily_releases is enabled. |
31+
| variable | required | default | description |
32+
|-------------------------|----------|------------------------------------------------------|---------------------------------------------------------------|
33+
| DISCORD_BOT_TOKEN | True | `None` | Token from Bot page on discord developer portal. |
34+
| DAILY_TASKS | False | `true` | Daily tasks on or off. |
35+
| DAILY_RELEASES | False | `true` | Send a message for each game released on this day in history. |
36+
| DAILY_CHANNEL_ID | False | `None` | Required if daily_tasks is enabled. |
37+
| DAILY_TASKS_UTC_HOUR | False | `12` | The hour to run daily tasks. |
38+
| GRAVATAR_EMAIL | False | `None` | Gravatar email address for bot avatar. |
39+
| IGDB_CLIENT_ID | False | `None` | Required if daily_releases is enabled. |
40+
| IGDB_CLIENT_SECRET | False | `None` | Required if daily_releases is enabled. |
41+
| SUPPORT_COMMANDS_REPO | False | `https://github.com/LizardByte/support-bot-commands` | Repository for support commands. |
42+
| SUPPORT_COMMANDS_BRANCH | False | `master` | Branch for support commands. |
4543

4644
* Running bot:
4745
* `python -m src`
@@ -52,9 +50,7 @@ platforms such as GitHub discussions/issues could be added.
5250
### Reddit
5351

5452
* Set up an application at [reddit apps](https://www.reddit.com/prefs/apps/).
55-
* The redirect uri must be publicly accessible.
56-
* If using Replit, enter `https://<REPL_SLUG>.<REPL_OWNER>.repl.co`
57-
* Otherwise, it is recommended to use [Nginx Proxy Manager](https://nginxproxymanager.com/) and [Duck DNS](https://www.duckdns.org/)
53+
* The redirect uri should be https://localhost:8080
5854
* Take note of the `client_id` and `client_secret`
5955
* Enter the following as environment variables
6056

@@ -65,13 +61,8 @@ platforms such as GitHub discussions/issues could be added.
6561
| PRAW_SUBREDDIT | True | None | Subreddit to monitor (reddit user should be moderator of the subreddit) |
6662
| DISCORD_WEBHOOK | False | None | URL of webhook to send discord notifications to |
6763
| GRAVATAR_EMAIL | False | None | Gravatar email address to get avatar from |
68-
| REDIRECT_URI | True | None | The redirect URI entered during the reddit application setup |
64+
| REDDIT_USERNAME | True | None | Reddit username |
65+
* | REDDIT_PASSWORD | True | None | Reddit password |
6966

70-
* First run (or manually get a new refresh token):
71-
* Delete `./data/refresh_token` file if needed
72-
* `python -m src`
73-
* Open browser and login to reddit account to use with bot
74-
* Navigate to URL printed in console and accept
75-
* `./data/refresh_token` file is written
76-
* Running after refresh_token already obtained:
67+
* Running bot:
7768
* `python -m src`

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
beautifulsoup4==4.12.3
22
Flask==3.0.3
3+
GitPython==3.1.43
34
igdb-api-v4==0.3.2
45
libgravatar==1.0.4
6+
mistletoe==1.3.0
57
praw==7.7.1
68
py-cord==2.5.0
79
python-dotenv==1.0.1

src/common.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,20 @@ def get_avatar_bytes():
3636
return avatar_img
3737

3838

39+
def get_data_dir():
40+
# parent directory name of this file, not full path
41+
parent_dir = os.path.dirname(os.path.abspath(__file__)).split(os.sep)[-1]
42+
if parent_dir == 'app': # running in Docker container
43+
d = '/data'
44+
else: # running locally
45+
d = os.path.join(os.getcwd(), 'data')
46+
os.makedirs(d, exist_ok=True)
47+
return d
48+
49+
3950
# constants
4051
avatar = get_bot_avatar(gravatar=os.environ['GRAVATAR_EMAIL'])
4152
org_name = 'LizardByte'
4253
bot_name = f'{org_name}-Bot'
4354
bot_url = 'https://app.lizardbyte.dev'
55+
data_dir = get_data_dir()

src/discord/cogs/support_commands.py

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,145 @@
1+
# standard imports
2+
import datetime
3+
import os
4+
15
# lib imports
26
import discord
37
from discord.commands import Option
8+
from discord.ext import tasks
9+
import git
10+
import mistletoe
11+
from mistletoe.markdown_renderer import MarkdownRenderer
412

513
# local imports
6-
from src.common import avatar, bot_name
14+
from src.common import avatar, bot_name, data_dir
715
from src.discord.views import DocsCommandView
816
from src.discord import cogs_common
917

1018

1119
class SupportCommandsCog(discord.Cog):
1220
def __init__(self, bot):
13-
self.bot = bot
21+
self.bot: discord.Bot = bot
22+
23+
self.commands = {}
24+
self.commands_for_removal = []
25+
26+
self.repo_url = os.getenv("SUPPORT_COMMANDS_REPO", "https://github.com/LizardByte/support-bot-commands")
27+
self.repo_branch = os.getenv("SUPPORT_COMMANDS_BRANCH", "master")
28+
self.local_dir = os.path.join(data_dir, "support-bot-commands")
29+
self.commands_dir = os.path.join(self.local_dir, "docs")
30+
self.relative_commands_dir = os.path.relpath(self.commands_dir, self.local_dir)
31+
32+
@discord.Cog.listener()
33+
async def on_ready(self):
34+
# Clone/update the repository
35+
self.update_repo()
36+
37+
# Create commands
38+
self.create_commands()
39+
40+
# Start the self update task
41+
self.self_update.start()
42+
43+
@tasks.loop(minutes=15.0)
44+
async def self_update(self):
45+
self.update_repo()
46+
self.create_commands()
47+
await self.bot.sync_commands()
48+
49+
def update_repo(self):
50+
# Clone or pull the repository
51+
if not os.path.exists(self.local_dir):
52+
repo = git.Repo.clone_from(self.repo_url, self.local_dir)
53+
else:
54+
repo = git.Repo(self.local_dir)
55+
origin = repo.remotes.origin
56+
57+
# Fetch the latest changes from the upstream
58+
origin.fetch()
59+
60+
# Reset the local branch to match the upstream
61+
repo.git.reset('--hard', f'origin/{self.repo_branch}')
62+
63+
for f in repo.untracked_files:
64+
# remove untracked files
65+
os.remove(os.path.join(self.local_dir, f))
66+
67+
# Checkout the branch
68+
repo.git.checkout(self.repo_branch)
69+
70+
def get_project_commands(self):
71+
projects = []
72+
for project in os.listdir(self.commands_dir):
73+
project_dir = os.path.join(self.commands_dir, project)
74+
if os.path.isdir(project_dir):
75+
projects.append(project)
76+
return projects
77+
78+
def create_commands(self):
79+
for project in self.get_project_commands():
80+
project_dir = os.path.join(self.commands_dir, project)
81+
if os.path.isdir(project_dir):
82+
self.create_project_commands(project=project, project_dir=project_dir)
83+
84+
def create_project_commands(self, project, project_dir):
85+
# Get the list of commands in the project directory
86+
command_choices = []
87+
for cmd in os.listdir(project_dir):
88+
cmd_path = os.path.join(project_dir, cmd)
89+
if os.path.isfile(cmd_path) and cmd.endswith('.md'):
90+
cmd_name = os.path.splitext(cmd)[0]
91+
command_choices.append(discord.OptionChoice(name=cmd_name, value=cmd_name))
92+
93+
# Check if a command with the same name already exists
94+
if project in self.commands:
95+
# Update the command options
96+
project_command = self.commands[project]
97+
project_command.options = [
98+
Option(
99+
name='command',
100+
description='The command to run',
101+
type=discord.SlashCommandOptionType.string,
102+
choices=command_choices,
103+
required=True,
104+
)
105+
]
106+
else:
107+
# Create a slash command for the project
108+
@self.bot.slash_command(name=project, description=f"Commands for the {project} project.",
109+
options=[
110+
Option(
111+
name='command',
112+
description='The command to run',
113+
type=discord.SlashCommandOptionType.string,
114+
choices=command_choices,
115+
required=True,
116+
)
117+
])
118+
async def project_command(ctx: discord.ApplicationContext, command: str):
119+
# Determine the command file path
120+
command_file = os.path.join(project_dir, f"{command}.md")
121+
122+
# Read the command file
123+
with open(command_file, "r", encoding='utf-8') as file:
124+
with MarkdownRenderer(
125+
max_line_length=4096, # this must be set to reflow the text
126+
normalize_whitespace=True) as renderer:
127+
description = renderer.render(mistletoe.Document(file))
128+
129+
source_url = (f"{self.repo_url}/blob/{self.repo_branch}/{self.relative_commands_dir}/"
130+
f"{project}/{command}.md")
131+
132+
embed = discord.Embed(
133+
color=0xF1C232,
134+
description=description,
135+
timestamp=datetime.datetime.now(tz=datetime.timezone.utc),
136+
title="See on GitHub",
137+
url=source_url,
138+
)
139+
embed.set_footer(text=f"Requested by {ctx.author.display_name}")
140+
await ctx.respond(embed=embed, ephemeral=False)
141+
142+
self.commands[project] = project_command
14143

15144
@discord.slash_command(
16145
name="docs",

src/reddit/bot.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,7 @@ def __init__(self, **kwargs):
4242
self.redirect_uri = kwargs['redirect_uri']
4343

4444
# directories
45-
# parent directory name of this file, not full path
46-
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))).split(os.sep)[-1]
47-
print(f'PARENT_DIR: {parent_dir}')
48-
if parent_dir == 'app': # running in Docker container
49-
self.data_dir = '/data'
50-
else: # running locally
51-
self.data_dir = os.path.join(os.getcwd(), 'data')
52-
print(F'DATA_DIR: {self.data_dir}')
53-
os.makedirs(self.data_dir, exist_ok=True)
45+
self.data_dir = common.data_dir
5446

5547
self.last_online_file = os.path.join(self.data_dir, 'last_online')
5648
self.reddit = praw.Reddit(

0 commit comments

Comments
 (0)