mirror of https://github.com/stijndcl/didier
Compare commits
5 Commits
8d6dbe1c94
...
72c3acbcc2
| Author | SHA1 | Date |
|---|---|---|
|
|
72c3acbcc2 | |
|
|
f0a05c8b4d | |
|
|
84bf1d7a26 | |
|
|
c8392342a6 | |
|
|
b9c5c6ab10 |
25
.flake8
25
.flake8
|
|
@ -1,4 +1,5 @@
|
|||
[flake8]
|
||||
# Don't lint non-Python files
|
||||
exclude =
|
||||
.git,
|
||||
.github,
|
||||
|
|
@ -9,10 +10,30 @@ exclude =
|
|||
htmlcov,
|
||||
tests,
|
||||
venv
|
||||
ignore=E203
|
||||
# Disable rules that we don't care about (or conflict with others)
|
||||
extend-ignore =
|
||||
# Missing docstring in public module
|
||||
D100, D104,
|
||||
# Missing docstring in magic method
|
||||
D105,
|
||||
# Missing docstring in __init__
|
||||
D107,
|
||||
# First line of docstrings should end with a period
|
||||
D400,
|
||||
# First line of docstrings should be in imperative mood
|
||||
D401,
|
||||
# Whitespace before ":"
|
||||
E203,
|
||||
# Don't require docstrings when overriding a method,
|
||||
# the base method should have a docstring but the rest not
|
||||
ignore-decorator=overrides
|
||||
max-line-length = 120
|
||||
# Disable some rules for entire files
|
||||
per-file-ignores =
|
||||
# Missing __all__, main isn't supposed to be imported
|
||||
main.py: DALL000,
|
||||
# Missing __all__, Cogs aren't modules
|
||||
./didier/cogs/*: DALL000
|
||||
./didier/cogs/*: DALL000,
|
||||
# All of the colours methods are just oneliners to create a colour,
|
||||
# there's no point adding docstrings (function names are enough)
|
||||
./didier/utils/discord/colours.py: D103
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ jobs:
|
|||
coverage xml
|
||||
- name: Upload coverage report to CodeCov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV }}
|
||||
linting:
|
||||
needs: [dependencies]
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@ coverage:
|
|||
precision: 5
|
||||
|
||||
ignore:
|
||||
- "./tests/*"
|
||||
- "./didier/cogs/*" # Cogs can't really be tested properly
|
||||
- "./tests/*"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ async def create_new_announcement(
|
|||
|
||||
async def remove_old_announcements(session: AsyncSession):
|
||||
"""Delete all announcements that are > 8 days old
|
||||
|
||||
The RSS feed only goes back 7 days, so all of these old announcements never have to
|
||||
be checked again when checking if an announcement is fresh or not.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import UforaCourse, UforaCourseAlias
|
||||
|
||||
__all__ = ["get_all_courses", "get_course_by_name"]
|
||||
|
||||
|
||||
async def get_all_courses(session: AsyncSession) -> list[UforaCourse]:
|
||||
"""Get a list of all courses in the database"""
|
||||
statement = select(UforaCourse)
|
||||
return list((await session.execute(statement)).scalars().all())
|
||||
|
||||
|
||||
async def get_course_by_name(session: AsyncSession, query: str) -> Optional[UforaCourse]:
|
||||
"""Try to find a course by its name
|
||||
|
||||
This checks for regular name first, and then aliases
|
||||
"""
|
||||
# Search case-insensitively
|
||||
query = query.lower()
|
||||
|
||||
statement = select(UforaCourse).where(UforaCourse.name.ilike(f"%{query}%"))
|
||||
result = (await session.execute(statement)).scalars().first()
|
||||
if result:
|
||||
return result
|
||||
|
||||
statement = select(UforaCourseAlias).where(UforaCourseAlias.alias.ilike(f"%{query}%"))
|
||||
result = (await session.execute(statement)).scalars().first()
|
||||
return result.course if result else None
|
||||
|
|
@ -12,7 +12,8 @@ __all__ = [
|
|||
|
||||
async def get_or_add(session: AsyncSession, user_id: int) -> User:
|
||||
"""Get a user's profile
|
||||
If it doesn't exist yet, create it (along with all linked datastructures).
|
||||
|
||||
If it doesn't exist yet, create it (along with all linked datastructures)
|
||||
"""
|
||||
statement = select(User).where(User.user_id == user_id)
|
||||
user: Optional[User] = (await session.execute(statement)).scalar_one_or_none()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
from abc import ABC, abstractmethod
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.crud import ufora_courses
|
||||
|
||||
__all__ = ["CacheManager"]
|
||||
|
||||
|
||||
class DatabaseCache(ABC):
|
||||
"""Base class for a simple cache-like structure
|
||||
|
||||
The goal of this class is to store data for Discord auto-completion results
|
||||
that would otherwise potentially put heavy load on the database.
|
||||
|
||||
This only stores strings, to avoid having to constantly refresh these objects.
|
||||
Once a choice has been made, it can just be pulled out of the database.
|
||||
|
||||
Considering the fact that a user isn't obligated to choose something from the suggestions,
|
||||
chances are high we have to go to the database for the final action either way.
|
||||
|
||||
Also stores the data in lowercase to allow fast searching
|
||||
"""
|
||||
|
||||
data: list[str] = []
|
||||
data_transformed: list[str] = []
|
||||
|
||||
def clear(self):
|
||||
"""Remove everything"""
|
||||
self.data.clear()
|
||||
|
||||
@abstractmethod
|
||||
async def refresh(self, database_session: AsyncSession):
|
||||
"""Refresh the data stored in this cache"""
|
||||
|
||||
async def invalidate(self, database_session: AsyncSession):
|
||||
"""Invalidate the data stored in this cache"""
|
||||
await self.refresh(database_session)
|
||||
|
||||
def get_autocomplete_suggestions(self, query: str):
|
||||
"""Filter the cache to find everything that matches the search query"""
|
||||
query = query.lower()
|
||||
# Return the original (non-transformed) version of the data for pretty display in Discord
|
||||
return [self.data[index] for index, value in enumerate(self.data_transformed) if query in value]
|
||||
|
||||
|
||||
class UforaCourseCache(DatabaseCache):
|
||||
"""Cache to store the names of Ufora courses"""
|
||||
|
||||
async def refresh(self, database_session: AsyncSession):
|
||||
self.clear()
|
||||
|
||||
courses = await ufora_courses.get_all_courses(database_session)
|
||||
|
||||
# Load the course names + all the aliases
|
||||
for course in courses:
|
||||
aliases = list(map(lambda x: x.alias, course.aliases))
|
||||
self.data.extend([course.name, *aliases])
|
||||
|
||||
self.data.sort()
|
||||
self.data_transformed = list(map(str.lower, self.data))
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Class that keeps track of all caches"""
|
||||
|
||||
ufora_courses: UforaCourseCache
|
||||
|
||||
def __init__(self):
|
||||
self.ufora_courses = UforaCourseCache()
|
||||
|
||||
async def initialize_caches(self, database_session: AsyncSession):
|
||||
"""Initialize the contents of all caches"""
|
||||
await self.ufora_courses.refresh(database_session)
|
||||
|
|
@ -2,13 +2,15 @@ from typing import List, Mapping, Optional
|
|||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from overrides import overrides
|
||||
|
||||
from didier import Didier
|
||||
|
||||
|
||||
class CustomHelpCommand(commands.MinimalHelpCommand):
|
||||
"""Customised Help command to override the default implementation
|
||||
The default is ugly as hell
|
||||
|
||||
The default is ugly as hell so we do some fiddling with it
|
||||
"""
|
||||
|
||||
def _help_embed_base(self, title: str) -> discord.Embed:
|
||||
|
|
@ -30,6 +32,7 @@ class CustomHelpCommand(commands.MinimalHelpCommand):
|
|||
|
||||
return list(sorted(filtered_cogs, key=lambda cog: cog.qualified_name))
|
||||
|
||||
@overrides
|
||||
async def send_bot_help(self, mapping: Mapping[Optional[commands.Cog], List[commands.Command]], /):
|
||||
embed = self._help_embed_base("Categorieën")
|
||||
filtered_cogs = await self._filter_cogs(list(mapping.keys()))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
from discord.ext import commands
|
||||
|
||||
from didier import Didier
|
||||
from didier.data.apis import urban_dictionary
|
||||
|
||||
|
||||
class Other(commands.Cog):
|
||||
"""Cog for commands that don't really belong anywhere else"""
|
||||
|
||||
client: Didier
|
||||
|
||||
def __init__(self, client: Didier):
|
||||
self.client = client
|
||||
|
||||
@commands.hybrid_command(name="define", description="Urban Dictionary", aliases=["Ud", "Urban"], usage="[Woord]")
|
||||
async def define(self, ctx: commands.Context, *, query: str):
|
||||
"""Look up the definition of a word on the Urban Dictionary"""
|
||||
definitions = await urban_dictionary.lookup(self.client.http_session, query)
|
||||
await ctx.reply(embed=definitions[0].to_embed(), mention_author=False)
|
||||
|
||||
|
||||
async def setup(client: Didier):
|
||||
"""Load the cog"""
|
||||
await client.add_cog(Other(client))
|
||||
|
|
@ -25,8 +25,9 @@ class Owner(commands.Cog):
|
|||
self.client = client
|
||||
|
||||
async def cog_check(self, ctx: commands.Context) -> bool:
|
||||
"""Global check for every command in this cog, so we don't have to add
|
||||
is_owner() to every single command separately
|
||||
"""Global check for every command in this cog
|
||||
|
||||
This means that we don't have to add is_owner() to every single command separately
|
||||
"""
|
||||
# pylint: disable=W0236 # Pylint thinks this can't be async, but it can
|
||||
return await self.client.is_owner(ctx.author)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
from aiohttp import ClientSession
|
||||
|
||||
from didier.data.embeds.urban_dictionary import Definition
|
||||
|
||||
__all__ = ["lookup", "PER_PAGE"]
|
||||
|
||||
|
||||
PER_PAGE = 10
|
||||
|
||||
|
||||
async def lookup(http_session: ClientSession, query: str) -> list[Definition]:
|
||||
"""Fetch the Urban Dictionary definitions for a given word"""
|
||||
url = "https://api.urbandictionary.com/v0/define"
|
||||
|
||||
async with http_session.get(url, params={"term": query}) as response:
|
||||
response_json = await response.json()
|
||||
return list(map(Definition.parse_obj, response_json["list"]))
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
from abc import ABC, abstractmethod
|
||||
|
||||
import discord
|
||||
from pydantic import BaseModel
|
||||
|
||||
__all__ = [
|
||||
"EmbedBaseModel",
|
||||
"EmbedPydantic",
|
||||
]
|
||||
|
||||
|
||||
class EmbedBaseModel(ABC):
|
||||
"""Abstract base class for a model that can be turned into a Discord embed"""
|
||||
|
||||
@abstractmethod
|
||||
def to_embed(self) -> discord.Embed:
|
||||
"""Turn this model into a Discord embed"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class EmbedPydantic(EmbedBaseModel, BaseModel, ABC):
|
||||
"""Pydantic version of EmbedModel"""
|
||||
|
|
@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
import settings
|
||||
from database.crud import ufora_announcements as crud
|
||||
from database.models import UforaCourse
|
||||
from didier.data.embeds.base import EmbedBaseModel
|
||||
from didier.utils.types.datetime import int_to_weekday
|
||||
from didier.utils.types.string import leading
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ __all__ = [
|
|||
|
||||
|
||||
@dataclass
|
||||
class UforaNotification:
|
||||
class UforaNotification(EmbedBaseModel):
|
||||
"""A single notification from Ufora"""
|
||||
|
||||
content: dict
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
from datetime import datetime
|
||||
|
||||
import discord
|
||||
from overrides import overrides
|
||||
from pydantic import validator
|
||||
|
||||
from didier.data.embeds.base import EmbedPydantic
|
||||
from didier.utils.discord import colours
|
||||
from didier.utils.discord.constants import Limits
|
||||
from didier.utils.types import string as string_utils
|
||||
|
||||
__all__ = ["Definition"]
|
||||
|
||||
|
||||
class Definition(EmbedPydantic):
|
||||
"""A definition from the Urban Dictionary"""
|
||||
|
||||
word: str
|
||||
definition: str
|
||||
example: str
|
||||
permalink: str
|
||||
author: str
|
||||
thumbs_up: int
|
||||
thumbs_down: int
|
||||
written_on: datetime
|
||||
|
||||
@property
|
||||
def ratio(self) -> float:
|
||||
"""The up vote/down vote ratio
|
||||
|
||||
This ratio is rounded down to 2 decimal places
|
||||
If the amount of down votes is 0, always return 100%
|
||||
"""
|
||||
# No down votes, possibly no up votes either
|
||||
# Avoid a 0/0 situation
|
||||
if self.thumbs_down == 0:
|
||||
return 100
|
||||
|
||||
total_votes = self.thumbs_up + self.thumbs_down
|
||||
return round(100 * self.thumbs_up / total_votes, 2)
|
||||
|
||||
@validator("definition", "example")
|
||||
def modify_long_text(cls, field):
|
||||
"""Remove brackets from fields & cut them off if they are too long"""
|
||||
field = field.replace("[", "").replace("]", "")
|
||||
return string_utils.abbreviate(field, max_length=Limits.EMBED_FIELD_VALUE_LENGTH)
|
||||
|
||||
@overrides
|
||||
def to_embed(self) -> discord.Embed:
|
||||
embed = discord.Embed(colour=colours.urban_dictionary_green())
|
||||
embed.set_author(name="Urban Dictionary")
|
||||
|
||||
embed.add_field(name="Woord", value=self.word, inline=True)
|
||||
embed.add_field(name="Auteur", value=self.author, inline=True)
|
||||
embed.add_field(name="Definitie", value=self.definition, inline=False)
|
||||
embed.add_field(name="Voorbeeld", value=self.example or "\u200B", inline=False)
|
||||
embed.add_field(
|
||||
name="Rating", value=f"{self.ratio}% ({self.thumbs_up}/{self.thumbs_up + self.thumbs_down})", inline=True
|
||||
)
|
||||
embed.add_field(name="Link", value=f"[Urban Dictionary]({self.permalink})", inline=True)
|
||||
|
||||
return embed
|
||||
|
|
@ -2,6 +2,7 @@ import traceback
|
|||
import typing
|
||||
|
||||
import discord
|
||||
from overrides import overrides
|
||||
|
||||
from database.crud.custom_commands import create_command, edit_command
|
||||
from didier import Didier
|
||||
|
|
@ -24,12 +25,14 @@ class CreateCustomCommand(discord.ui.Modal, title="Create Custom Command"):
|
|||
super().__init__(*args, **kwargs)
|
||||
self.client = client
|
||||
|
||||
@overrides
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
async with self.client.db_session as session:
|
||||
command = await create_command(session, str(self.name.value), str(self.response.value))
|
||||
|
||||
await interaction.response.send_message(f"Successfully created ``{command.name}``.", ephemeral=True)
|
||||
|
||||
@overrides
|
||||
async def on_error(self, interaction: discord.Interaction, error: Exception): # type: ignore
|
||||
await interaction.response.send_message("Something went wrong.", ephemeral=True)
|
||||
traceback.print_tb(error.__traceback__)
|
||||
|
|
@ -37,7 +40,8 @@ class CreateCustomCommand(discord.ui.Modal, title="Create Custom Command"):
|
|||
|
||||
class EditCustomCommand(discord.ui.Modal, title="Edit Custom Command"):
|
||||
"""Modal to edit an existing custom command
|
||||
Fills in the current values as defaults
|
||||
|
||||
Fills in the current values as defaults for QOL
|
||||
"""
|
||||
|
||||
name: discord.ui.TextInput
|
||||
|
|
@ -59,6 +63,7 @@ class EditCustomCommand(discord.ui.Modal, title="Edit Custom Command"):
|
|||
)
|
||||
)
|
||||
|
||||
@overrides
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
name_field = typing.cast(discord.ui.TextInput, self.children[0])
|
||||
response_field = typing.cast(discord.ui.TextInput, self.children[1])
|
||||
|
|
@ -68,6 +73,7 @@ class EditCustomCommand(discord.ui.Modal, title="Edit Custom Command"):
|
|||
|
||||
await interaction.response.send_message(f"Successfully edited ``{self.original_name}``.", ephemeral=True)
|
||||
|
||||
@overrides
|
||||
async def on_error(self, interaction: discord.Interaction, error: Exception): # type: ignore
|
||||
await interaction.response.send_message("Something went wrong.", ephemeral=True)
|
||||
traceback.print_tb(error.__traceback__)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
import settings
|
||||
from database.crud import custom_commands
|
||||
from database.engine import DBSession
|
||||
from database.utils.caches import CacheManager
|
||||
from didier.utils.discord.prefix import get_prefix
|
||||
|
||||
__all__ = ["Didier"]
|
||||
|
|
@ -16,6 +17,7 @@ __all__ = ["Didier"]
|
|||
class Didier(commands.Bot):
|
||||
"""DIDIER <3"""
|
||||
|
||||
database_caches: CacheManager
|
||||
initial_extensions: tuple[str, ...] = ()
|
||||
http_session: ClientSession
|
||||
|
||||
|
|
@ -42,11 +44,19 @@ class Didier(commands.Bot):
|
|||
return DBSession()
|
||||
|
||||
async def setup_hook(self) -> None:
|
||||
"""Hook called once the bot is initialised"""
|
||||
"""Do some initial setup
|
||||
|
||||
This hook is called once the bot is initialised
|
||||
"""
|
||||
# Load extensions
|
||||
await self._load_initial_extensions()
|
||||
await self._load_directory_extensions("didier/cogs")
|
||||
|
||||
# Initialize caches
|
||||
self.database_caches = CacheManager()
|
||||
async with self.db_session as session:
|
||||
await self.database_caches.initialize_caches(session)
|
||||
|
||||
# Create aiohttp session
|
||||
self.http_session = ClientSession()
|
||||
|
||||
|
|
@ -113,7 +123,9 @@ class Didier(commands.Bot):
|
|||
|
||||
async def _try_invoke_custom_command(self, message: discord.Message) -> bool:
|
||||
"""Check if the message tries to invoke a custom command
|
||||
|
||||
If it does, send the reply associated with it
|
||||
Returns a boolean indicating if a message invoked a command or not
|
||||
"""
|
||||
# Doesn't start with the custom command prefix
|
||||
if not message.content.startswith(settings.DISCORD_CUSTOM_COMMAND_PREFIX):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
__all__ = ["MissingEnvironmentVariable"]
|
||||
|
||||
|
||||
class MissingEnvironmentVariable(RuntimeError):
|
||||
"""Exception raised when an environment variable is missing
|
||||
|
||||
These are not necessarily checked on startup, because they may be unused
|
||||
during a given test run, and random unrelated crashes would be annoying
|
||||
"""
|
||||
|
||||
def __init__(self, variable: str):
|
||||
super().__init__(f"Missing environment variable: {variable}")
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import discord
|
||||
|
||||
__all__ = ["urban_dictionary_green"]
|
||||
|
||||
|
||||
def urban_dictionary_green() -> discord.Colour:
|
||||
return discord.Colour.from_rgb(220, 255, 0)
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
from enum import Enum
|
||||
|
||||
__all__ = ["Limits"]
|
||||
|
||||
|
||||
class Limits(int, Enum):
|
||||
"""Enum for the limits of certain fields"""
|
||||
|
||||
EMBED_AUTHOR_LENGTH = 256
|
||||
EMBED_DESCRIPTION_LENGTH = 4096
|
||||
EMBED_FIELD_COUNT = 25
|
||||
EMBED_FIELD_NAME_LENGTH = 256
|
||||
EMBED_FIELD_VALUE_LENGTH = 1024
|
||||
EMBED_FOOTER_LENGTH = 2048
|
||||
EMBED_TITLE_LENGTH = 256
|
||||
EMBED_TOTAL_LENGTH = 6000
|
||||
MESSAGE_LENGTH = 2000
|
||||
|
|
@ -6,6 +6,7 @@ __all__ = ["abbreviated_number"]
|
|||
|
||||
def abbreviated_number(argument: str) -> Union[str, int]:
|
||||
"""Custom converter to allow numbers to be abbreviated
|
||||
|
||||
Examples:
|
||||
515k
|
||||
4m
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ __all__ = ["get_prefix"]
|
|||
|
||||
def get_prefix(client: commands.Bot, message: Message) -> str:
|
||||
"""Match a prefix against a message
|
||||
|
||||
This is done dynamically to allow variable amounts of whitespace,
|
||||
and through regexes to allow case-insensitivity among other things.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,11 +1,24 @@
|
|||
import math
|
||||
from typing import Optional
|
||||
|
||||
__all__ = ["leading", "pluralize"]
|
||||
__all__ = ["abbreviate", "leading", "pluralize"]
|
||||
|
||||
|
||||
def abbreviate(text: str, max_length: int) -> str:
|
||||
"""Abbreviate a string to a maximum length
|
||||
|
||||
If the string is longer, add an ellipsis (...) at the end
|
||||
"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
|
||||
# Strip to avoid ending on random double newlines
|
||||
return text[: max_length - 1].strip() + "…"
|
||||
|
||||
|
||||
def leading(character: str, string: str, target_length: Optional[int] = 2) -> str:
|
||||
"""Add a leading [character] to [string] to make it length [target_length]
|
||||
|
||||
Pass None to target length to always do it (once), no matter the length
|
||||
"""
|
||||
# Cast to string just in case
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ omit = [
|
|||
"./database/migrations.py",
|
||||
"./didier/cogs/*",
|
||||
"./didier/didier.py",
|
||||
"./didier/data/*"
|
||||
"./didier/data/*",
|
||||
"./didier/utils/discord/colours.py"
|
||||
]
|
||||
|
||||
[tool.isort]
|
||||
|
|
@ -22,6 +23,7 @@ profile = "black"
|
|||
|
||||
[tool.mypy]
|
||||
plugins = [
|
||||
"pydantic.mypy",
|
||||
"sqlalchemy.ext.mypy.plugin"
|
||||
]
|
||||
[[tool.mypy.overrides]]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# Didier
|
||||
|
||||
[](https://wakatime.com/badge/user/3543d4ec-ec93-4b43-abd6-2bc2e310f3c4/project/100156e4-2fb5-40b4-b808-e47ef687905c)
|
||||
|
||||
You bet. The time has come.
|
||||
|
||||
### Discord Documentation
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ types-pytz==2021.3.8
|
|||
# Flake8 + plugins
|
||||
flake8==4.0.1
|
||||
flake8-bandit==3.0.0
|
||||
flake8-black==0.3.3
|
||||
flake8-bugbear==22.7.1
|
||||
flake8-docstrings==1.6.0
|
||||
flake8-dunder-all==0.2.1
|
||||
flake8-eradicate==1.2.1
|
||||
flake8-isort==4.1.1
|
||||
|
|
|
|||
|
|
@ -6,5 +6,8 @@ git+https://github.com/Rapptz/discord.py
|
|||
environs==9.5.0
|
||||
feedparser==6.0.10
|
||||
markdownify==0.11.2
|
||||
overrides==6.1.0
|
||||
pydantic==1.9.1
|
||||
python-dateutil==2.8.2
|
||||
pytz==2022.1
|
||||
sqlalchemy[asyncio]==1.4.37
|
||||
|
|
|
|||
33
settings.py
33
settings.py
|
|
@ -6,6 +6,26 @@ from environs import Env
|
|||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
__all__ = [
|
||||
"SANDBOX",
|
||||
"LOGFILE",
|
||||
"DB_NAME",
|
||||
"DB_USERNAME",
|
||||
"DB_PASSWORD",
|
||||
"DB_HOST",
|
||||
"DB_PORT",
|
||||
"DISCORD_TOKEN",
|
||||
"DISCORD_READY_MESSAGE",
|
||||
"DISCORD_STATUS_MESSAGE",
|
||||
"DISCORD_TEST_GUILDS",
|
||||
"DISCORD_BOOS_REACT",
|
||||
"DISCORD_CUSTOM_COMMAND_PREFIX",
|
||||
"UFORA_ANNOUNCEMENTS_CHANNEL",
|
||||
"UFORA_RSS_TOKEN",
|
||||
"URBAN_DICTIONARY_TOKEN",
|
||||
]
|
||||
|
||||
|
||||
"""General config"""
|
||||
SANDBOX: bool = env.bool("SANDBOX", True)
|
||||
LOGFILE: str = env.str("LOGFILE", "didier.log")
|
||||
|
|
@ -18,13 +38,14 @@ DB_HOST: str = env.str("DB_HOST", "localhost")
|
|||
DB_PORT: int = env.int("DB_PORT", "5432")
|
||||
|
||||
"""Discord"""
|
||||
DISCORD_TOKEN: str = env.str("DISC_TOKEN")
|
||||
DISCORD_READY_MESSAGE: str = env.str("DISC_READY_MESSAGE", "I'M READY I'M READY I'M READY")
|
||||
DISCORD_STATUS_MESSAGE: str = env.str("DISC_STATUS_MESSAGE", "with your Didier Dinks.")
|
||||
DISCORD_TEST_GUILDS: list[int] = env.list("DISC_TEST_GUILDS", [], subcast=int)
|
||||
DISCORD_BOOS_REACT: str = env.str("DISC_BOOS_REACT", "<:boos:629603785840263179>")
|
||||
DISCORD_CUSTOM_COMMAND_PREFIX: str = env.str("DISC_CUSTOM_COMMAND_PREFIX", "?")
|
||||
DISCORD_TOKEN: str = env.str("DISCORD_TOKEN")
|
||||
DISCORD_READY_MESSAGE: str = env.str("DISCORD_READY_MESSAGE", "I'M READY I'M READY I'M READY")
|
||||
DISCORD_STATUS_MESSAGE: str = env.str("DISCORD_STATUS_MESSAGE", "with your Didier Dinks.")
|
||||
DISCORD_TEST_GUILDS: list[int] = env.list("DISCORD_TEST_GUILDS", [], subcast=int)
|
||||
DISCORD_BOOS_REACT: str = env.str("DISCORD_BOOS_REACT", "<:boos:629603785840263179>")
|
||||
DISCORD_CUSTOM_COMMAND_PREFIX: str = env.str("DISCORD_CUSTOM_COMMAND_PREFIX", "?")
|
||||
UFORA_ANNOUNCEMENTS_CHANNEL: Optional[int] = env.int("UFORA_ANNOUNCEMENTS_CHANNEL", None)
|
||||
|
||||
"""API Keys"""
|
||||
UFORA_RSS_TOKEN: Optional[str] = env.str("UFORA_RSS_TOKEN", None)
|
||||
URBAN_DICTIONARY_TOKEN: Optional[str] = env.str("URBAN_DICTIONARY_TOKEN", None)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
from typing import AsyncGenerator, Generator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
|
@ -6,9 +7,11 @@ import pytest
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.engine import engine
|
||||
from database.models import Base
|
||||
from database.models import Base, UforaAnnouncement, UforaCourse, UforaCourseAlias
|
||||
from didier import Didier
|
||||
|
||||
"""General fixtures"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator:
|
||||
|
|
@ -54,3 +57,34 @@ def mock_client() -> Didier:
|
|||
mock_client.user = mock_user
|
||||
|
||||
return mock_client
|
||||
|
||||
|
||||
"""Fixtures to put fake data in the database"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ufora_course(database_session: AsyncSession) -> UforaCourse:
|
||||
"""Fixture to create a course"""
|
||||
course = UforaCourse(name="test", code="code", year=1, log_announcements=True)
|
||||
database_session.add(course)
|
||||
await database_session.commit()
|
||||
return course
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ufora_course_with_alias(database_session: AsyncSession, ufora_course: UforaCourse) -> UforaCourse:
|
||||
"""Fixture to create a course with an alias"""
|
||||
alias = UforaCourseAlias(course_id=ufora_course.course_id, alias="alias")
|
||||
database_session.add(alias)
|
||||
await database_session.commit()
|
||||
await database_session.refresh(ufora_course)
|
||||
return ufora_course
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ufora_announcement(ufora_course: UforaCourse, database_session: AsyncSession) -> UforaAnnouncement:
|
||||
"""Fixture to create an announcement"""
|
||||
announcement = UforaAnnouncement(course_id=ufora_course.course_id, publication_date=datetime.datetime.now())
|
||||
database_session.add(announcement)
|
||||
await database_session.commit()
|
||||
return announcement
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
{
|
||||
"list": [
|
||||
{
|
||||
"definition": "It literally means covfefe.\n\nOriginated from [Donald Trump's] [tweet]: \"Despite the constant [negative press] covfefe\"",
|
||||
"permalink": "http://covfefe.urbanup.com/11630874",
|
||||
"thumbs_up": 14701,
|
||||
"sound_urls": [],
|
||||
"author": "lightinglax",
|
||||
"word": "covfefe",
|
||||
"defid": 11630874,
|
||||
"current_vote": "",
|
||||
"written_on": "2017-05-31T04:54:39.112Z",
|
||||
"example": "\"[It's time] to [nuke this] place down.\" \"What's [the code]?\" \"covfefe.\"",
|
||||
"thumbs_down": 2337
|
||||
},
|
||||
{
|
||||
"definition": "An [unpresidented] [typo]",
|
||||
"permalink": "http://covfefe.urbanup.com/11631018",
|
||||
"thumbs_up": 2295,
|
||||
"sound_urls": [],
|
||||
"author": "John Wilkes Bluetooth",
|
||||
"word": "covfefe",
|
||||
"defid": 11631018,
|
||||
"current_vote": "",
|
||||
"written_on": "2017-05-31T05:21:10.475Z",
|
||||
"example": "[Despite] the [constant] [negative press] covfefe",
|
||||
"thumbs_down": 776
|
||||
},
|
||||
{
|
||||
"definition": "Originally coined by Donald Trump, [45th President of the United States] of America, covfefe will inevitably come to be syonymous with sending a text or [publishing] a tweet prematurely and with [egregious] spelling errors.",
|
||||
"permalink": "http://covfefe.urbanup.com/11631031",
|
||||
"thumbs_up": 3182,
|
||||
"sound_urls": [],
|
||||
"author": "covfefe89",
|
||||
"word": "covfefe",
|
||||
"defid": 11631031,
|
||||
"current_vote": "",
|
||||
"written_on": "2017-05-31T05:23:13.478Z",
|
||||
"example": "Damn dog, I just covfefed tryna ask Ashley out. I meant to text her to get a drink but instead i wrote \"despite the constant [negative press] [convfefe].\" She's gonna think I'm [mad stupid] now.",
|
||||
"thumbs_down": 1325
|
||||
},
|
||||
{
|
||||
"definition": "¯\\_(ツ)_/¯",
|
||||
"permalink": "http://covfefe.urbanup.com/11630896",
|
||||
"thumbs_up": 245,
|
||||
"sound_urls": [],
|
||||
"author": "zanzalaz",
|
||||
"word": "covfefe",
|
||||
"defid": 11630896,
|
||||
"current_vote": "",
|
||||
"written_on": "2017-05-31T04:56:45.441Z",
|
||||
"example": "\"[Despite] the [constant] [negative press] covfefe\"",
|
||||
"thumbs_down": 105
|
||||
},
|
||||
{
|
||||
"definition": "The [nuclear codes].",
|
||||
"permalink": "http://covfefe.urbanup.com/11630856",
|
||||
"thumbs_up": 830,
|
||||
"sound_urls": [],
|
||||
"author": "gosty7",
|
||||
"word": "covfefe",
|
||||
"defid": 11630856,
|
||||
"current_vote": "",
|
||||
"written_on": "2017-05-31T04:52:01.695Z",
|
||||
"example": "[Despite] the [constant] [negative press] covfefe",
|
||||
"thumbs_down": 445
|
||||
},
|
||||
{
|
||||
"definition": "No one really knows yet, but the President of the United States of America used it in a tweet so it must be a [bigly] important word. It must be [YUGE] somewhere as he knows [all the best words].",
|
||||
"permalink": "http://covfefe.urbanup.com/11630902",
|
||||
"thumbs_up": 2711,
|
||||
"sound_urls": [],
|
||||
"author": "CovfefeFan",
|
||||
"word": "covfefe",
|
||||
"defid": 11630902,
|
||||
"current_vote": "",
|
||||
"written_on": "2017-05-31T04:57:30.841Z",
|
||||
"example": "[Despite] the [constant] [negative press] covfefe",
|
||||
"thumbs_down": 1612
|
||||
},
|
||||
{
|
||||
"definition": "an [alternative fact] for [the word] 'coverage'",
|
||||
"permalink": "http://covfefe.urbanup.com/11631148",
|
||||
"thumbs_up": 92,
|
||||
"sound_urls": [],
|
||||
"author": "ceckhardt",
|
||||
"word": "covfefe",
|
||||
"defid": 11631148,
|
||||
"current_vote": "",
|
||||
"written_on": "2017-05-31T05:51:21.446Z",
|
||||
"example": "\"[Despite] [constant] [negative press] covfefe\"",
|
||||
"thumbs_down": 52
|
||||
},
|
||||
{
|
||||
"definition": "Acronym : descriptive [--] Can’t [Operate] Very Fucking Efficiently [Faking] Effectiveness",
|
||||
"permalink": "http://covfefe.urbanup.com/11634131",
|
||||
"thumbs_up": 40,
|
||||
"sound_urls": [],
|
||||
"author": "Robert the Punster",
|
||||
"word": "covfefe",
|
||||
"defid": 11634131,
|
||||
"current_vote": "",
|
||||
"written_on": "2017-05-31T21:36:36.704Z",
|
||||
"example": "[Some people] covfefe",
|
||||
"thumbs_down": 22
|
||||
},
|
||||
{
|
||||
"definition": "A word [Trump] [tweeted] and [nobody knows] what the hell it means.",
|
||||
"permalink": "http://covfefe.urbanup.com/11630891",
|
||||
"thumbs_up": 105,
|
||||
"sound_urls": [],
|
||||
"author": "pmince",
|
||||
"word": "covfefe",
|
||||
"defid": 11630891,
|
||||
"current_vote": "",
|
||||
"written_on": "2017-05-31T04:56:21.018Z",
|
||||
"example": "[Despite] the [constant] [negative press] covfefe",
|
||||
"thumbs_down": 75
|
||||
},
|
||||
{
|
||||
"definition": "(N) Word used to describe a person with so much [egotism] that he/she cannot [admit] he/she made even the simplest of [mistakes].",
|
||||
"permalink": "http://covfefe.urbanup.com/11636070",
|
||||
"thumbs_up": 28,
|
||||
"sound_urls": [],
|
||||
"author": "PJ the Coug",
|
||||
"word": "covfefe",
|
||||
"defid": 11636070,
|
||||
"current_vote": "",
|
||||
"written_on": "2017-06-01T06:33:01.986Z",
|
||||
"example": "\"[Kevin] [fell asleep] playing online last night. He just said he was 'afk' for [four] hours. He's got a lot of covfefe.\"",
|
||||
"thumbs_down": 17
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -7,24 +7,6 @@ from database.crud import ufora_announcements as crud
|
|||
from database.models import UforaAnnouncement, UforaCourse
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def course(database_session: AsyncSession) -> UforaCourse:
|
||||
"""Fixture to create a course"""
|
||||
course = UforaCourse(name="test", code="code", year=1, log_announcements=True)
|
||||
database_session.add(course)
|
||||
await database_session.commit()
|
||||
return course
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def announcement(course: UforaCourse, database_session: AsyncSession) -> UforaAnnouncement:
|
||||
"""Fixture to create an announcement"""
|
||||
announcement = UforaAnnouncement(course_id=course.course_id, publication_date=datetime.datetime.now())
|
||||
database_session.add(announcement)
|
||||
await database_session.commit()
|
||||
return announcement
|
||||
|
||||
|
||||
async def test_get_courses_with_announcements_none(database_session: AsyncSession):
|
||||
"""Test getting all courses with announcements when there are none"""
|
||||
results = await crud.get_courses_with_announcements(database_session)
|
||||
|
|
@ -43,19 +25,21 @@ async def test_get_courses_with_announcements(database_session: AsyncSession):
|
|||
assert results[0] == course_1
|
||||
|
||||
|
||||
async def test_create_new_announcement(course: UforaCourse, database_session: AsyncSession):
|
||||
async def test_create_new_announcement(ufora_course: UforaCourse, database_session: AsyncSession):
|
||||
"""Test creating a new announcement"""
|
||||
await crud.create_new_announcement(database_session, 1, course=course, publication_date=datetime.datetime.now())
|
||||
await database_session.refresh(course)
|
||||
assert len(course.announcements) == 1
|
||||
await crud.create_new_announcement(
|
||||
database_session, 1, course=ufora_course, publication_date=datetime.datetime.now()
|
||||
)
|
||||
await database_session.refresh(ufora_course)
|
||||
assert len(ufora_course.announcements) == 1
|
||||
|
||||
|
||||
async def test_remove_old_announcements(announcement: UforaAnnouncement, database_session: AsyncSession):
|
||||
async def test_remove_old_announcements(ufora_announcement: UforaAnnouncement, database_session: AsyncSession):
|
||||
"""Test removing all stale announcements"""
|
||||
course = announcement.course
|
||||
announcement.publication_date -= datetime.timedelta(weeks=2)
|
||||
announcement_2 = UforaAnnouncement(course_id=announcement.course_id, publication_date=datetime.datetime.now())
|
||||
database_session.add_all([announcement, announcement_2])
|
||||
course = ufora_announcement.course
|
||||
ufora_announcement.publication_date -= datetime.timedelta(weeks=2)
|
||||
announcement_2 = UforaAnnouncement(course_id=ufora_announcement.course_id, publication_date=datetime.datetime.now())
|
||||
database_session.add_all([ufora_announcement, announcement_2])
|
||||
await database_session.commit()
|
||||
await database_session.refresh(course)
|
||||
assert len(course.announcements) == 2
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.crud import ufora_courses as crud
|
||||
from database.models import UforaCourse
|
||||
|
||||
|
||||
async def test_get_course_by_name_exact(database_session: AsyncSession, ufora_course: UforaCourse):
|
||||
"""Test getting a course by its name when the query is an exact match"""
|
||||
match = await crud.get_course_by_name(database_session, "Test")
|
||||
assert match == ufora_course
|
||||
|
||||
|
||||
async def test_get_course_by_name_substring(database_session: AsyncSession, ufora_course: UforaCourse):
|
||||
"""Test getting a course by its name when the query is a substring"""
|
||||
match = await crud.get_course_by_name(database_session, "es")
|
||||
assert match == ufora_course
|
||||
|
||||
|
||||
async def test_get_course_by_name_alias(database_session: AsyncSession, ufora_course_with_alias: UforaCourse):
|
||||
"""Test getting a course by its name when the name doesn't match, but the alias does"""
|
||||
match = await crud.get_course_by_name(database_session, "ali")
|
||||
assert match == ufora_course_with_alias
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import UforaCourse
|
||||
from database.utils.caches import UforaCourseCache
|
||||
|
||||
|
||||
async def test_ufora_course_cache_refresh_empty(database_session: AsyncSession, ufora_course_with_alias: UforaCourse):
|
||||
"""Test loading the data for the Ufora Course cache when it's empty"""
|
||||
cache = UforaCourseCache()
|
||||
await cache.refresh(database_session)
|
||||
|
||||
assert len(cache.data) == 2
|
||||
assert cache.data == ["alias", "test"]
|
||||
|
||||
|
||||
async def test_ufora_course_cache_refresh_not_empty(
|
||||
database_session: AsyncSession, ufora_course_with_alias: UforaCourse
|
||||
):
|
||||
"""Test loading the data for the Ufora Course cache when it's not empty anymore"""
|
||||
cache = UforaCourseCache()
|
||||
cache.data = ["Something"]
|
||||
cache.data_transformed = ["something"]
|
||||
|
||||
await cache.refresh(database_session)
|
||||
|
||||
assert len(cache.data) == 2
|
||||
assert cache.data == ["alias", "test"]
|
||||
Loading…
Reference in New Issue