Skip to content

Commit

Permalink
tests: add unit testing (#275)
Browse files Browse the repository at this point in the history
  • Loading branch information
ReenigneArcher authored Apr 27, 2024
1 parent 76a903c commit a19e9a6
Show file tree
Hide file tree
Showing 32 changed files with 1,977 additions and 945 deletions.
46 changes: 44 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,55 @@ on:
workflow_dispatch:

jobs:
release:
name: Release
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.11

- name: Install Python Dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
python -m pip install --upgrade -r requirements.txt
python -m pip install --upgrade -r requirements-dev.txt
- name: Test with pytest
id: test
env:
GITHUB_PYTEST: "true"
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_TEST_BOT_TOKEN }}
DISCORD_WEBHOOK: ${{ secrets.DISCORD_TEST_BOT_WEBHOOK }}
GRAVATAR_EMAIL: ${{ secrets.GRAVATAR_EMAIL }}
PRAW_CLIENT_ID: ${{ secrets.REDDIT_CLIENT_ID }}
PRAW_CLIENT_SECRET: ${{ secrets.REDDIT_CLIENT_SECRET }}
REDDIT_USERNAME: ${{ secrets.REDDIT_USERNAME }}
REDDIT_PASSWORD: ${{ secrets.REDDIT_PASSWORD }}
shell: bash
run: |
python -m pytest \
-rxXs \
--tb=native \
--verbose \
--cov=src \
tests
- name: Upload coverage
# any except canceled or skipped
if: >-
always() &&
(steps.test.outcome == 'success' || steps.test.outcome == 'failure') &&
startsWith(github.repository, 'LizardByte/')
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

- name: Setup Release
id: setup_release
uses: LizardByte/[email protected]
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ WORKDIR /app/
COPY . .
RUN python -m pip install --no-cache-dir -r requirements.txt

CMD ["python", "./src/main.py"]
CMD ["python", "-m", "src"]
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# support-bot
[![GitHub Workflow Status (CI)](https://img.shields.io/github/actions/workflow/status/lizardbyte/support-bot/ci.yml.svg?branch=master&label=CI%20build&logo=github&style=for-the-badge)](https://github.com/LizardByte/support-bot/actions/workflows/ci.yml?query=branch%3Amaster)
[![Codecov](https://img.shields.io/codecov/c/gh/LizardByte/support-bot.svg?token=900Q93P1DE&style=for-the-badge&logo=codecov&label=codecov)](https://app.codecov.io/gh/LizardByte/support-bot)

Support bot written in python to help manage LizardByte communities. The current focus is discord and reddit, but other
platforms such as GitHub discussions/issues could be added.

Expand Down Expand Up @@ -41,7 +44,7 @@ platforms such as GitHub discussions/issues could be added.
| IGDB_CLIENT_SECRET | False | None | Required if daily_releases is enabled. |

* Running bot:
* `python ./src/main.py`
* `python -m src`
* Invite bot to server:
* `https://discord.com/api/oauth2/authorize?client_id=<the client id of the bot>&permissions=8&scope=bot%20applications.commands`

Expand All @@ -66,9 +69,9 @@ platforms such as GitHub discussions/issues could be added.

* First run (or manually get a new refresh token):
* Delete `./data/refresh_token` file if needed
* `python ./src/main.py`
* `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:
* `python ./src/main.py`
* `python -m src`
15 changes: 15 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
codecov:
branch: master

coverage:
status:
project:
default:
target: auto
threshold: 10%

comment:
layout: "diff, flags, files"
behavior: default
require_changes: false # if true: only post the comment if coverage changes
5 changes: 5 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
betamax==0.9.0
betamax-serializers==0.2.1
pytest==8.1.1
pytest-asyncio==0.23.6
pytest-cov==5.0.0
15 changes: 9 additions & 6 deletions src/main.py → src/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

# local imports
if True: # hack for flake8
import discord_bot
import keep_alive
import reddit_bot
from src.discord import bot as d_bot
from src import keep_alive
from src.reddit import bot as r_bot


def main():
Expand All @@ -22,15 +22,18 @@ def main():
else:
keep_alive.keep_alive() # Start the web server

discord_bot.start() # Start the discord bot
reddit_bot.start() # Start the reddit bot
discord_bot = d_bot.Bot()
discord_bot.start_threaded() # Start the discord bot

reddit_bot = r_bot.Bot()
reddit_bot.start_threaded() # Start the reddit bot

try:
while discord_bot.bot_thread.is_alive() or reddit_bot.bot_thread.is_alive():
time.sleep(0.5)
except KeyboardInterrupt:
print("Keyboard Interrupt Detected")
discord_bot.stop() # Stop the discord bot
discord_bot.stop()
reddit_bot.stop()


Expand Down
18 changes: 18 additions & 0 deletions src/common.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# standard imports
from io import BytesIO
import os

# lib imports
from libgravatar import Gravatar
import requests


def get_bot_avatar(gravatar: str) -> str:
Expand All @@ -23,3 +28,16 @@ def get_bot_avatar(gravatar: str) -> str:
image_url = g.get_image()

return image_url


def get_avatar_bytes():
avatar_response = requests.get(url=avatar)
avatar_img = BytesIO(avatar_response.content).read()
return avatar_img


# 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'
Empty file added src/discord/__init__.py
Empty file.
98 changes: 98 additions & 0 deletions src/discord/bot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# standard imports
import asyncio
import os
import threading

# lib imports
import discord

# local imports
from src.common import bot_name, get_avatar_bytes, org_name
from src.discord.tasks import daily_task
from src.discord.views import DonateCommandView


class Bot(discord.Bot):
"""
Discord bot class.
This class extends the discord.Bot class to include additional functionality. The class will automatically
enable all intents and sync commands on startup. The class will also update the bot presence, username, and avatar
when the bot is ready.
"""
def __init__(self, *args, **kwargs):
if 'intents' not in kwargs:
intents = discord.Intents.all()
kwargs['intents'] = intents
if 'auto_sync_commands' not in kwargs:
kwargs['auto_sync_commands'] = True
super().__init__(*args, **kwargs)

self.bot_thread = threading.Thread(target=lambda: None)
self.token = os.environ['DISCORD_BOT_TOKEN']

self.load_extension(
name='src.discord.cogs',
recursive=True,
store=False,
)

async def on_ready(self):
"""
Bot on ready event.
This function runs when the discord bot is ready. The function will update the bot presence, update the username
and avatar, and start daily tasks.
"""
print(f'py-cord version: {discord.__version__}')
print(f'Logged in as {self.user.name} (ID: {self.user.id})')
print(f'Servers connected to: {self.guilds}')

# update the username and avatar
avatar_img = get_avatar_bytes()
if await self.user.avatar.read() != avatar_img or self.user.name != bot_name:
await self.user.edit(username=bot_name, avatar=avatar_img)

await self.change_presence(
activity=discord.Activity(type=discord.ActivityType.watching, name=f"the {org_name} server")
)

self.add_view(DonateCommandView()) # register view for persistent listening

await self.sync_commands()

try:
os.environ['DAILY_TASKS']
except KeyError:
daily_task.start(bot=self)
else:
if os.environ['DAILY_TASKS'].lower() == 'true':
daily_task.start(bot=self)
else:
print("'DAILY_TASKS' environment variable is disabled")

def start_threaded(self):
try:
# Login the bot in a separate thread
self.bot_thread = threading.Thread(
target=self.loop.run_until_complete,
args=(self.start(token=self.token),),
daemon=True
)
self.bot_thread.start()
except KeyboardInterrupt:
print("Keyboard Interrupt Detected")
self.stop()

def stop(self, future: asyncio.Future = None):
print("Attempting to stop daily tasks")
daily_task.stop()
print("Attempting to close bot connection")
if self.bot_thread is not None and self.bot_thread.is_alive():
asyncio.run_coroutine_threadsafe(self.close(), self.loop)
self.bot_thread.join()
print("Closed bot")

# Set a result for the future to mark it as done (unit testing)
if future and not future.done():
future.set_result(None)
106 changes: 106 additions & 0 deletions src/discord/cogs/base_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# lib imports
import discord
from discord.commands import Option

# local imports
from src.common import avatar, bot_name, org_name
from src.discord.views import DonateCommandView
from src.discord import cogs_common


class BaseCommandsCog(discord.Cog):
def __init__(self, bot):
self.bot = bot

@discord.slash_command(
name="help",
description=f"Get help with {bot_name}"
)
async def help_command(
self,
ctx: discord.ApplicationContext,
):
"""
Get help with the bot.
Parameters
----------
ctx : discord.ApplicationContext
Request message context.
"""
description = ""

for cmd in self.bot.commands:
if isinstance(cmd, discord.SlashCommandGroup):
for sub_cmd in cmd.subcommands:
description += await self.get_command_help(ctx=ctx, cmd=sub_cmd, group_name=cmd.name)
else:
description += await self.get_command_help(ctx=ctx, cmd=cmd)

embed = discord.Embed(description=description, color=0xE5A00D)
embed.set_footer(text=bot_name, icon_url=avatar)

await ctx.respond(embed=embed, ephemeral=True)

@staticmethod
async def get_command_help(
ctx: discord.ApplicationContext,
cmd: discord.command,
group_name=None,
) -> str:
description = ""
permissions = cmd.default_member_permissions
has_permissions = True
if permissions:
permissions_dict = {perm[0]: perm[1] for perm in permissions}
has_permissions = all(getattr(ctx.author.guild_permissions, perm, False) for perm in permissions_dict)
if has_permissions:
doc_help = cmd.description
if not doc_help:
doc_lines = cmd.callback.__doc__.split('\n')
doc_help = '\n'.join(line.strip() for line in doc_lines).split('\nParameters\n----------')[0].strip()
if group_name:
description = f"### `/{group_name} {cmd.name}`\n"
else:
description = f"### `/{cmd.name}`\n"
description += f"{doc_help}\n"
if cmd.options:
description += "\n**Options:**\n"
for option in cmd.options:
description += (f"`{option.name}`: {option.description} "
f"({'Required' if option.required else 'Optional'})\n")
description += "\n"
return description

@discord.slash_command(
name="donate",
description=f"Support the development of {org_name}"
)
async def donate_command(
self,
ctx: discord.ApplicationContext,
user: Option(
discord.Member,
description=cogs_common.user_mention_desc,
required=False,
),
):
"""
Sends a discord view, with various donation urls, to the server and channel where the
command was issued.
Parameters
----------
ctx : discord.ApplicationContext
Request message context.
user : discord.Member
Username to mention in response.
"""
if user:
await ctx.respond(f'Thank you for your support {user.mention}!', view=DonateCommandView())
else:
await ctx.respond('Thank you for your support!', view=DonateCommandView())


def setup(bot: discord.Bot):
bot.add_cog(BaseCommandsCog(bot=bot))
Loading

0 comments on commit a19e9a6

Please sign in to comment.