diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e3b47ad..b67ea63 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,14 @@ version: 2 updates: + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "daily" + time: "00:00" + target-branch: "nightly" + open-pull-requests-limit: 10 + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/label-actions.yml b/.github/label-actions.yml index 6d0a74a..2949601 100644 --- a/.github/label-actions.yml +++ b/.github/label-actions.yml @@ -25,8 +25,8 @@ invalid:duplicate: invalid:support: comment: > :wave: @{issue-author}, we use the issue tracker exclusively for bug reports. - However, this issue appears to be a support request. Please use - [Discord](https://docs.lizardbyte.dev/about/support.html#discord) for support issues. Thanks. + However, this issue appears to be a support request. Please use our + [Support Center](https://app.lizardbyte.dev/support) for support issues. Thanks. close: true lock: true lock-reason: 'off-topic' diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 7eabd7a..ed36038 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -36,6 +36,19 @@ jobs: outputs: dockerfile: ${{ steps.check.outputs.dockerfile }} + lint_dockerfile: + name: Lint Dockerfile + needs: [check_dockerfile] + if: ${{ needs.check_dockerfile.outputs.dockerfile == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Lint Dockerfile + uses: actions/checkout@v3 + + - uses: hadolint/hadolint-action@v2.1.0 + with: + dockerfile: ./Dockerfile + check_changelog: name: Check Changelog needs: [check_dockerfile] @@ -68,6 +81,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 + with: + submodules: recursive - name: Prepare id: prepare diff --git a/.github/workflows/issues-stale.yml b/.github/workflows/issues-stale.yml index 225b07d..d6e63e7 100644 --- a/.github/workflows/issues-stale.yml +++ b/.github/workflows/issues-stale.yml @@ -18,7 +18,7 @@ jobs: uses: actions/stale@v5 with: close-issue-message: > - This issue was closed because it has been stalled for 5 days with no activity. + This issue was closed because it has been stalled for 10 days with no activity. close-pr-message: > This PR was closed because it has been stalled for 10 days with no activity. days-before-stale: 90 @@ -28,12 +28,13 @@ jobs: exempt-pr-labels: 'dependencies,l10n' stale-issue-label: 'stale' stale-issue-message: > - This issue is stale because it has been open for 30 days with no activity. - Comment or remove the stale label, otherwise this will be closed in 5 days. + This issue is stale because it has been open for 90 days with no activity. + Comment or remove the stale label, otherwise this will be closed in 10 days. stale-pr-label: 'stale' stale-pr-message: > This PR is stale because it has been open for 90 days with no activity. Comment or remove the stale label, otherwise this will be closed in 10 days. + repo-token: ${{ secrets.GH_BOT_TOKEN }} - name: Invalid Template uses: actions/stale@v5 @@ -52,3 +53,4 @@ jobs: stale-pr-label: 'invalid:template-incomplete' stale-pr-message: > Invalid PR template. + repo-token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml index f89975f..6ba4444 100644 --- a/.github/workflows/issues.yml +++ b/.github/workflows/issues.yml @@ -19,4 +19,4 @@ jobs: - name: Label Actions uses: dessant/label-actions@v2 with: - github-token: ${{ github.token }} + github-token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6102cb5..6a55c26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [0.1.1] - 2022-08-20 +### Changed +- Enable timeout callback for `/docs` command after 45s +- Incomplete `/docs` commands are deleted 30s after the timeout period +- `/docs` command is reset for each call +- Fix url returned for `/docs` command when `None` was selected as category +- Move constants to `discord_constants.py` +- Move avatar related items to `discord_avatar.py` +### Added +- Add `discord_modals.py` +### Dependencies +- Bump flask from 2.2.1 to 2.2.2 +- Bump py-cord from 2.0.0 to 2.0.1 + + ## [0.1.0] - 2022-08-07 ### Changed - Select Menus added to `docs` slash command to give finer control of returned documentation url diff --git a/Dockerfile b/Dockerfile index fece6e2..00eeb93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,9 @@ ENV GRAVATAR_EMAIL=$GRAVATAR_EMAIL ENV IGDB_CLIENT_ID=$IGDB_CLIENT_ID ENV IGDB_CLIENT_SECRET=$IGDB_CLIENT_SECRET +RUN mkdir /app +WORKDIR /app/ + COPY requirements.txt . COPY *.py . RUN pip install --no-cache-dir -r requirements.txt diff --git a/discord_avatar.py b/discord_avatar.py new file mode 100644 index 0000000..040d030 --- /dev/null +++ b/discord_avatar.py @@ -0,0 +1,15 @@ +# standard imports +from io import BytesIO +import os + +# lib imports +import requests + +# local imports +from discord_helpers import get_bot_avatar + +# avatar +avatar = get_bot_avatar(gravatar=os.environ['GRAVATAR_EMAIL']) + +avatar_response = requests.get(url=avatar) +avatar_img = BytesIO(avatar_response.content).read() diff --git a/discord_bot.py b/discord_bot.py index 1698880..aa9c16b 100644 --- a/discord_bot.py +++ b/discord_bot.py @@ -1,6 +1,5 @@ # standard imports from datetime import datetime -from io import BytesIO import json import os import random @@ -13,30 +12,24 @@ import requests # local imports -from discord_helpers import get_bot_avatar, igdb_authorization, month_dictionary -from discord_views import DocsCommandView, DonateCommandView +from discord_constants import org_name, bot_name, bot_url +from discord_helpers import igdb_authorization, month_dictionary import keep_alive # development imports from dotenv import load_dotenv load_dotenv(override=False) # environment secrets take priority over .env file +if True: # hack for flake8 + from discord_avatar import avatar, avatar_img + from discord_views import DocsCommandView, DonateCommandView, RefundCommandView # constants bot_token = os.environ['BOT_TOKEN'] bot = discord.Bot(intents=discord.Intents.all(), auto_sync_commands=True) -org_name = 'LizardByte' -bot_name = f'{org_name}-Bot' -bot_url = 'https://app.lizardbyte.dev' user_mention_desc = 'Select the user to mention' -# avatar -avatar = get_bot_avatar(gravatar=os.environ['GRAVATAR_EMAIL']) - -response = requests.get(url=avatar) -avatar_img = BytesIO(response.content).read() - # context reference # https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context # https://docs.pycord.dev/en/master/ext/commands/api.html#discord.ext.commands.Context @@ -198,13 +191,55 @@ async def docs_command(ctx: discord.ApplicationContext, user : discord.Commands.Option Username to mention in response. """ - embed = discord.Embed(title="Select a project", color=0xDC143C) + embed = discord.Embed(title="Select a project", color=0xF1C232) + embed.set_footer(text=bot_name, icon_url=avatar) + + if user: + await ctx.respond( + f'{ctx.author.mention}, {user.mention}', + embed=embed, + ephemeral=False, + view=DocsCommandView(ctx=ctx) + ) + else: + await ctx.respond( + f'{ctx.author.mention}', + embed=embed, + ephemeral=False, + view=DocsCommandView(ctx=ctx) + ) + + +@bot.slash_command(name="refund", + description="Refund form for unhappy customers." + ) +async def refund_command(ctx: discord.ApplicationContext, + user: Option(discord.Member, + description=user_mention_desc, + required=False)): + """ + The ``refund`` slash command. + + Sends a discord embed, with a `Modal`, to the server and channel where the command was issued. This command is + pure satire. + + Parameters + ---------- + ctx : discord.ApplicationContext + Request message context. + user : discord.Commands.Option + Username to mention in response. + """ + embed = discord.Embed(title="Refund request", + description="Original purchase price: $0.00\n\n" + "Select the button below to request a full refund!", + color=0xDC143C) embed.set_footer(text=bot_name, icon_url=avatar) if user: - await ctx.respond(f'{ctx.author.mention}, {user.mention}', embed=embed, view=DocsCommandView(ctx=ctx)) + await ctx.respond(user.mention, embed=embed, view=RefundCommandView()) else: - await ctx.respond(f'{ctx.author.mention}', embed=embed, view=DocsCommandView(ctx=ctx)) + await ctx.respond(embed=embed, view=RefundCommandView()) @tasks.loop(minutes=60.0) diff --git a/discord_constants.py b/discord_constants.py new file mode 100644 index 0000000..af6eb86 --- /dev/null +++ b/discord_constants.py @@ -0,0 +1,3 @@ +org_name = 'LizardByte' +bot_name = f'{org_name}-Bot' +bot_url = 'https://app.lizardbyte.dev' diff --git a/discord_modals.py b/discord_modals.py new file mode 100644 index 0000000..6385bd0 --- /dev/null +++ b/discord_modals.py @@ -0,0 +1,21 @@ +# lib imports +import discord + + +class RefundModal(discord.ui.Modal): + """ + Class representing `discord.ui.Modal` for ``refund`` slash command. + """ + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.add_item(discord.ui.InputText(label="Name")) + self.add_item(discord.ui.InputText(label="Email")) + self.add_item(discord.ui.InputText(label="Purchase Date")) + + async def callback(self, interaction: discord.Interaction): + embed = discord.Embed(title="Refund request completed", + description="Your refund is being processed!") + embed.add_field(name="Original price", value="$0.00") + embed.add_field(name="Refund amount", value="$0.00") + await interaction.response.send_message(embeds=[embed]) diff --git a/discord_views.py b/discord_views.py index 2877f2e..b852417 100644 --- a/discord_views.py +++ b/discord_views.py @@ -1,14 +1,21 @@ +# standard imports +from typing import Tuple + # lib imports from bs4 import BeautifulSoup import discord from discord.ui.select import Select +from discord.ui.button import Button import requests # local imports +from discord_avatar import avatar +from discord_constants import bot_name from discord_helpers import get_json +from discord_modals import RefundModal -class DocsCommandDefaultProjects(object): +class DocsCommandDefaultProjects: """ Class representing default projects for ``docs`` slash command. @@ -67,9 +74,10 @@ class DocsCommandView(discord.ui.View): A list of sections for the selected page. """ def __init__(self, ctx: discord.ApplicationContext): - super().__init__(timeout=60) + super().__init__(timeout=45) self.ctx = ctx + self.interaction = None # final values self.docs_project = None @@ -86,19 +94,68 @@ def __init__(self, ctx: discord.ApplicationContext): self.pages = None self.sections = None - # timeout is not working, see: https://github.com/Pycord-Development/pycord/issues/1549 - # async def on_timeout(self): - # """ - # Timeout callback. - # - # Disable children items, and edit the original message. - # """ - # for child in self.children: - # child.disabled = True - # - # embed = discord.Embed(title="...", color=0xDC143C) - # embed.set_footer(text=bot_name, icon_url=avatar) - # await self.message.edit(embed=embed, view=self) + # reset the first select menu because it remembers the last selected value + self.children[0].options = DocsCommandDefaultProjects().projects_options + + # check selections completed + def check_completion_status(self) -> Tuple[bool, discord.Embed]: + """ + Check if Select Menu choices are valid. + + Obtaining a valid docs url depends on the selections made in the select menus. This function checks if + the conditions are met to provide a valid docs url. + + Returns + ------- + Tuple[bool, discord.Embed] + """ + complete = False + embed = discord.Embed() + embed.set_footer(text=bot_name, icon_url=avatar) + + url = f'{self.docs_version}{self.docs_section}' + + if self.docs_project and self.docs_version: # the project and version are selected + if self.docs_category is not None: # category has a value, which may be "" + if self.docs_category: # category is selected, so the next item must not be blank + if self.docs_page is not None and self.docs_section is not None: # info is complete + complete = True + else: # info is complete IF category is "" + complete = True + + if complete: + embed.title = f'{self.docs_project} | {self.docs_category}' if self.docs_category else self.docs_project + embed.description = f'The selected docs are available at {url}' + embed.color = 0x39FF14 # PyCharm complains that the color is read only, but this works anyway + embed.url = url + else: + # info is not complete + embed.title = "Select the remaining values" + embed.description = None + embed.color = 0xF1C232 + embed.url = None + + return complete, embed + + async def on_timeout(self): + """ + Timeout callback. + + Disable children items, and edit the original message. + """ + for child in self.children: + child.disabled = True + + complete, embed = self.check_completion_status() + + if not complete: + embed.title = "Command timed out..." + embed.color = 0xDC143C + delete_after = 30 # delete after 30 seconds + else: + delete_after = None # do not delete + + await self.ctx.interaction.edit_original_message(embed=embed, view=self, delete_after=delete_after) async def interaction_check(self, interaction: discord.Interaction) -> bool: """ @@ -135,6 +192,8 @@ async def callback(self, select: Select, interaction: discord.Interaction): interaction : discord.Interaction The original discord interaction object. """ + self.interaction = interaction + select_index = None index = 0 for child in self.children: @@ -280,41 +339,12 @@ async def callback(self, select: Select, interaction: discord.Interaction): self.docs_page = None self.docs_section = None elif select == self.children[2]: # chose the docs category - self.docs_page = None - self.docs_section = None + self.docs_page = None if self.children[2].values[0] != 'None' else '' + self.docs_section = None if self.children[2].values[0] != 'None' else '' elif select == self.children[3]: # chose the docs page self.docs_section = None - # get the original embed - embed = interaction.message.embeds[0] # we know there is only 1 embed - - if self.docs_project and self.docs_version: # the project and version are selected - url = f'{self.docs_version}{self.docs_section}' - - if self.docs_category is not None: # category has a value, which may be "" - if self.docs_category: # category is selected, so the next item must not be blank - if self.docs_page is not None and self.docs_section is not None: # info is complete - embed.title = f'{self.docs_project} | {self.docs_category}' - embed.description = f'The selected docs are available at {url}' - embed.color = 0x39FF14 # PyCharm complains that the color is read only, but this works anyway - embed.url = url - - await interaction.response.edit_message(embed=embed, view=self) - return - else: # info is complete IF category is "" - embed.title = f'{self.docs_project} | {self.docs_category}' - embed.description = f'The selected docs are available at {url}' - embed.color = 0x39FF14 # PyCharm complains that the color is read only, but this works anyway - embed.url = url - - await interaction.response.edit_message(embed=embed, view=self) - return - - # info is not complete - embed.title = "Select the remaining values" - embed.description = None - embed.color = 0xDC143C - embed.url = None + complete, embed = self.check_completion_status() await interaction.response.edit_message(embed=embed, view=self) @@ -326,7 +356,7 @@ async def callback(self, select: Select, interaction: discord.Interaction): options=DocsCommandDefaultProjects().projects_options ) async def slug_callback(self, select: Select, interaction: discord.Interaction): - await self.callback(select, interaction) + await self.callback(select=select, interaction=interaction) @discord.ui.select( placeholder="Choose version...", @@ -408,3 +438,15 @@ def __init__(self): ) self.add_item(button) + + +class RefundCommandView(discord.ui.View): + """ + Class representing `discord.ui.View` for ``refund`` slash command. + """ + def __init__(self): + super().__init__(timeout=None) # timeout of the view must be set to None, view is persistent + + @discord.ui.button(label="Refund form", style=discord.ButtonStyle.red, custom_id='button-refund') + async def button_callback(self, button: Button, interaction: discord.Interaction): + await interaction.response.send_modal(RefundModal(title="Refund Request Form")) diff --git a/pyproject.toml b/pyproject.toml index a458d53..4bc5f84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,8 @@ authors = ["ReenigneArcher"] [tool.poetry.dependencies] python = "^3.8" -py-cord = "2.0.0" -Flask = "2.2.1" +py-cord = "2.0.1" +Flask = "2.2.2" igdb-api-v4 = "0.0.5" python-dotenv = "0.20.0" requests = "2.28.1" diff --git a/requirements.txt b/requirements.txt index 0f5fe4a..ac8fd2e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ beautifulsoup4==4.11.1 -Flask==2.1.3 +Flask==2.2.2 igdb-api-v4==0.0.5 libgravatar==1.0.0 -py-cord==2.0.0 +py-cord==2.0.1 python-dotenv==0.20.0 requests==2.28.1