Skip to content

Commit a19e9a6

Browse files
tests: add unit testing (#275)
1 parent 76a903c commit a19e9a6

32 files changed

+1977
-945
lines changed

.github/workflows/ci.yml

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,55 @@ on:
1010
workflow_dispatch:
1111

1212
jobs:
13-
release:
14-
name: Release
13+
build:
14+
name: Build
1515
runs-on: ubuntu-latest
1616
steps:
1717
- name: Checkout
1818
uses: actions/checkout@v4
1919

20+
- name: Set up Python
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: 3.11
24+
25+
- name: Install Python Dependencies
26+
run: |
27+
python -m pip install --upgrade pip setuptools wheel
28+
python -m pip install --upgrade -r requirements.txt
29+
python -m pip install --upgrade -r requirements-dev.txt
30+
31+
- name: Test with pytest
32+
id: test
33+
env:
34+
GITHUB_PYTEST: "true"
35+
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_TEST_BOT_TOKEN }}
36+
DISCORD_WEBHOOK: ${{ secrets.DISCORD_TEST_BOT_WEBHOOK }}
37+
GRAVATAR_EMAIL: ${{ secrets.GRAVATAR_EMAIL }}
38+
PRAW_CLIENT_ID: ${{ secrets.REDDIT_CLIENT_ID }}
39+
PRAW_CLIENT_SECRET: ${{ secrets.REDDIT_CLIENT_SECRET }}
40+
REDDIT_USERNAME: ${{ secrets.REDDIT_USERNAME }}
41+
REDDIT_PASSWORD: ${{ secrets.REDDIT_PASSWORD }}
42+
shell: bash
43+
run: |
44+
python -m pytest \
45+
-rxXs \
46+
--tb=native \
47+
--verbose \
48+
--cov=src \
49+
tests
50+
51+
- name: Upload coverage
52+
# any except canceled or skipped
53+
if: >-
54+
always() &&
55+
(steps.test.outcome == 'success' || steps.test.outcome == 'failure') &&
56+
startsWith(github.repository, 'LizardByte/')
57+
uses: codecov/codecov-action@v4
58+
with:
59+
fail_ci_if_error: true
60+
token: ${{ secrets.CODECOV_TOKEN }}
61+
2062
- name: Setup Release
2163
id: setup_release
2264
uses: LizardByte/[email protected]

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,4 @@ WORKDIR /app/
4444
COPY . .
4545
RUN python -m pip install --no-cache-dir -r requirements.txt
4646

47-
CMD ["python", "./src/main.py"]
47+
CMD ["python", "-m", "src"]

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# support-bot
2+
[![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)
3+
[![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)
4+
25
Support bot written in python to help manage LizardByte communities. The current focus is discord and reddit, but other
36
platforms such as GitHub discussions/issues could be added.
47

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

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

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

6770
* First run (or manually get a new refresh token):
6871
* Delete `./data/refresh_token` file if needed
69-
* `python ./src/main.py`
72+
* `python -m src`
7073
* Open browser and login to reddit account to use with bot
7174
* Navigate to URL printed in console and accept
7275
* `./data/refresh_token` file is written
7376
* Running after refresh_token already obtained:
74-
* `python ./src/main.py`
77+
* `python -m src`

codecov.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
codecov:
3+
branch: master
4+
5+
coverage:
6+
status:
7+
project:
8+
default:
9+
target: auto
10+
threshold: 10%
11+
12+
comment:
13+
layout: "diff, flags, files"
14+
behavior: default
15+
require_changes: false # if true: only post the comment if coverage changes

requirements-dev.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
betamax==0.9.0
2+
betamax-serializers==0.2.1
3+
pytest==8.1.1
4+
pytest-asyncio==0.23.6
5+
pytest-cov==5.0.0

src/main.py renamed to src/__main__.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88

99
# local imports
1010
if True: # hack for flake8
11-
import discord_bot
12-
import keep_alive
13-
import reddit_bot
11+
from src.discord import bot as d_bot
12+
from src import keep_alive
13+
from src.reddit import bot as r_bot
1414

1515

1616
def main():
@@ -22,15 +22,18 @@ def main():
2222
else:
2323
keep_alive.keep_alive() # Start the web server
2424

25-
discord_bot.start() # Start the discord bot
26-
reddit_bot.start() # Start the reddit bot
25+
discord_bot = d_bot.Bot()
26+
discord_bot.start_threaded() # Start the discord bot
27+
28+
reddit_bot = r_bot.Bot()
29+
reddit_bot.start_threaded() # Start the reddit bot
2730

2831
try:
2932
while discord_bot.bot_thread.is_alive() or reddit_bot.bot_thread.is_alive():
3033
time.sleep(0.5)
3134
except KeyboardInterrupt:
3235
print("Keyboard Interrupt Detected")
33-
discord_bot.stop() # Stop the discord bot
36+
discord_bot.stop()
3437
reddit_bot.stop()
3538

3639

src/common.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
# standard imports
2+
from io import BytesIO
3+
import os
4+
15
# lib imports
26
from libgravatar import Gravatar
7+
import requests
38

49

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

2530
return image_url
31+
32+
33+
def get_avatar_bytes():
34+
avatar_response = requests.get(url=avatar)
35+
avatar_img = BytesIO(avatar_response.content).read()
36+
return avatar_img
37+
38+
39+
# constants
40+
avatar = get_bot_avatar(gravatar=os.environ['GRAVATAR_EMAIL'])
41+
org_name = 'LizardByte'
42+
bot_name = f'{org_name}-Bot'
43+
bot_url = 'https://app.lizardbyte.dev'

src/discord/__init__.py

Whitespace-only changes.

src/discord/bot.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# standard imports
2+
import asyncio
3+
import os
4+
import threading
5+
6+
# lib imports
7+
import discord
8+
9+
# local imports
10+
from src.common import bot_name, get_avatar_bytes, org_name
11+
from src.discord.tasks import daily_task
12+
from src.discord.views import DonateCommandView
13+
14+
15+
class Bot(discord.Bot):
16+
"""
17+
Discord bot class.
18+
19+
This class extends the discord.Bot class to include additional functionality. The class will automatically
20+
enable all intents and sync commands on startup. The class will also update the bot presence, username, and avatar
21+
when the bot is ready.
22+
"""
23+
def __init__(self, *args, **kwargs):
24+
if 'intents' not in kwargs:
25+
intents = discord.Intents.all()
26+
kwargs['intents'] = intents
27+
if 'auto_sync_commands' not in kwargs:
28+
kwargs['auto_sync_commands'] = True
29+
super().__init__(*args, **kwargs)
30+
31+
self.bot_thread = threading.Thread(target=lambda: None)
32+
self.token = os.environ['DISCORD_BOT_TOKEN']
33+
34+
self.load_extension(
35+
name='src.discord.cogs',
36+
recursive=True,
37+
store=False,
38+
)
39+
40+
async def on_ready(self):
41+
"""
42+
Bot on ready event.
43+
44+
This function runs when the discord bot is ready. The function will update the bot presence, update the username
45+
and avatar, and start daily tasks.
46+
"""
47+
print(f'py-cord version: {discord.__version__}')
48+
print(f'Logged in as {self.user.name} (ID: {self.user.id})')
49+
print(f'Servers connected to: {self.guilds}')
50+
51+
# update the username and avatar
52+
avatar_img = get_avatar_bytes()
53+
if await self.user.avatar.read() != avatar_img or self.user.name != bot_name:
54+
await self.user.edit(username=bot_name, avatar=avatar_img)
55+
56+
await self.change_presence(
57+
activity=discord.Activity(type=discord.ActivityType.watching, name=f"the {org_name} server")
58+
)
59+
60+
self.add_view(DonateCommandView()) # register view for persistent listening
61+
62+
await self.sync_commands()
63+
64+
try:
65+
os.environ['DAILY_TASKS']
66+
except KeyError:
67+
daily_task.start(bot=self)
68+
else:
69+
if os.environ['DAILY_TASKS'].lower() == 'true':
70+
daily_task.start(bot=self)
71+
else:
72+
print("'DAILY_TASKS' environment variable is disabled")
73+
74+
def start_threaded(self):
75+
try:
76+
# Login the bot in a separate thread
77+
self.bot_thread = threading.Thread(
78+
target=self.loop.run_until_complete,
79+
args=(self.start(token=self.token),),
80+
daemon=True
81+
)
82+
self.bot_thread.start()
83+
except KeyboardInterrupt:
84+
print("Keyboard Interrupt Detected")
85+
self.stop()
86+
87+
def stop(self, future: asyncio.Future = None):
88+
print("Attempting to stop daily tasks")
89+
daily_task.stop()
90+
print("Attempting to close bot connection")
91+
if self.bot_thread is not None and self.bot_thread.is_alive():
92+
asyncio.run_coroutine_threadsafe(self.close(), self.loop)
93+
self.bot_thread.join()
94+
print("Closed bot")
95+
96+
# Set a result for the future to mark it as done (unit testing)
97+
if future and not future.done():
98+
future.set_result(None)

src/discord/cogs/base_commands.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# lib imports
2+
import discord
3+
from discord.commands import Option
4+
5+
# local imports
6+
from src.common import avatar, bot_name, org_name
7+
from src.discord.views import DonateCommandView
8+
from src.discord import cogs_common
9+
10+
11+
class BaseCommandsCog(discord.Cog):
12+
def __init__(self, bot):
13+
self.bot = bot
14+
15+
@discord.slash_command(
16+
name="help",
17+
description=f"Get help with {bot_name}"
18+
)
19+
async def help_command(
20+
self,
21+
ctx: discord.ApplicationContext,
22+
):
23+
"""
24+
Get help with the bot.
25+
26+
Parameters
27+
----------
28+
ctx : discord.ApplicationContext
29+
Request message context.
30+
"""
31+
description = ""
32+
33+
for cmd in self.bot.commands:
34+
if isinstance(cmd, discord.SlashCommandGroup):
35+
for sub_cmd in cmd.subcommands:
36+
description += await self.get_command_help(ctx=ctx, cmd=sub_cmd, group_name=cmd.name)
37+
else:
38+
description += await self.get_command_help(ctx=ctx, cmd=cmd)
39+
40+
embed = discord.Embed(description=description, color=0xE5A00D)
41+
embed.set_footer(text=bot_name, icon_url=avatar)
42+
43+
await ctx.respond(embed=embed, ephemeral=True)
44+
45+
@staticmethod
46+
async def get_command_help(
47+
ctx: discord.ApplicationContext,
48+
cmd: discord.command,
49+
group_name=None,
50+
) -> str:
51+
description = ""
52+
permissions = cmd.default_member_permissions
53+
has_permissions = True
54+
if permissions:
55+
permissions_dict = {perm[0]: perm[1] for perm in permissions}
56+
has_permissions = all(getattr(ctx.author.guild_permissions, perm, False) for perm in permissions_dict)
57+
if has_permissions:
58+
doc_help = cmd.description
59+
if not doc_help:
60+
doc_lines = cmd.callback.__doc__.split('\n')
61+
doc_help = '\n'.join(line.strip() for line in doc_lines).split('\nParameters\n----------')[0].strip()
62+
if group_name:
63+
description = f"### `/{group_name} {cmd.name}`\n"
64+
else:
65+
description = f"### `/{cmd.name}`\n"
66+
description += f"{doc_help}\n"
67+
if cmd.options:
68+
description += "\n**Options:**\n"
69+
for option in cmd.options:
70+
description += (f"`{option.name}`: {option.description} "
71+
f"({'Required' if option.required else 'Optional'})\n")
72+
description += "\n"
73+
return description
74+
75+
@discord.slash_command(
76+
name="donate",
77+
description=f"Support the development of {org_name}"
78+
)
79+
async def donate_command(
80+
self,
81+
ctx: discord.ApplicationContext,
82+
user: Option(
83+
discord.Member,
84+
description=cogs_common.user_mention_desc,
85+
required=False,
86+
),
87+
):
88+
"""
89+
Sends a discord view, with various donation urls, to the server and channel where the
90+
command was issued.
91+
92+
Parameters
93+
----------
94+
ctx : discord.ApplicationContext
95+
Request message context.
96+
user : discord.Member
97+
Username to mention in response.
98+
"""
99+
if user:
100+
await ctx.respond(f'Thank you for your support {user.mention}!', view=DonateCommandView())
101+
else:
102+
await ctx.respond('Thank you for your support!', view=DonateCommandView())
103+
104+
105+
def setup(bot: discord.Bot):
106+
bot.add_cog(BaseCommandsCog(bot=bot))

0 commit comments

Comments
 (0)