diff --git a/.gitattributes b/.gitattributes index dfe0770..dada09c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # Auto detect text files and perform LF normalization * text=auto + +# Keep shell files as LF so Windows-host-machine users can still use Docker +*.sh text eol=lf diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 720ebce..346353d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -13,10 +13,8 @@ on: tags: [ 'v*.*.*' ] env: - # Use docker.io for Docker Hub if empty REGISTRY: ghcr.io - # github.repository as / - IMAGE_NAME: feyko/ficsit-fred + IMAGE_NAME: ${{ github.repository }} jobs: @@ -26,31 +24,25 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 - - # Login against a Docker registry except on PR + uses: actions/checkout@v4 + - name: Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 with: registry: ${{ env.REGISTRY }} - username: feyko - password: ${{ secrets.GH_TOKEN }} - - # Extract metadata (tags, labels) for Docker - # https://github.com/docker/metadata-action + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract Docker metadata id: meta - uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - uses: docker/setup-buildx-action@v1 - id: buildx - - # Build and push Docker image with Buildx (don't push on PR) - # https://github.com/docker/build-push-action + - uses: docker/setup-buildx-action@v3 + - name: Build and push Docker image - uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 with: context: . file: ./docker/Dockerfile diff --git a/.gitignore b/.gitignore index 7ec2647..b9f63b7 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ celerybeat.pid # Environments .env +!example.env .venv .run/ env/ diff --git a/README.md b/README.md index a79dbe9..47cf70c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,73 @@ # FICSIT-Fred + A Discord bot for the Satisfactory Modding Discord -Thanks to Illya, creator of the bot ! +## Development + +Want to contribute to Fred? Here's everything you need to know to get started. + +### Prerequisites + +#### A Discord token + +First of all, you'll need a Discord bot token for Fred to connect to Discord with. +To get one, you can go to , register a new application, add a bot to the application and **activate the "message content intent"**. +This intent is needed to receive the content of messages. +Fred doesn't use slash commands so this is simply needed for him to function. + +#### Docker + +Docker is for making containers. If you're not sure what that means, look it up it's real cool! In our case, it helps use set up all needed dependencies for Fred without you needing to do anything other than install Docker. +You can get docker [here](https://docs.docker.com/engine/install/). For Linux, make sure to **not** use Docker Desktop. For Windows, it is the easiest way. +If you don't want to install Docker (especially on Windows where Docker Desktop can take up resources and requires virtualisation to be enabled), you can also manually set up a PostgreSQL DB and configure Fred to point to it. More on that later. + +#### (Optional) Dialogflow auth + +This is optional because this feature is currently disabled in Fred. +You'll have get authentication information for dialogflow if you want to work on that. + +### Setup + +Two choices here: All through docker or hybrid local/docker. +I recommend the hybrid way for ease of development. We don't have a proper devcontainer setup so debugging Fred when running in Docker is not great. +Instead, in a hybrid setup, you'll use Docker for the postgres DB only and run Fred locally using Poetry. + +#### Docker-only + +Run `FRED_TOKEN= docker compose up -d` and that's it! Fred should run. +(Note: This command assumes you're using bash as your shell. For Powershell or other, set the environment variable differently). + +You can verify that the database was properly created and manage it by going to where pgadmin should show. +You can use `fred@fred.com` for the user and `fred` for the password. All of this is customizable in `docker-compose.yml`. + +#### Hybrid + +For this, you'll need [poetry](https://python-poetry.org/) installed, as well as `libpq` for the connection to the postgres DB. +For libpq, you can install `libpq-dev` (or equivalent) on linux and [install postgres](https://www.postgresql.org/download/windows/) on Windows (test without it first actually I'm not certain this is needed). + +Once Poetry is installed, run `poetry install`. This should create a `.venv` folder that contains the virtualenv with all the necessary packages. Activate it using `poetry shell` or manually. + +Now, run `docker compose up -d -f docker-compose-deps.yml`. This should spin up the postgres DB. +You can verify that the database was properly created and manage it by going to where pgadmin should show. +You can use `fred@fred.com` for the user and `fred` for the password. All of this is customizable in `docker-compose-deps.yml`. + +Almost there! You'll now have to configure Fred. This just means setting the following env vars (found in `fred/__main__.py`). + +```sh +"FRED_IP", +"FRED_PORT", +"FRED_TOKEN", +"FRED_SQL_DB", +"FRED_SQL_USER", +"FRED_SQL_PASSWORD", +"FRED_SQL_HOST", +"FRED_SQL_PORT", +``` + +For convenience, a `.env` file is included and Fred will load them automatically. It includes defaults that will work with the config of the docker-compose-deps.yml. You'll only have to set `FRED_TOKEN` and maybe change the others if the defaults don't suit you. + +Finally, run `python fred/__main__.py`. Fred should run! You can now adapt this to your setup and run the script from your IDE instead. Don't forget to use the virtualenv python! + +## Thanks + +Massive thanks to Borketh, Mircea and everyone else that has contributed! diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000..585b9d5 --- /dev/null +++ b/cspell.json @@ -0,0 +1,19 @@ +// https://cspell.org/configuration/ +{ + // Version of the setting file. Always 0.2 + "version": "0.2", + // language - current active spelling language + "language": "en", + // words - list of words to be always considered correct + "words": [ + "FICSIT", + "libpq", + "Mircea", + "pgadmin", + "virtualenv" + ], + // flagWords - list of words to be always considered incorrect + // This is useful for offensive words and common spelling errors. + // cSpell:disable (don't complain about the words we listed here) + "flagWords": [] +} diff --git a/docker-compose-deps.yml b/docker-compose-deps.yml new file mode 100644 index 0000000..4c7bac2 --- /dev/null +++ b/docker-compose-deps.yml @@ -0,0 +1,25 @@ +services: + postgres: + image: postgres:14.0-alpine + environment: + POSTGRES_PASSWORD: fred + POSTGRES_USER: fred + PGDATA: /var/lib/postgresql/data/pgdata + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data/pgdata:z + - ./docker/exampledb.sql:/docker-entrypoint-initdb.d/exampledb.sql + + pgadmin: + depends_on: + - postgres + image: dpage/pgadmin4:6 + environment: + PGADMIN_DEFAULT_EMAIL: 'fred@fred.com' + PGADMIN_DEFAULT_PASSWORD: 'fred' + ports: + - "8080:80" + +volumes: + pgdata: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 70920f9..90364d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3' services: fred: depends_on: diff --git a/docker/Dockerfile b/docker/Dockerfile index 8581de4..35d9c1e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,26 +1,31 @@ -FROM python:3.10-slim as runtime +FROM python:3.12-alpine AS runtime +#FROM python:3.12-slim AS runtime -ENV DEBIAN_FRONTEND=noninteractive VOLUME /config WORKDIR /app -COPY docker/runtime-deps.sh . -RUN bash runtime-deps.sh 1> /dev/null && rm runtime-deps.sh +RUN apk update; apk add --no-cache tesseract-ocr-data-eng +#ENV DEBIAN_FRONTEND=noninteractive APT="apt-get -qq" +#RUN $APT update; \ +# $APT install tesseract-ocr; \ +# $APT clean; \ +# rm -rf /var/lib/apt/lists/* -FROM python:3.10-slim as build +FROM python:3.12-alpine AS build +#FROM python:3.12-slim AS build -WORKDIR /app - -RUN apt-get -qq update && apt-get -qq install curl libpq-dev gcc 1> /dev/null +WORKDIR /deps -RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/usr/local python3 - +RUN pip --no-cache-dir install --progress-bar=off "poetry==1.8" COPY pyproject.toml . -RUN poetry install -nvvv --no-dev && mv $(poetry env info --path) /app/venv +COPY poetry.lock . +RUN poetry install -nvvv --only main --no-root; mv $(poetry env info --path) ./venv FROM runtime -COPY --from=build /app/venv /app/venv -COPY fred *.env ./fred/ +COPY --from=build /deps/venv ./venv +COPY fred ./fred/ +COPY *.env . CMD ./venv/bin/python3 -m fred \ No newline at end of file diff --git a/docker/runtime-deps.sh b/docker/runtime-deps.sh deleted file mode 100644 index c880d12..0000000 --- a/docker/runtime-deps.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -# This file is meant to be run in the Dockerfile to get the runtime dependencies -# if you run it outside, good luck - -echo "Getting initial setup dependencies..." 1>&2 -apt-get update -apt-get install software-properties-common ca-certificates gnupg curl -y - -# add repo that adds libpq properly -echo "Adding repository for libpq..." 1>&2 -curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/trusted.gpg.d/apt.postgresql.org.gpg >/dev/null -add-apt-repository "deb http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" -apt-get update - -# install runtime dependencies -echo "Installing runtime dependencies..." 1>&2 -apt-get install -y libpq5 tesseract-ocr - - -isDocker(){ - local cgroup=/proc/1/cgroup - test -f $cgroup && [[ "$(<$cgroup)" = *:cpuset:/docker/* ]] -} - -isDockerBuildkit(){ - local cgroup=/proc/1/cgroup - test -f $cgroup && [[ "$(<$cgroup)" = *:cpuset:/docker/buildkit/* ]] -} - -isDockerContainer(){ - [ -e /.dockerenv ] -} - -# we don't need this crap anymore -echo "Cleaning up..." 1>&2 - -if isDockerBuildkit || (isDocker && ! isDockerContainer) -then - echo "Detected run within docker RUN. Removing apt cache for image size." 1>&2 - apt-get remove -y software-properties-common gnupg curl - apt-get autopurge -y - apt-get autoclean -y - rm -rf /var/lib/apt/lists/* -else - echo "Opting to not remove stuff so running on real hardware does not break things." 1>&2 -fi - -echo "All runtime dependencies handled!" 1>&2 \ No newline at end of file diff --git a/example.env b/example.env new file mode 100644 index 0000000..e271d82 --- /dev/null +++ b/example.env @@ -0,0 +1,8 @@ +FRED_IP=:: +FRED_PORT=80 +FRED_SQL_DB=fred +FRED_SQL_USER=fred +FRED_SQL_PASSWORD=fred +FRED_SQL_HOST=0.0.0.0 +FRED_SQL_PORT=5432 +FRED_LOG_LEVEL=DEBUG \ No newline at end of file diff --git a/fred/__init__.py b/fred/__init__.py index 37cf71c..8278c5d 100644 --- a/fred/__init__.py +++ b/fred/__init__.py @@ -1 +1,29 @@ -from .fred import __version__ +import logging +from os import getenv + +from dotenv import load_dotenv + +logging.root = logging.getLogger("FRED") +logging.basicConfig(level=getenv("FRED_LOG_LEVEL", logging.DEBUG)) + +from .fred import __version__ # noqa + +logging.root.debug(f"HELLO WORLD!!! FRED version: {__version__}") + +ENVVARS = ( + "FRED_IP", + "FRED_PORT", + "FRED_TOKEN", + "FRED_SQL_DB", + "FRED_SQL_USER", + "FRED_SQL_PASSWORD", + "FRED_SQL_HOST", + "FRED_SQL_PORT", + # "DIALOGFLOW_AUTH", +) + +load_dotenv() + +for var in ENVVARS: + if getenv(var) is None: + raise EnvironmentError(f"The ENV variable '{var}' isn't set") diff --git a/fred/__main__.py b/fred/__main__.py index f13e32d..ba81ec1 100644 --- a/fred/__main__.py +++ b/fred/__main__.py @@ -1,26 +1,6 @@ -from dotenv import load_dotenv from os import getenv -load_dotenv() - -ENVVARS = [ - "FRED_IP", - "FRED_PORT", - "FRED_TOKEN", - "FRED_SQL_DB", - "FRED_SQL_USER", - "FRED_SQL_PASSWORD", - "FRED_SQL_HOST", - "FRED_SQL_PORT", - "DIALOGFLOW_AUTH", -] - -for var in ENVVARS: - if getenv(var) is None: - raise EnvironmentError(f"The ENV variable '{var}' isn't set") - -from .fred import Bot, nextcord, __version__ - +from .fred import Bot, nextcord intents = nextcord.Intents.all() diff --git a/fred/cogs/crashes.py b/fred/cogs/crashes.py index ea42cb4..4eb049b 100644 --- a/fred/cogs/crashes.py +++ b/fred/cogs/crashes.py @@ -1,28 +1,32 @@ +from __future__ import annotations + import asyncio import io import json -import logging -import re import traceback import zipfile from concurrent.futures import ThreadPoolExecutor +from os.path import split from time import strptime -from typing import AsyncIterator +from typing import AsyncIterator, IO +from urllib.parse import urlparse import nextcord -import nextcord.ext.commands as commands +import regex from PIL import Image, ImageEnhance, UnidentifiedImageError +from nextcord import Message from pytesseract import image_to_string, TesseractError from .. import config from ..libraries import createembed +from ..libraries.common import FredCog -REGEX_LIMIT: float = 2 +REGEX_LIMIT: float = 6.9 async def regex_with_timeout(*args, **kwargs): try: - return await asyncio.wait_for(asyncio.to_thread(re.search, *args, **kwargs), REGEX_LIMIT) + return await asyncio.wait_for(asyncio.to_thread(regex.search, *args, **kwargs), REGEX_LIMIT) except asyncio.TimeoutError: raise TimeoutError( f"A regex timed out after {REGEX_LIMIT} seconds! \n" @@ -32,18 +36,14 @@ async def regex_with_timeout(*args, **kwargs): ) -class Crashes(commands.Cog): +class Crashes(FredCog): vanilla_info_patterns = [ - re.compile(r"Net CL: (?P\d+)"), - re.compile(r"Command Line: (?P.*)"), - re.compile(r"Base Directory: (?P.+)"), - re.compile(r"Launcher ID: (?P\w+)"), + regex.compile(r"Net CL: (?P\d+)"), + regex.compile(r"Command Line: (?P.*)"), + regex.compile(r"Base Directory: (?P.+)"), + regex.compile(r"Launcher ID: (?P\w+)"), ] - def __init__(self, bot): - self.bot = bot - self.logger = logging.Logger("CRASH_PARSING") - @staticmethod def filter_epic_commandline(cli: str) -> str: return " ".join(filter(lambda opt: "auth" not in opt.lower(), cli.split())) @@ -51,10 +51,16 @@ def filter_epic_commandline(cli: str) -> str: async def parse_factory_game_log(self, text: str) -> dict[str, str | int]: self.logger.info("Extracting game info") lines = text.splitlines() - vanilla_info_search_area = filter(lambda l: re.match("^LogInit", l), lines) + vanilla_info_search_area = filter(lambda l: regex.match("^LogInit", l), lines) info = {} - patterns = self.vanilla_info_patterns[:] + patterns = self.vanilla_info_patterns[:] # shallow copy + """ + This godforsaken loop sequentially finds information, + dropping patterns to look for as they are found (until it runs out of patterns). + The patterns have named regex captures which the rest of the code knows the names of. + - Borketh + """ for line in vanilla_info_search_area: if not patterns: break @@ -64,7 +70,7 @@ async def parse_factory_game_log(self, text: str) -> dict[str, str | int]: else: self.logger.info("Didn't find all four pieces of information normally found in a log") - mod_loader_logs = filter(lambda l: re.match("LogSatisfactoryModLoader", l), lines) + mod_loader_logs = filter(lambda l: regex.match("LogSatisfactoryModLoader", l), lines) for line in mod_loader_logs: if match := await regex_with_timeout(r"(?<=v\.)(?P[\d.]+)", line): info |= match.groupdict() @@ -189,8 +195,9 @@ async def check_for_outdated_mods(self, mod_list: list) -> list[str]: if latest_compatible_loader > strptime(mod["last_version_date"], "%Y-%m-%dT%H:%M:%S.%fZ") ] - async def process_file(self, file, extension) -> list[dict](): + async def process_file(self, file: IO, extension: str) -> list[dict](): self.logger.info(f"Processing {extension} file") + match extension: case "": return [] @@ -211,7 +218,8 @@ async def process_file(self, file, extension) -> list[dict](): return messages + [ dict( name="Bad Zip File!", - value="This zipfile is invalid! Its contents may have been changed after zipping.", + value="This zipfile is invalid! " + "Its contents may have been changed after zipping.", inline=False, ) ] @@ -280,7 +288,9 @@ async def process_file(self, file, extension) -> list[dict](): image = Image.open(file) ratio = 2160 / image.height if ratio > 1: - image = image.resize((round(image.width * ratio), round(image.height * ratio)), Image.LANCZOS) + image = image.resize( + (round(image.width * ratio), round(image.height * ratio)), Image.Resampling.LANCZOS + ) enhancer_contrast = ImageEnhance.Contrast(image) @@ -301,15 +311,20 @@ async def process_file(self, file, extension) -> list[dict](): async def mass_regex(self, text: str) -> AsyncIterator[dict]: for crash in config.Crashes.fetch_all(): - if match := await regex_with_timeout(crash["crash"], text, flags=re.IGNORECASE): + if match := await regex_with_timeout(crash["crash"], text, flags=regex.IGNORECASE): if str(crash["response"]).startswith(self.bot.command_prefix): if command := config.Commands.fetch(crash["response"].strip(self.bot.command_prefix)): command_response = command["content"] if command_response.startswith(self.bot.command_prefix): # is alias command = config.Commands.fetch(command_response.strip(self.bot.command_prefix)) - yield dict(name=command["name"], value=command["content"], attachment=command["attachment"], inline=True) + yield dict( + name=command["name"], + value=command["content"], + attachment=command["attachment"], + inline=True, + ) else: - response = re.sub(r"{(\d+)}", lambda m: match.group(int(m.group(1))), str(crash["response"])) + response = regex.sub(r"{(\d+)}", lambda m: match.group(int(m.group(1))), str(crash["response"])) yield dict(name=crash["name"], value=response, inline=True) async def complex_responses(self, log_details: dict): @@ -323,26 +338,28 @@ async def complex_responses(self, log_details: dict): async def process_text(self, text: str) -> list[dict]: return [msg async for msg in self.mass_regex(text)] - async def process_message(self, message) -> bool: + async def process_message(self, message: Message) -> bool: self.bot.logger.info("Processing crashes") responses: list[dict] = [] # attachments - cdn_link = re.search(r"(https://cdn.discordapp.com/attachments/\S+)", message.content) + cdn_link = regex.search(r"(https://cdn.discordapp.com/attachments/\S+)", message.content) if cdn_link or message.attachments: self.logger.info("Found file in message") try: - file = await message.attachments[0].to_file() - file = file.fp - name = message.attachments[0].filename - self.logger.info("Acquired file from discord API") + with message.channel.typing(): + file = await message.attachments[0].to_file() + file = file.fp + name = message.attachments[0].filename + self.logger.info("Acquired file from discord API") except IndexError: self.logger.info("Couldn't acquire file from discord API") try: self.logger.info("Attempting to acquire linked file manually") - url = cdn_link.group(1) - name = url.split("/")[-1] - async with self.bot.web_session.get(url) as response: + att_url = cdn_link.group(1) + url_path = urlparse(att_url).path + _, name = split(url_path) + async with self.bot.web_session.get(att_url) as response, message.channel.typing(): assert response.status == 200, f"the web request failed with status {response.status}" file = io.BytesIO(await response.read()) except (AttributeError, AssertionError) as e: @@ -353,7 +370,7 @@ async def process_message(self, message) -> bool: extension = name.rpartition(".")[-1] - if name.startswith("SMMDebug") or extension == "log": + if name.startswith("SMMDebug") or extension in ("log", "zip"): async with message.channel.typing(): responses += await self.process_file(file, extension) else: @@ -362,14 +379,14 @@ async def process_message(self, message) -> bool: file.close() # Pastebin links - elif match := re.search(r"(https://pastebin.com/\S+)", message.content): - url = re.sub(r"(?<=bin.com)/", "/raw/", match.group(1)) - async with self.bot.web_session.get(url) as response: + elif match := regex.search(r"(https://pastebin.com/\S+)", message.content): + url = regex.sub(r"(?<=bin.com)/", "/raw/", match.group(1)) + async with self.bot.web_session.get(url) as response, message.channel.typing(): text: str = await response.text() - responses += await self.process_text(text) - maybe_log_info = await self.parse_factory_game_log(message.content) - responses += await self.complex_responses(maybe_log_info) + responses += await self.process_text(text) + maybe_log_info = await self.parse_factory_game_log(message.content) + responses += await self.complex_responses(maybe_log_info) else: responses += await self.process_text(message.content) @@ -378,10 +395,12 @@ async def process_message(self, message) -> bool: await self.bot.reply_to_msg(message, embed=createembed.crashes(responses)) else: for response in responses: - attachment = response.get("attachment") - if attachment is not None: - async with self.bot.web_session.get(attachment) as resp: + att_url: str = response.get("attachment") + if att_url is not None: + async with self.bot.web_session.get(att_url) as resp: buff = io.BytesIO(await resp.read()) - attachment = nextcord.File(filename=attachment.split("/")[-1], fp=buff) + attachment = nextcord.File(filename=att_url.split("/")[-1], fp=buff) + else: + attachment = None await self.bot.reply_to_msg(message, response["value"], file=attachment, propagate_reply=False) return len(responses) > 0 diff --git a/fred/cogs/dialogflow.py b/fred/cogs/dialogflow.py index 8823276..e0c46a4 100644 --- a/fred/cogs/dialogflow.py +++ b/fred/cogs/dialogflow.py @@ -1,30 +1,32 @@ import asyncio import json -import logging -import os import uuid +from os import getenv import nextcord -from nextcord.ext import commands from google.cloud import dialogflow from google.oauth2 import service_account from .. import config from ..libraries import common -DIALOGFLOW_AUTH = json.loads(os.environ.get("DIALOGFLOW_AUTH")) -session_client = dialogflow.SessionsClient( - credentials=service_account.Credentials.from_service_account_info(DIALOGFLOW_AUTH) -) -DIALOGFLOW_PROJECT_ID = DIALOGFLOW_AUTH["project_id"] -SESSION_LIFETIME = 10 * 60 # 10 minutes to avoid repeated false positives - - -class DialogFlow(commands.Cog): - def __init__(self, bot): - self.bot = bot +if env_auth_str := getenv("DIALOGFLOW_AUTH"): + DIALOGFLOW_AUTH = json.loads(env_auth_str) + session_client = dialogflow.SessionsClient( + credentials=service_account.Credentials.from_service_account_info(DIALOGFLOW_AUTH) + ) + DIALOGFLOW_PROJECT_ID = DIALOGFLOW_AUTH["project_id"] + SESSION_LIFETIME = 10 * 60 # 10 minutes to avoid repeated false positives +else: + DIALOGFLOW_AUTH = None + session_client = None + DIALOGFLOW_PROJECT_ID = None + + +class DialogFlow(common.FredCog): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.session_ids = {} - self.logger = logging.Logger("DIALOGFLOW") async def process_message(self, message): self.bot.logger.info("Processing NLP") @@ -37,8 +39,10 @@ async def process_message(self, message): # We're in a DM channel self.logger.info("Ignoring a message because it is in a DM channel", extra=common.message_info(message)) return - if not config.Misc.fetch("dialogflow_state"): - self.logger.info("Ignoring a message because NLP is disabled", extra=common.message_info(message)) + if not config.Misc.fetch("dialogflow_state") or DIALOGFLOW_AUTH is None: + self.logger.info( + "Ignoring a message because NLP is disabled or not configured", extra=common.message_info(message) + ) return if not config.Misc.fetch("dialogflow_debug_state"): self.logger.info("Checking someone's permissions", extra=common.user_info(message.author)) @@ -51,7 +55,7 @@ async def process_message(self, message): for role in roles: if role.id not in exception_roles: self.logger.info( - "Ignoring someone's message because they are authorised", extra=common.user_info(message.author) + "Ignoring someone's message because they are exempt", extra=common.user_info(message.author) ) return diff --git a/fred/cogs/levelling.py b/fred/cogs/levelling.py index 3b800b5..c12d988 100644 --- a/fred/cogs/levelling.py +++ b/fred/cogs/levelling.py @@ -4,36 +4,56 @@ import math from datetime import * -import nextcord.ext.commands as commands -from nextcord import DMChannel +from nextcord import DMChannel, Message, Guild +from nextcord.ext.commands import MemberNotFound from .. import config from ..libraries import common +logger = common.new_logger(__name__) + class UserProfile: - def __init__(self, user_id, guild, bot): + def __init__(self, user_id: int, guild: Guild): self.guild = guild - self.bot = bot self.user_id = user_id - self.logger = logging.Logger("USERPROFILE") - assert bot.intents.members, "The bot has no member intents, so it cannot do levelling!!" - self.member = bot.get_user(user_id) - assert self.member is not None, "This member does not exist o.0" - self.logger.info(f"Found member id {self.member}") + self.member = guild.get_member(user_id) + if self.member is None: + logger.warning(f"Unable to retrieve information about user {user_id}") + raise MemberNotFound(f"Unable to retrieve information about user {user_id}") + + logger.info(f"Found member id {self.member}") if DB_user := config.Users.fetch(user_id): self.DB_user = DB_user else: - self.DB_user = config.Users(user_id=user_id, full_name=f"{self.member.name}#{self.member.discriminator}") + self.DB_user = config.Users(user_id=user_id) + + self._rank: int = DB_user.rank + self._xp_count: int = DB_user.xp_count + + @property + def rank(self): + return self._rank - self.rank = self.DB_user.rank - self.xp_count = self.DB_user.xp_count + @rank.setter + def rank(self, value: int): + self._rank = value + self.DB_user.rank = value + + @property + def xp_count(self): + return self._xp_count + + @xp_count.setter + def xp_count(self, value: int): + self._xp_count = value + self.DB_user.xp_count = value async def validate_role(self): if not self.member: - self.logger.info( + logger.info( "Could not validate someone's level role because they aren't in the main guild", extra={"user_id": self.user_id}, ) @@ -43,25 +63,26 @@ async def validate_role(self): role = self.guild.get_role(role_id) if not role: logpayload["role_id"] = role_id - self.logger.warning( + logger.warning( "Could not validate someone's level role because the role isn't in the main guild", extra=logpayload ) return self.DB_user.rank_role_id = role_id - # self.rank_role_id = role_id - if not self.member.permissions_in(self.bot.modchannel).send_messages: + + # if not self.guild.get_channel(config.Misc.fetch("mod_channel")).permissions_for(self.member).send_messages: + if not common.permission_check(self.member, level=6): for member_role in self.member.roles: - if rank := config.RankRoles.fetch_by_role(member_role.id): + if config.RankRoles.fetch_by_role(member_role.id) is not None: # i.e. member_role is a rank role logpayload["role_id"] = member_role.id - self.logger.info("Removing a mismatched level role from someone", extra=logpayload) + logger.info("Removing a mismatched level role from someone", extra=logpayload) await self.member.remove_roles(member_role) logpayload["role_id"] = role.id - self.logger.info("Removing a mismatched level role from someone", logpayload) + logger.info("Removing a mismatched level role from someone", logpayload) await self.member.add_roles(role) async def validate_level(self): if not self.member: - self.logger.info( + logger.info( "Could not validate someone's level because they aren't in the main guild", extra={"user_id": self.user_id}, ) @@ -78,26 +99,26 @@ async def validate_level(self): logpayload["expected_level"] = expected_level logpayload["current_level"] = self.rank if expected_level != self.rank: - self.logger.info("Correcting a mismatched level", extra=logpayload) - self.DB_user.rank = expected_level + logger.info("Correcting a mismatched level", extra=logpayload) if self.DB_user.accepts_dms: if expected_level > self.rank: - await self.bot.send_DM( + await Levelling.bot.send_DM( self.member, f"You went up from level {self.rank} to level {expected_level}! " f"Congratulations!", ) else: - await self.bot.send_DM( + await Levelling.bot.send_DM( self.member, f"You went down from level {self.rank} to level {expected_level}... " f"Sorry about that", ) + self.rank = expected_level await self.validate_role() async def increment_xp(self): xp_gain = config.Misc.fetch("xp_gain_value") * self.DB_user.xp_multiplier * self.DB_user.role_xp_multiplier logpayload = common.user_info(self.member) logpayload["xp_increment"] = xp_gain - self.logger.info("Incrementing someone's xp", logpayload) + logger.info("Incrementing someone's xp", logpayload) await self.give_xp(xp_gain) async def give_xp(self, xp): @@ -105,20 +126,18 @@ async def give_xp(self, xp): return logpayload = common.user_info(self.member) logpayload["xp_gain"] = xp - self.logger.info("Giving someone xp", logpayload) - self.DB_user.xp_count += xp + logger.info("Giving someone xp", logpayload) self.xp_count += xp await self.validate_level() return True async def take_xp(self, xp): - if xp > self.DB_user.xp_count: + if xp > self.xp_count: return False # can't take more than a user has logpayload = common.user_info(self.member) logpayload["xp_loss"] = xp - self.logger.info("Taking xp from someone", logpayload) - self.DB_user.xp_count -= xp + logger.info("Taking xp from someone", logpayload) self.xp_count -= xp await self.validate_level() return True @@ -126,27 +145,27 @@ async def take_xp(self, xp): async def set_xp(self, xp): logpayload = common.user_info(self.member) logpayload["new_xp"] = xp - self.logger.info("Setting someone's xp", logpayload) - self.DB_user.xp_count = xp + logger.info("Setting someone's xp", logpayload) self.xp_count = xp await self.validate_level() return True -class Levelling(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.bot.xp_timers = {} - self.logger = logging.Logger("LEVELLING") +class Levelling(common.FredCog): + xp_timers = {} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.xp_timers = {} # TODO make xp roles - # @commands.Cog.listener() + # @commonn.FredCog.listener() # async def on_member_update(self, before, after): # if before.roles != after.roles: # config.XpRoles - @commands.Cog.listener() - async def on_message(self, message): + @common.FredCog.listener() + async def on_message(self, message: Message): self.logger.info("Levelling: Processing message", extra=common.message_info(message)) if ( message.author.bot @@ -156,14 +175,14 @@ async def on_message(self, message): ): return - profile = UserProfile(message.author.id, message.guild, self.bot) + profile = UserProfile(message.author.id, message.guild) profile.DB_user.message_count += 1 - if profile.user_id in self.bot.xp_timers: - if datetime.now() >= self.bot.xp_timers[profile.user_id]: + if profile.user_id in self.xp_timers: + if datetime.now() >= self.xp_timers[profile.user_id]: await profile.increment_xp() else: self.logger.info( "Levelling: Someone sent a message too fast and will not be awarded xp", extra=common.message_info(message), ) - self.bot.xp_timers[profile.user_id] = datetime.now() + timedelta(seconds=config.Misc.fetch("xp_gain_delay")) + self.xp_timers[profile.user_id] = datetime.now() + timedelta(seconds=config.Misc.fetch("xp_gain_delay")) diff --git a/fred/cogs/mediaonly.py b/fred/cogs/mediaonly.py index 9cf2f49..ee603ee 100644 --- a/fred/cogs/mediaonly.py +++ b/fred/cogs/mediaonly.py @@ -1,15 +1,9 @@ -import logging - -import nextcord.ext.commands as commands - -from ..libraries import common from .. import config +from ..libraries import common +from ..libraries.common import FredCog -class MediaOnly(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.logger = logging.Logger("MEDIA_ONLY") +class MediaOnly(FredCog): async def process_message(self, message): self.logger.info("Processing a message", extra=common.message_info(message)) @@ -20,7 +14,7 @@ async def process_message(self, message): if await common.l4_only(ctx): self.logger.info("Message doesn't contain media but the author is a T3", extra=common.message_info(message)) return False - if config.MediaOnlyChannels.fetch(message.channel.id): + if config.MediaOnlyChannels.check(message.channel.id): self.logger.info("Removing a message", extra=common.message_info(message)) await message.delete() await self.bot.send_DM( diff --git a/fred/cogs/webhooklistener.py b/fred/cogs/webhooklistener.py index a380e1a..7e4e913 100644 --- a/fred/cogs/webhooklistener.py +++ b/fred/cogs/webhooklistener.py @@ -1,38 +1,48 @@ +from __future__ import annotations + import asyncio import json -import logging -import os import socket import sys import threading import traceback from http.server import BaseHTTPRequestHandler, HTTPServer +from os import getenv +from typing import TYPE_CHECKING + +from fred.libraries import common -import nextcord.ext.commands as commands +if TYPE_CHECKING: + from fred.fred import Bot -logger = logging.Logger("GITHOOK") +logger = common.new_logger(__name__) def runServer(self, bot): logger.info("Running the webserver for the Githook") - server = HTTPServerV6((os.environ.get("FRED_IP"), int(os.environ.get("FRED_PORT"))), MakeGithookHandler(bot)) - server.serve_forever() + ip = getenv("FRED_IP") + port = int(getenv("FRED_PORT")) + try: + server = HTTPServerV6((ip, port), MakeGithookHandler(bot)) + server.serve_forever() + except PermissionError as pe: + logger.error(f"Cannot handle githooks! Permission denied to listen to {ip=} {port=}.") + logger.exception(pe) -class Githook(commands.Cog): - def __init__(self, bot): - self.bot = bot +class Githook(common.FredCog): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) # Run GitHub webhook handling server try: - botargs = [bot, bot] - daemon = threading.Thread(target=runServer, args=botargs) + daemon = threading.Thread(target=runServer, args=[self.bot, self.bot]) daemon.daemon = True daemon.start() - except Exception: - type, value, tb = sys.exc_info() + except Exception: # noqa + exc_type, value, tb = sys.exc_info() tbs = "".join(traceback.format_tb(tb)) - logger.error(f"Failed to run the webserver:\n{tbs}") + self.logger.error(f"Failed to run the webserver:\n{tbs}") # handle POST events from GitHub server @@ -43,7 +53,7 @@ def __init__(self, bot): EVENT_TYPE = "x-github-event" -def MakeGithookHandler(bot): +def MakeGithookHandler(bot: Bot): class MyGithookHandler(BaseHTTPRequestHandler): def respond(self, code: int, message: str | None = None): self.send_response(code, message) @@ -55,17 +65,17 @@ def handle_check(self): case "/ready": self.respond(200 if bot.isReady else 503) case "/healthy": - logging.info("handling /healthy") + logger.info("handling /healthy") fut = asyncio.run_coroutine_threadsafe(bot.isAlive(), bot.loop) - logging.info("waiting for result from healthcheck") + logger.info("waiting for result from healthcheck") healthy = fut.result(5) - logging.info("responding") + logger.info("responding") self.respond(200 if healthy else 503) case _: self.respond(200) except Exception as e: - logging.error(f"Errored during check") - logging.exception(e) + logger.error(f"Errored during check") + logger.exception(e) def do_HEAD(self): self.handle_check() diff --git a/fred/cogs/welcome.py b/fred/cogs/welcome.py index 22b2181..365fdf0 100644 --- a/fred/cogs/welcome.py +++ b/fred/cogs/welcome.py @@ -1,20 +1,20 @@ -from nextcord.ext import commands +from nextcord import Member from .. import config from ..libraries import common -class Welcome(commands.Cog): - def __init__(self, bot): - self.bot = bot +class Welcome(common.FredCog): + @common.FredCog.listener() + async def on_member_join(self, member: Member): + + self.logger.info("Processing a member joining", extra=common.user_info(member)) - @commands.Cog.listener() - async def on_member_join(self, member): - self.bot.logger.info("Processing a member joining", extra=common.user_info(member)) if welcome := config.Misc.fetch("welcome_message"): - self.bot.logger.info("Sending the welcome message to a new member", extra=common.user_info(member)) + self.logger.info("Sending the welcome message to a new member", extra=common.user_info(member)) await self.bot.send_DM(member, welcome) + if info := config.Misc.fetch("latest_info"): - self.bot.logger.info("Sending the latest information to a new member", extra=common.user_info(member)) + self.logger.info("Sending the latest information to a new member", extra=common.user_info(member)) info = f"Here's the latest information :\n\n{info}" await self.bot.send_DM(member, info) diff --git a/fred/config.py b/fred/config.py index 08ab441..8272720 100644 --- a/fred/config.py +++ b/fred/config.py @@ -1,5 +1,13 @@ -from sqlobject import * +from __future__ import annotations + import json +import os +import pathlib +from numbers import Number +from typing import Optional, Any + +import nextcord +from sqlobject import SQLObject, IntCol, BoolCol, JSONCol, BigIntCol, StringCol, FloatCol, sqlhub class PermissionRoles(SQLObject): @@ -11,12 +19,12 @@ class sqlmeta: role_name = StringCol() @staticmethod - def fetch_by_lvl(perm_lvl): + def fetch_by_lvl(perm_lvl: int) -> list[PermissionRoles]: query = PermissionRoles.select(PermissionRoles.q.perm_lvl >= perm_lvl).orderBy("-perm_lvl") return list(query) @staticmethod - def fetch_by_role(role_id): + def fetch_by_role(role_id: int) -> list[PermissionRoles]: query = PermissionRoles.selectBy(role_id=role_id) return list(query) @@ -29,16 +37,14 @@ class sqlmeta: role_id = BigIntCol() @staticmethod - def fetch_by_rank(rank): - query = RankRoles.select(RankRoles.q.rank <= rank).orderBy("-rank") - results = list(query) - return results[0].role_id if results else None + def fetch_by_rank(rank: int) -> Optional[int]: + query = RankRoles.select(RankRoles.q.rank <= rank).orderBy("-rank").getOne(None) + return getattr(query, "role_id", None) @staticmethod - def fetch_by_role(role_id): - query = RankRoles.selectBy(role_id=role_id) - results = list(query) - return results[0].rank if results else None + def fetch_by_role(role_id: int) -> Optional[int]: + query = RankRoles.selectBy(role_id=role_id).getOne(None) + return getattr(query, "rank", None) class XpRoles(SQLObject): @@ -49,15 +55,12 @@ class sqlmeta: role_id = BigIntCol() @staticmethod - def fetch(role_id): - query = XpRoles.selectBy(role_id=role_id) - results = list(query) - return results[0] if results else None + def fetch(role_id: int) -> Optional[XpRoles]: + return XpRoles.selectBy(role_id=role_id).getOne(None) class Users(SQLObject): user_id = BigIntCol() - full_name = StringCol() message_count = IntCol(default=0) xp_count = FloatCol(default=0) xp_multiplier = FloatCol(default=1) @@ -66,31 +69,23 @@ class Users(SQLObject): rank_role_id = BigIntCol(default=None) accepts_dms = BoolCol(default=True) - def as_dict(self): + def as_dict(self) -> dict[str, Any]: return dict( user_id=self.user_id, message_count=self.message_count, xp_count=self.xp_count, rank_role_id=self.rank_role_id, rank=self.rank, - full_name=self.full_name, accepts_dms=self.accepts_dms, ) @staticmethod - def fetch(user_id): - query = Users.selectBy(user_id=user_id) - results = list(query) - return results[0] if results else None + def fetch(user_id: int) -> Users: + return Users.selectBy(user_id=user_id).getOne(None) @staticmethod - def create_if_missing(user): - query = Users.selectBy(user_id=user.id) - results = list(query) - if results: - return results[0] - else: - return Users(user_id=user.id, full_name=f"{user.name}#{user.discriminator}") + def create_if_missing(user: nextcord.User) -> Users: + return Users.selectBy(user_id=user.id).getOne(False) or Users(user_id=user.id) class ActionColours(SQLObject): @@ -101,10 +96,9 @@ class sqlmeta: colour = IntCol() @staticmethod - def fetch(name): - query = ActionColours.selectBy(name=name.lower()) - results = list(query) - return results[0].colour if results else None + def fetch(name: str) -> Optional[int]: + query = ActionColours.selectBy(name=name.lower()).getOne(None) + return getattr(query, "colour", None) class MediaOnlyChannels(SQLObject): @@ -114,10 +108,8 @@ class sqlmeta: channel_id = BigIntCol() @staticmethod - def fetch(channel_id): - query = MediaOnlyChannels.selectBy(channel_id=channel_id) - results = list(query) - return results[0].channel_id if results else None + def check(channel_id: int) -> bool: + return bool(MediaOnlyChannels.selectBy(channel_id=channel_id).getOne(False)) class DialogflowChannels(SQLObject): @@ -127,10 +119,8 @@ class sqlmeta: channel_id = BigIntCol() @staticmethod - def fetch(channel_id): - query = DialogflowChannels.selectBy(channel_id=channel_id) - results = list(query) - return results[0].channel_id if results else None + def check(channel_id: int) -> bool: + return bool(DialogflowChannels.selectBy(channel_id=channel_id).getOne(False)) class DialogflowExceptionRoles(SQLObject): @@ -140,16 +130,13 @@ class sqlmeta: role_id = BigIntCol() @staticmethod - def fetch(role_id): - query = DialogflowExceptionRoles.selectBy(role_id=role_id) - results = list(query) - return results[0].role_id if results else None + def check(role_id: int) -> bool: + return bool(DialogflowExceptionRoles.selectBy(role_id=role_id).getOne(False)) @staticmethod - def fetch_all(): + def fetch_all() -> list[int]: query = DialogflowExceptionRoles.select() - results = list(query) - return [role.role_id for role in results] + return [role.role_id for role in query.lazyIter()] class Dialogflow(SQLObject): @@ -158,7 +145,7 @@ class Dialogflow(SQLObject): response = StringCol() has_followup = BoolCol() - def as_dict(self): + def as_dict(self) -> dict[str, Any]: return dict( intent_id=self.intent_id, data=json.loads(str(self.data)) if self.data else None, @@ -167,10 +154,11 @@ def as_dict(self): ) @staticmethod - def fetch(intent_id, data): - query = Dialogflow.selectBy(intent_id=intent_id, data=data) - results = list(query) - return results[0].as_dict() if results else None + def fetch(intent_id: str, data: dict) -> Optional[Dialogflow]: + return Dialogflow.selectBy(intent_id=intent_id, data=data).getOne(None) + + +type CommandsOrCrashesDict = dict[str, str | StringCol] class Commands(SQLObject): @@ -178,30 +166,32 @@ class Commands(SQLObject): content = StringCol() attachment = StringCol(default=None) - def as_dict(self) -> dict: + def as_dict(self) -> CommandsOrCrashesDict: return dict(name=self.name, content=self.content, attachment=self.attachment) @staticmethod - def fetch(name) -> dict | None: - query = Commands.selectBy(name=name.lower()) - results = list(query) - return results[0].as_dict() if results else None + def fetch(name: str) -> Optional[CommandsOrCrashesDict]: + query: Optional[Commands] + if query := Commands.selectBy(name=name.lower()).getOne(None): + return query.as_dict() + return None @classmethod - def fetch_by(cls, col: str, val: str) -> dict | None: + def fetch_by(cls, col: str, val: str) -> Optional[CommandsOrCrashesDict]: + # used by the search command to get a specific value if possible col, val = col.lower(), val.lower() - if not isinstance(getattr(cls, col), property): + if not isinstance(getattr(cls, col, None), property): raise KeyError("This is not a valid column!") - query = cls.selectBy(**{col: val}) - results: list[Commands] = list(query) - return results[0].as_dict() if results else None + query: Optional[Commands] = cls.selectBy(**{col: val}).getOne(None) + if query is None: + return None + return query.as_dict() @staticmethod - def fetch_all() -> list[dict]: + def fetch_all() -> list[CommandsOrCrashesDict]: query = Commands.selectBy() - results = list(query) - return [cmd.as_dict() for cmd in results] + return [cmd.as_dict() for cmd in query.lazyIter()] class Crashes(SQLObject): @@ -209,30 +199,31 @@ class Crashes(SQLObject): crash = StringCol() response = StringCol() - def as_dict(self) -> dict: + def as_dict(self) -> CommandsOrCrashesDict: return dict(name=self.name, response=self.response, crash=self.crash) @staticmethod - def fetch(name) -> dict | None: - query = Crashes.selectBy(name=name.lower()) - results = list(query) - return results[0].as_dict() if results else None + def fetch(name: str) -> Optional[CommandsOrCrashesDict]: + query: Optional[Crashes] + if (query := Crashes.selectBy(name=name.lower()).getOne(None)) is not None: + return query.as_dict() + return None @classmethod - def fetch_by(cls, col: str, val: str) -> dict | None: + def fetch_by(cls, col: str, val: str) -> Optional[CommandsOrCrashesDict]: col, val = col.lower(), val.lower() if not isinstance(getattr(cls, col), property): raise KeyError("This is not a valid column!") - query = cls.selectBy(**{col: val}) - results: list[Commands] = list(query) - return results[0].as_dict() if results else None + query: Optional[Crashes] = cls.selectBy(**{col: val}).getOne(None) + if query is None: + return None + return query.as_dict() @staticmethod - def fetch_all() -> list[dict]: + def fetch_all() -> list[CommandsOrCrashesDict]: query = Crashes.selectBy() - results = list(query) - return [crash.as_dict() for crash in results] + return [crash.as_dict() for crash in query.lazyIter()] class ReservedCommands(SQLObject): @@ -242,10 +233,12 @@ class sqlmeta: name = StringCol() @staticmethod - def fetch(name): - query = ReservedCommands.selectBy(name=name.lower()) - results = list(query) - return bool(results) + def check(name: str) -> bool: + query = ReservedCommands.selectBy(name=name.lower()).getOne(False) + return bool(query) + + +type JSONValue = Number | bool | str | list | dict class Misc(SQLObject): @@ -256,14 +249,41 @@ class sqlmeta: value = JSONCol() @staticmethod - def fetch(key): - query = Misc.selectBy(key=key) - results = list(query) - return results[0].value if results else None + def fetch(key: str) -> Optional[JSONValue]: + query = Misc.selectBy(key=key).getOne(None) + return getattr(query, "value", None) + + @staticmethod + def change(key: str, value: JSONValue): + query = Misc.selectBy(key=key).getOne(None) + if query is not None: + query.value = value @staticmethod - def change(key, value): - query = Misc.selectBy(key=key) - results = list(query) - if results: - results[0].value = value + def create_or_change(key: str, value: JSONValue): + query: Optional[Misc] = Misc.selectBy(key=key).getOne(None) + if query is None: + Misc(key=key, value=value) + else: + query.value = value + + +def migrate(): + current_migration_rev = Misc.fetch("migration_rev") + if current_migration_rev is None: + current_migration_rev = 0 + + migrations_dir = pathlib.Path(os.path.dirname(os.path.abspath(__file__))) + migrations_filenames = list(migrations_dir.glob("migrations/*-*.up.sql")) + valid_migrations = [ + migration for migration in migrations_filenames if _migration_rev(migration) > current_migration_rev + ] + + for migration in valid_migrations: + sqlhub.processConnection.query(migration.read_text()) + + Misc.create_or_change("migration_rev", _migration_rev(migrations_filenames[0])) + + +def _migration_rev(filepath: pathlib.Path) -> int: + return int(filepath.name.split("-")[0]) diff --git a/fred/fred.py b/fred/fred.py index 56522ff..d59c14d 100644 --- a/fred/fred.py +++ b/fred/fred.py @@ -1,43 +1,40 @@ import asyncio -import logging -import os import sys -import textwrap import time import traceback +from os import getenv import aiohttp import nextcord -from nextcord.ext import commands import sqlobject as sql +from nextcord.ext import commands from . import config -from .fred_commands import Commands, FredHelpEmbed from .cogs import crashes, dialogflow, mediaonly, webhooklistener, welcome, levelling +from .fred_commands import Commands, FredHelpEmbed from .libraries import createembed, common - -__version__ = "2.20.4" +__version__ = "2.21.0" class Bot(commands.Bot): async def isAlive(self): try: - logging.info("getting from config") + self.logger.info("Healthcheck: Attempting DB fetch") _ = config.Misc.get(1) - logging.info("creating user fetch") + self.logger.info("Healthcheck: Creating user fetch") coro = self.fetch_user(227473074616795137) - logging.info("fetching user") + self.logger.info("Healthcheck: Executing user fetch") await asyncio.wait_for(coro, timeout=5) except Exception as e: - self.logger.error(f"Healthiness check failed: {e}") + self.logger.error(f"Healthcheck failed: {e}") return False return True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.isReady = False - self.setup_logger() + self.logger = common.new_logger(self.__class__.__name__) self.setup_DB() self.command_prefix = config.Misc.fetch("prefix") self.setup_cogs() @@ -46,11 +43,11 @@ def __init__(self, *args, **kwargs): self.owo = False self.loop = asyncio.new_event_loop() - self._error_channel = int(env_val) if (env_val := os.getenv("ERROR_CHANNEL")) else 748229790825185311 + self._error_channel = int(chan) if (chan := config.Misc.fetch("error_channel")) else 748229790825185311 async def start(self, *args, **kwargs): async with aiohttp.ClientSession() as session: - self.web_session = session + self.web_session = session # noqa return await super().start(*args, **kwargs) @staticmethod @@ -60,7 +57,7 @@ def is_running(): async def on_ready(self): await self.change_presence(activity=nextcord.Game(f"v{self.version}")) self.isReady = True - logging.info(f"We have logged in as {self.user} with prefix {self.command_prefix}") + self.logger.info(f"We have logged in as {self.user} with prefix {self.command_prefix}") @staticmethod async def on_reaction_add(reaction: nextcord.Reaction, user: nextcord.User) -> None: @@ -69,11 +66,11 @@ async def on_reaction_add(reaction: nextcord.Reaction, user: nextcord.User) -> N def setup_DB(self): self.logger.info("Connecting to the database") - user = os.environ.get("FRED_SQL_USER") - password = os.environ.get("FRED_SQL_PASSWORD") - host = os.environ.get("FRED_SQL_HOST") - port = os.environ.get("FRED_SQL_PORT") - dbname = os.environ.get("FRED_SQL_DB") + user = getenv("FRED_SQL_USER") + password = getenv("FRED_SQL_PASSWORD") + host = getenv("FRED_SQL_HOST") + port = getenv("FRED_SQL_PORT") + dbname = getenv("FRED_SQL_DB") uri = f"postgresql://{user}:{password}@{host}:{port}/{dbname}" attempt = 1 while attempt < 6: @@ -87,18 +84,12 @@ def setup_DB(self): time.sleep(5) else: # this happens if the loop is not broken by a successful connection raise ConnectionError("Could not connect to the DB") - - def setup_logger(self): - logging.root = logging.Logger("FRED") - logging.root.setLevel(logging.DEBUG) - - self.logger = logging.root - - self.logger.info("Successfully set up the logger") - self.logger.info(f"Prefix: {self.command_prefix}") + self.logger.info(f"Connected to the DB. Took {attempt} tries.") + config.migrate() + self.logger.debug("Applied migration.") def setup_cogs(self): - logging.info("Setting up cogs") + self.logger.info("Setting up cogs") self.add_cog(Commands(self)) self.add_cog(webhooklistener.Githook(self)) self.add_cog(mediaonly.MediaOnly(self)) @@ -106,13 +97,23 @@ def setup_cogs(self): self.add_cog(dialogflow.DialogFlow(self)) self.add_cog(welcome.Welcome(self)) self.add_cog(levelling.Levelling(self)) - self.MediaOnly = self.get_cog("MediaOnly") - self.Crashes = self.get_cog("Crashes") - self.DialogFlow = self.get_cog("DialogFlow") - logging.info("Successfully set up cogs") + + self.logger.info("Successfully set up cogs") + + @property + def MediaOnly(self) -> mediaonly.MediaOnly: + return self.get_cog("MediaOnly") # noqa + + @property + def Crashes(self) -> crashes.Crashes: + return self.get_cog("Crashes") # noqa + + @property + def DialogFlow(self) -> dialogflow.DialogFlow: + return self.get_cog("DialogFlow") # noqa async def on_error(self, event, *args, **kwargs): - type, value, tb = sys.exc_info() + exc_type, value, tb = sys.exc_info() if event == "on_message": channel: nextcord.TextChannel | nextcord.DMChannel = args[0].channel if isinstance(channel, nextcord.DMChannel): @@ -123,9 +124,9 @@ async def on_error(self, event, *args, **kwargs): channel_str = "" fred_str = f"Fred v{self.version}" - error_meta = f"{type.__name__} exception handled in {event}{channel_str}" + error_meta = f"{exc_type.__name__} exception handled in `{event}` {channel_str}" full_error = f"\n{value}\n\n{''.join(traceback.format_tb(tb))}" - logging.error(f"{fred_str}\n{error_meta}\n{full_error}") + self.logger.error(f"{fred_str}\n{error_meta}\n{full_error}") # error_embed = nextcord.Embed(colour=nextcord.Colour.red(), title=error_meta, description=full_error) # error_embed.set_author(name=fred_str) @@ -145,7 +146,7 @@ async def githook_send(self, data): async def send_DM( self, - user: nextcord.User, + user: nextcord.User | nextcord.Member, content: str = None, embed: nextcord.Embed = None, user_meta: config.Users = None, @@ -178,7 +179,7 @@ async def send_DM( content = None await user.dm_channel.send(content=content, embed=embed, **kwargs) return True - except Exception: + except Exception: # noqa self.logger.error(f"DMs: Failed to DM, reason: \n{traceback.format_exc()}") return False @@ -227,7 +228,7 @@ async def reply_yes_or_no(self, message, question, **kwargs): await self.reply_to_msg(message, "Invalid bool string. Aborting") raise ValueError(f"Could not convert {s} to bool") - async def on_message(self, message): + async def on_message(self, message: nextcord.Message): self.logger.info("Processing a message", extra=common.message_info(message)) if (is_bot := message.author.bot) or not self.is_running(): self.logger.info( @@ -250,7 +251,9 @@ async def on_message(self, message): removed = await self.MediaOnly.process_message(message) if not removed: - if message.content.startswith(self.command_prefix): + before, space, after = message.content.partition(" ") + # if the prefix is the only thing before the space then this isn't a command + if before.startswith(self.command_prefix) and len(before) > 1: self.logger.info("Processing commands") await self.process_commands(message) else: diff --git a/fred/fred_commands/__init__.py b/fred/fred_commands/__init__.py index af58da1..3f03392 100644 --- a/fred/fred_commands/__init__.py +++ b/fred/fred_commands/__init__.py @@ -1,21 +1,20 @@ from __future__ import annotations -import logging - import asyncio import inspect import io +import logging import re import nextcord -from nextcord.ext.commands.view import StringView from algoliasearch.search_client import SearchClient +from nextcord.ext.commands.view import StringView from ._baseclass import BaseCmds, common, config, commands from .bot_meta import BotCmds from .channels import ChannelCmds -from .dbcommands import CommandCmds from .crashes import CrashCmds +from .dbcommands import CommandCmds from .dialogflow import DialogflowCmds from .experience import EXPCmds from .help import HelpCmds, FredHelpEmbed @@ -30,7 +29,7 @@ async def on_command_error(self, ctx: commands.Context, error): self.logger.error(f"Caught {error!r}, {dir(error)}") if isinstance(error, commands.CommandNotFound): command = ctx.message.content.lower().lstrip(self.bot.command_prefix).split(" ")[0] - if config.Commands.fetch(command): + if config.Commands.fetch(command) is not None: return self.logger.warning("Invalid command attempted") return @@ -75,32 +74,33 @@ async def on_message(self, message: nextcord.Message): if message.content.startswith(prefix): name = message.content.lower().lstrip(prefix).split(" ")[0] self.logger.info(f"Processing the command {name}", extra=common.message_info(message)) - if command := config.Commands.fetch(name): + if (command := config.Commands.fetch(name)) is not None: if ( (content := command["content"]) and content.startswith(prefix) # for linked aliases of commands like ff->rp - and (linked_command := config.Commands.fetch(command["content"].lstrip(prefix))) + and (linked_command := config.Commands.fetch(content.lstrip(prefix))) ): command = linked_command - attachment = None - if command["attachment"]: - async with self.bot.web_session.get(command["attachment"]) as resp: + if (attachment := command["attachment"]) is not None: + async with self.bot.web_session.get(attachment) as resp: buff = io.BytesIO(await resp.read()) - attachment = nextcord.File(filename=command["attachment"].split("/")[-1], fp=buff) + attachment = nextcord.File(filename=attachment.split("/")[-1], fp=buff) args = [] view = StringView(message.content.lstrip(prefix)) view.get_word() # command name while not view.eof: view.skip_ws() args.append(view.get_quoted_word()) - if command["content"] is not None: + if content: + # ok who wrote this unreadable garbage? oh wait, it was me - Borketh + # this should probably be simplified... text = re.sub( r"{(\d+)}", - lambda match: args[int(match.group(1))] - if int(match.group(1)) < len(args) - else "(missing argument)", - command["content"], + lambda match: ( + args[int(match.group(1))] if int(match.group(1)) < len(args) else "(missing argument)" + ), + content, ).replace("{...}", " ".join(args)) else: text = None @@ -114,6 +114,10 @@ async def mod(self, ctx: commands.Context, *, mod_name: str) -> None: Response: If a near-exact match is found, gives you info about that mod. If close matches are found, up to 10 of those will be listed. If nothing even comes close, I'll let you know ;)""" + if len(mod_name) < 3: + await self.bot.reply_to_msg(ctx.message, "Searching needs at least three characters!") + return + embed, attachment, multiple_mods = await createembed.mod_embed(mod_name, self.bot) if embed is None: await self.bot.reply_to_msg(ctx.message, "No mods found!") @@ -124,6 +128,7 @@ async def mod(self, ctx: commands.Context, *, mod_name: str) -> None: view = None msg = await self.bot.reply_to_msg(ctx.message, embed=embed, view=view, file=attachment) if view: + async def callback(interaction: nextcord.Interaction): logging.info(interaction.data.values) new_embed, new_attachment, _ = await createembed.mod_embed(interaction.data["values"][0], self.bot) @@ -140,7 +145,7 @@ async def timeout(): await view.wait() - @commands.command(aliases=['docssearch']) + @commands.command(aliases=["docssearch"]) async def docsearch(self, ctx: commands.Context, *, search: str) -> None: """Usage: `docsearch (search: str)` Response: Equivalent to using the search function on the SMR docs page; links the first search result""" diff --git a/fred/fred_commands/_baseclass.py b/fred/fred_commands/_baseclass.py index cfca9ec..56e10f5 100644 --- a/fred/fred_commands/_baseclass.py +++ b/fred/fred_commands/_baseclass.py @@ -1,18 +1,12 @@ -import logging - from nextcord.ext import commands + from .. import config from ..libraries import common -assert config # shut up linter, things that import this need this for convenience - - -class BaseCmds(commands.Cog): +assert config # noqa - logger = logging.Logger("COMMANDS") - def __init__(self, bot): - self.bot = bot +class BaseCmds(common.FredCog): @commands.group() @commands.check(common.l4_only) diff --git a/fred/fred_commands/_command_utils.py b/fred/fred_commands/_command_utils.py index 17827ea..2eca0a5 100644 --- a/fred/fred_commands/_command_utils.py +++ b/fred/fred_commands/_command_utils.py @@ -1,5 +1,4 @@ from typing import Type -import logging from regex import E, match, escape diff --git a/fred/fred_commands/channels.py b/fred/fred_commands/channels.py index d5d38f8..9eccfd4 100644 --- a/fred/fred_commands/channels.py +++ b/fred/fred_commands/channels.py @@ -1,3 +1,5 @@ +from nextcord import TextChannel + from ._baseclass import BaseCmds, commands, config, common @@ -7,7 +9,8 @@ async def add_mediaonly(self, ctx: commands.Context, channel: commands.TextChann """Usage: `add mediaonly (channel)` Purpose: Adds channel to the list of channels that are managed to be media-only Notes: Limited to permission level 4 and above""" - if config.MediaOnlyChannels.fetch(channel.id): + channel: TextChannel + if config.MediaOnlyChannels.check(channel.id): await self.bot.reply_to_msg(ctx.message, "This channel is already a media only channel") return @@ -21,7 +24,9 @@ async def remove_mediaonly(self, ctx: commands.Context, channel: commands.TextCh """Usage: `add mediaonly (channel)` Purpose: Removes channel from the list of channels that are managed to be media-only Notes: Limited to permission level 4 and above""" - if not config.MediaOnlyChannels.fetch(channel.id): + channel: TextChannel + + if not config.MediaOnlyChannels.check(channel.id): await self.bot.reply_to_msg(ctx.message, "Media Only Channel could not be found!") return @@ -35,7 +40,9 @@ async def add_dialogflow_channel(self, ctx: commands.Context, channel: commands. """Usage: `add dialogflowChannel (channel)` Purpose: Adds channel to the list of channels that natural language processing is applied to Notes: probably don't mess around with this, Mircea is the only wizard that knows how these works""" - if config.DialogflowChannels.fetch(channel.id): + channel: TextChannel + + if config.DialogflowChannels.check(channel.id): await self.bot.reply_to_msg(ctx.message, "This channel is already a dialogflow channel!") else: config.DialogflowChannels(channel_id=channel.id) @@ -48,7 +55,9 @@ async def remove_dialogflow_channel(self, ctx: commands.Context, channel: comman """Usage: `remove dialogflowChannel (channel)` Purpose: Removes channel from the list of channels that natural language processing is applied to Notes: probably don't mess around with this, Mircea is the only wizard that knows how these works""" - if config.DialogflowChannels.fetch(channel.id): + channel: TextChannel + + if config.DialogflowChannels.check(channel.id): config.DialogflowChannels.deleteBy(channel_id=channel.id) await self.bot.reply_to_msg( ctx.message, f"Dialogflow Channel {self.bot.get_channel(channel.id).mention} removed!" @@ -62,6 +71,7 @@ async def set_webhook_channel(self, ctx: commands.Context, channel: commands.Tex """Usage: `set webhook_channel (channel: int | channel mention)` Purpose: changes where GitHub webhooks are sent Notes: unless you're testing me as a beta fork, don't use this""" + channel: TextChannel config.Misc.change("githook_channel", channel.id) await self.bot.reply_to_msg( ctx.message, f"The channel for the github hooks is now " f"{self.bot.get_channel(channel.id).mention}!" diff --git a/fred/fred_commands/crashes.py b/fred/fred_commands/crashes.py index 25aec41..39cc7ee 100644 --- a/fred/fred_commands/crashes.py +++ b/fred/fred_commands/crashes.py @@ -1,6 +1,6 @@ -from ._baseclass import BaseCmds, commands, config, SearchFlags from regex import search, error as RegexError -from typing import Literal + +from ._baseclass import BaseCmds, commands, config, SearchFlags from ._command_utils import get_search @@ -15,7 +15,7 @@ async def add_crash(self, ctx: commands.Context, crash_name: str.lower, match: s - `response` can be my command prefix and the name of a command, which will result in the response mirroring that of the command indicated.""" - if config.Crashes.fetch(crash_name): + if config.Crashes.fetch(crash_name) is not None: await self.bot.reply_to_msg(ctx.message, "A crash with this name already exists") return @@ -42,7 +42,7 @@ async def remove_crash(self, ctx: commands.Context, crash_name: str.lower): """Usage: `remove crash (name) Purpose: Removes a crash from the list of known crashes. Notes: hi""" - if not config.Crashes.fetch(crash_name): + if config.Crashes.fetch(crash_name) is None: await self.bot.reply_to_msg(ctx.message, "Crash could not be found!") return @@ -79,8 +79,8 @@ async def modify_crash(self, ctx: commands.Context, name: str.lower, match: str if change_response := await self.bot.reply_yes_or_no(ctx.message, "Do you want to change the response?"): response, _ = await self.bot.reply_question( ctx.message, - "What response do you want it to provide? Responding with " - "a command will make the response that command", + f"What response do you want it to provide? Responding with `{self.bot.command_prefix}command_name`" + "will use the response of that command.", ) except ValueError: return diff --git a/fred/fred_commands/dbcommands.py b/fred/fred_commands/dbcommands.py index 46380a1..80872ff 100644 --- a/fred/fred_commands/dbcommands.py +++ b/fred/fred_commands/dbcommands.py @@ -1,5 +1,3 @@ -from typing import Literal - from ._baseclass import BaseCmds, commands, SearchFlags from ._command_utils import get_search from .. import config @@ -21,10 +19,10 @@ async def add_command(self, ctx: commands.Context, command_name: str.lower, *, r """Usage: `add command (name) [response]` Purpose: Adds a simple command to the list of commands Notes: If response is not supplied you will be prompted for one with a timeout""" - if config.Commands.fetch(command_name): + if config.Commands.fetch(command_name) is not None: await self.bot.reply_to_msg(ctx.message, "This command already exists!") return - if config.ReservedCommands.fetch(command_name): + if config.ReservedCommands.check(command_name): await self.bot.reply_to_msg(ctx.message, "This command name is reserved") return @@ -67,7 +65,7 @@ async def modify_command(self, ctx: commands.Context, command_name: str.lower, * """Usage: `modify command (name) [response]` Purpose: Modifies a command Notes: If response is not supplied you will be prompted for one with a timeout""" - if config.ReservedCommands.fetch(command_name): + if config.ReservedCommands.check(command_name): await self.bot.reply_to_msg(ctx.message, "This command is special and cannot be modified.") return @@ -107,7 +105,7 @@ async def modify_command(self, ctx: commands.Context, command_name: str.lower, * def _valid_aliases(target: str, aliases: list[str]) -> dict[str, list[str | tuple[str, str]]]: rtn = {"valid": [], "overwrite": [], "failure": []} for alias in aliases: - if config.ReservedCommands.fetch(alias): + if config.ReservedCommands.check(alias): rtn["failure"] += (alias, "reserved") elif cmd := config.Commands.fetch(name=alias): @@ -158,7 +156,7 @@ async def _add_alias(self, ctx: commands.Context, target: str, aliases: list[str return user_info @BaseCmds.add.command(name="alias") - async def add_alias(self, ctx: commands.Context, target: str.lower, *aliases: str): + async def add_alias(self, ctx: commands.Context, target: str.lower, *aliases: str.lower): """Usage: `add alias (command) [aliases...]` Purpose: Adds one or more aliases to a command, checking first for overwriting stuff Notes: If an alias is not supplied you will be prompted for one with a timeout""" @@ -184,7 +182,7 @@ async def add_alias(self, ctx: commands.Context, target: str.lower, *aliases: st if not aliases: response, _ = await self.bot.reply_question(ctx.message, "Please input aliases, separated by spaces.") - aliases = response.split(" ") + aliases = response.lower().split(" ") response = await self._add_alias(ctx, target, aliases) @@ -212,13 +210,13 @@ async def rename_command(self, ctx: commands.Context, name: str.lower, *, new_na """Usage: `rename command (name) (new_name)` Purpose: Renames a command. Notes: If response is not supplied you will be prompted for one with a timeout""" - if config.ReservedCommands.fetch(name): + if config.ReservedCommands.check(name): await self.bot.reply_to_msg(ctx.message, "This command is special and cannot be modified.") return results: list[config.Commands] if not (results := list(config.Commands.selectBy(name=name))): # this command hasn't been created yet - await self.bot.reply_to_msg("Command could not be found!") + await self.bot.reply_to_msg(ctx.message, "Command could not be found!") return if new_name is None: diff --git a/fred/fred_commands/dialogflow.py b/fred/fred_commands/dialogflow.py index d9ceafc..ddbae88 100644 --- a/fred/fred_commands/dialogflow.py +++ b/fred/fred_commands/dialogflow.py @@ -1,5 +1,7 @@ import json +from nextcord import Role + from ._baseclass import BaseCmds, commands, config @@ -59,9 +61,10 @@ async def remove_dialogflow(self, ctx: commands.Context, intent_id: str, *args): async def add_dialogflow_role(self, ctx: commands.Context, role: commands.RoleConverter): """Usage: `add dialogflowRole (role)` Purpose: Adds role to the list of roles that natural language processing is not applied to""" + role: Role role_id = role.id - if config.DialogflowExceptionRoles.fetch(role_id): + if config.DialogflowExceptionRoles.check(role_id): await self.bot.reply_to_msg(ctx.message, "This role is already a dialogflow exception role") return @@ -72,9 +75,10 @@ async def add_dialogflow_role(self, ctx: commands.Context, role: commands.RoleCo async def remove_dialogflow_role(self, ctx: commands.Context, role: commands.RoleConverter): """Usage: `remove dialogflowRole (role)` Purpose: Removes role from the list of roles that natural language processing is not applied to""" + role: Role role_id = role.id - if config.DialogflowExceptionRoles.fetch(role_id): + if config.DialogflowExceptionRoles.check(role_id): config.DialogflowExceptionRoles.deleteBy(role_id=role_id) await self.bot.reply_to_msg(ctx.message, "Dialogflow role removed!") else: diff --git a/fred/fred_commands/experience.py b/fred/fred_commands/experience.py index 8fa4656..523d77f 100644 --- a/fred/fred_commands/experience.py +++ b/fred/fred_commands/experience.py @@ -1,3 +1,5 @@ +from nextcord import Role, User + from ._baseclass import BaseCmds, commands, common, config from ..cogs import levelling from ..libraries import createembed @@ -19,6 +21,7 @@ async def xp_give(self, ctx: commands.Context, target: commands.UserConverter, a """Usage: `xp give (user) (amount)` Purpose: gives the indicated user the specified xp Notes: don't give negative xp, use take""" + target: User profile = levelling.UserProfile(target.id, ctx.guild, self.bot) if amount < 0: await self.bot.reply_to_msg( @@ -38,6 +41,7 @@ async def xp_take(self, ctx: commands.Context, target: commands.UserConverter, a """Usage: `xp give (user) (amount)` Purpose: takes the specified xp from the indicated user Notes: don't take negative xp, use give""" + target: User profile = levelling.UserProfile(target.id, ctx.guild, self.bot) if amount < 0: await self.bot.reply_to_msg( @@ -60,6 +64,7 @@ async def xp_multiplier(self, ctx: commands.Context, target: commands.UserConver """Usage: `xp multiplier (user) (multiplier)` Purpose: sets the user's personalised xp gain multiplier from the base value Notes: a negative value will be converted to 0""" + target: User user_meta = config.Users.create_if_missing(target) amount = max(multiplier, 0) # no negative gain allowed user_meta.xp_multiplier = amount @@ -74,6 +79,7 @@ async def xp_set(self, ctx: commands.Context, target: commands.UserConverter, am """Usage: `xp set (user) (amount)` Purpose: sets the user's xp amount to the specified amount Notes: don't try negative values, it won't work""" + target: User profile = levelling.UserProfile(target.id, ctx.guild, self.bot) if amount < 0: @@ -142,9 +148,16 @@ async def leaderboard(self, ctx): query = config.Users.select().orderBy("-xp_count").limit(10) results = list(query) if not results: - self.bot.reply_to_msg(ctx.message, "The database was empty. This should NEVER happen") + await self.bot.reply_to_msg(ctx.message, "The database was empty. This should NEVER happen") return - data = [dict(name=user.full_name, count_and_rank=dict(count=user.xp_count, rank=user.rank)) for user in results] + + data = [] + for db_user in results: + fetched_user = self.bot.get_user(db_user.user_id) + if fetched_user is None: + raise LookupError(f"Unable to find user with ID {db_user.user_id}") + data.append({"name": fetched_user.global_name, "xp": db_user.xp_count, "rank": db_user.rank}) + embed = createembed.leaderboard(data) await self.bot.reply_to_msg(ctx.message, embed=embed) @@ -153,11 +166,12 @@ async def level(self, ctx: commands.Context, target_user: commands.UserConverter """Usage: `level` [user] Response: Either your level or the level of the user specified Notes: the user parameter can be the user's @ mention or their UID, like 506192269557366805""" + target_user: User if target_user: user_id = target_user.id user = self.bot.get_user(user_id) if not user: - self.bot.reply_to_msg(ctx.message, f"Sorry, I was unable to find the user with id {user_id}") + await self.bot.reply_to_msg(ctx.message, f"Sorry, I was unable to find the user with id {user_id}") return else: user = ctx.author @@ -169,9 +183,10 @@ async def add_level_role(self, ctx: commands.Context, role: commands.RoleConvert """Usage: `add level_role (role)` Purpose: adds a levelling role Notes: NOT IMPLEMENTED""" + role: Role role_id = role.id - if config.DialogflowExceptionRoles.fetch(role_id): + if config.DialogflowExceptionRoles.check(role_id): await self.bot.reply_to_msg(ctx.message, "This role is already a level role") return @@ -183,6 +198,7 @@ async def remove_level_role(self, ctx: commands.Context, role: commands.RoleConv """Usage: `remove level_role (role)` Purpose: removes a levelling role Notes: NOT IMPLEMENTED""" + role: Role role_id = role.id if config.RankRoles.fetch_by_role(role_id): diff --git a/fred/fred_commands/help.py b/fred/fred_commands/help.py index f3d390b..f48db5f 100644 --- a/fred/fred_commands/help.py +++ b/fred/fred_commands/help.py @@ -1,14 +1,16 @@ from __future__ import annotations -import logging +import regex from functools import lru_cache from typing import Coroutine import attrs import nextcord -import re from ._baseclass import BaseCmds, commands, config +from ..libraries import common + +logger = common.new_logger(__name__) class HelpCmds(BaseCmds): @@ -24,7 +26,7 @@ async def help(self, ctx: commands.Context) -> None: async def _send_help(self, ctx: commands.Context, **kwargs): if not ctx.author.dm_channel: await ctx.author.create_dm() - if not await self.bot.checked_DM(ctx.author, in_dm=ctx.channel == ctx.author.dm_channel,**kwargs): + if not await self.bot.checked_DM(ctx.author, in_dm=ctx.channel == ctx.author.dm_channel, **kwargs): await ctx.reply( "Help commands only work in DMs to avoid clutter. " "You have either disabled server DMs or indicated that you do not wish for Fred to DM you. " @@ -121,15 +123,14 @@ class FredHelpEmbed(nextcord.Embed): # these are placeholder values only, they will be set up when setup() is called post-DB connection help_colour: int = 0 prefix: str = ">" - logger = logging.Logger("HELP-EMBEDS") def __init__( self: FredHelpEmbed, name: str, desc: str, /, usage: str = "", fields: list[dict] = (), **kwargs ) -> None: - desc = re.sub(r"^\s*(\S.*)$", r"\1", desc, flags=re.MULTILINE) - desc = re.sub(r"(?<=Usage: )`(.+)`", rf"`{self.prefix}\1`", desc) - desc = re.sub(r"^(\w+:) ", r"**\1** ", desc, flags=re.MULTILINE) + desc = regex.sub(r"^\s*(\S.*)$", r"\1", desc, flags=regex.MULTILINE) + desc = regex.sub(r"(?<=Usage: )`(.+)`", rf"`{self.prefix}\1`", desc) + desc = regex.sub(r"^(\w+:) ", r"**\1** ", desc, flags=regex.MULTILINE) super().__init__(title=f"**{name}**", colour=self.help_colour, description=desc, **kwargs) for f in fields: self.add_field(**f) @@ -230,7 +231,7 @@ def crashes(cls, index: int = 0) -> FredHelpEmbed: "in a message, pastebin, debug file, or screenshot.*\n" ) all_crashes = list(config.Crashes.selectBy()) - cls.logger.info(f"Fetched {len(all_crashes)} crashes from database.") + logger.info(f"Fetched {len(all_crashes)} crashes from database.") global page_size, field_size # splits all crashes into groups of {page_size} @@ -278,7 +279,7 @@ def commands(cls, index: int = 0) -> FredHelpEmbed: desc = "*These are normal commands that can be called by stating their name.*\n" all_commands = list(config.Commands.selectBy()) - cls.logger.info(f"Fetched {len(all_commands)} commands from database.") + logger.info(f"Fetched {len(all_commands)} commands from database.") global page_size, field_size # splits all commands into groups of {page_size} pages = [all_commands[page : page + page_size] for page in range(0, len(all_commands), page_size)] diff --git a/fred/libraries/__init__.py b/fred/libraries/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fred/libraries/common.py b/fred/libraries/common.py index dff7050..26d213e 100644 --- a/fred/libraries/common.py +++ b/fred/libraries/common.py @@ -1,13 +1,37 @@ +from __future__ import annotations + import logging import re -from functools import lru_cache +from functools import lru_cache, singledispatch +from typing import TYPE_CHECKING, Optional -from nextcord import User, Message +from nextcord import User, Message, Member +from nextcord.ext import commands from nextcord.ext.commands import Context from .. import config -logger = logging.Logger("PERMISSIONS") +if TYPE_CHECKING: + from ..fred import Bot + + +def new_logger(name: str) -> logging.Logger: + logging.root.debug("Creating new logger for %s", name) + new_logger_ = logging.root.getChild(name) + new_logger_.setLevel(new_logger_.parent.level) + return new_logger_ + + +logger = new_logger(__name__) + + +class FredCog(commands.Cog): + bot: Bot # we can assume any cog will have a bot by the time it needs to be accessed + + def __init__(self, bot: Bot): + self.bot = bot + self.logger = new_logger(self.__class__.__name__) + self.logger.debug("Cog loaded.") def is_bot_author(user_id: int) -> bool: @@ -17,25 +41,50 @@ def is_bot_author(user_id: int) -> bool: async def l4_only(ctx: Context) -> bool: logger.info("Checking if someone is a T3", extra=user_info(ctx.author)) - return is_bot_author(ctx.author.id) or permission_check(ctx, 4) + return is_bot_author(ctx.author.id) or permission_check(ctx, level=4) async def mod_only(ctx: Context) -> bool: logger.info("Checking if someone is a Moderator", extra=user_info(ctx.author)) - return is_bot_author(ctx.author.id) or permission_check(ctx, 6) + return is_bot_author(ctx.author.id) or permission_check(ctx, level=6) + + +@singledispatch +def permission_check(_, *, level: int) -> bool: + pass + + +@permission_check.register +def _permission_check_ctx(ctx: Context, *, level: int) -> bool: + main_guild_id = config.Misc.fetch("main_guild_id") + main_guild = ctx.bot.get_guild(main_guild_id) + if main_guild is None: + raise LookupError(f"Unable to retrieve the guild {main_guild_id}. Is this the guild you meant?") -def permission_check(ctx: Context, level: int) -> bool: - logpayload = user_info(ctx.author) + if (main_guild_member := main_guild.get_member(ctx.author.id)) is None: + logger.warning( + "Checked permissions for someone but they weren't in the main guild", extra=user_info(ctx.author) + ) + return False + + return _permission_check_member(main_guild_member, level=level) + + +@permission_check.register +def _permission_check_member(member: Member, *, level: int) -> bool: + """Checks permissions for a member assuming they are in the main guild.""" + logpayload = user_info(member) logpayload["level"] = level + + if member.guild.id != config.Misc.fetch("main_guild_id"): + logger.warning("Checked permissions for a member of the wrong guild", extra=logpayload) + return False + logger.info("Checking permissions for someone", extra=logpayload) perms = config.PermissionRoles.fetch_by_lvl(level) - main_guild = ctx.bot.get_guild(config.Misc.fetch("main_guild_id")) - if (main_guild_member := main_guild.get_member(ctx.author.id)) is None: - logger.warning("Checked permissions for someone but they weren't in the main guild", extra=logpayload) - return False - user_roles = [role.id for role in main_guild_member.roles] + user_roles = [role.id for role in member.roles] if ( # it shouldn't be possible to request a level above the defined levels but check anyway role := next( @@ -50,20 +99,18 @@ def permission_check(ctx: Context, level: int) -> bool: return False -@lru_cache(5) -def user_info(user: User | config.Users) -> dict: - if isinstance(user, User): - return {"user_full_name": str(user), "user_id": user.id} - elif isinstance(user, config.Users): - return {"user_full_name": user.full_name, "user_id": user.id} - return {} +@lru_cache(15) +def user_info(user: Optional[User | Member]) -> dict: + if user is None: + return {} + return {"user_full_name": user.global_name, "user_id": user.id} -@lru_cache(5) -def message_info(message: Message) -> dict: +@lru_cache(15) +def message_info(message: Optional[Message]) -> dict: if message is None: return {} - return {"message_id": message.id, "channel_id": message.channel.id, "user_id": message.author.id} + return {"message_id": message.id, "channel_id": message.channel.id, **user_info(message.author)} def reduce_str(string: str) -> str: diff --git a/fred/libraries/createembed.py b/fred/libraries/createembed.py index d1f2f0d..1686a7a 100644 --- a/fred/libraries/createembed.py +++ b/fred/libraries/createembed.py @@ -1,4 +1,3 @@ -import logging from datetime import datetime from io import BytesIO from urllib.parse import quote as url_safe @@ -7,8 +6,10 @@ from PIL import Image from nextcord.utils import format_dt -from . import common from .. import config +from ..libraries import common + +logger = common.new_logger(__name__) def timestamp(iso8601: str) -> str: @@ -30,7 +31,7 @@ async def run(data: dict) -> nextcord.Embed | None: repo_name, repo_full_name = "", "" if (data_type := data.get("type")) is None: - logging.error("data didn't have a type field") + logger.error("data didn't have a type field") return match data_type: @@ -47,7 +48,7 @@ async def run(data: dict) -> nextcord.Embed | None: case "issue": embed = issue(data) case _: - logging.warning("Unsupported GitHub payload", extra={"data": data}) + logger.warning("Unsupported GitHub payload", extra={"data": data}) return embed @@ -57,9 +58,7 @@ def leaderboard(data: list[dict]) -> nextcord.Embed: embed = nextcord.Embed(title="XP Leaderboard", colour=config.ActionColours.fetch("purple"), description=desc) for user in data: - embed.add_field( - name=user["name"], value=f'XP: {user["count_and_rank"]["count"]} | Level: {user["count_and_rank"]["rank"]}' - ) + embed.add_field(name=user["name"], value=f'XP: {user["xp"]} | Level: {user["rank"]}') return embed @@ -82,7 +81,7 @@ def format_commit(commit: dict) -> tuple[str, str]: change_summary_icons = " ".join( [f"{em} {len(commit[k])}" for em, k in zip("✅❌📝", ["added", "removed", "modified"])] ) - return (f"{commit_message}\n", f'{change_summary_icons} - by {attribution} {ts} [{hash_id}]({commit["url"]})\n') + return f"{commit_message}\n", f'{change_summary_icons} - by {attribution} {ts} [{hash_id}]({commit["url"]})\n' def push(data: dict) -> nextcord.Embed: @@ -362,7 +361,7 @@ async def mod_embed(name: str, bot) -> tuple[nextcord.Embed | None, nextcord.Fil # fmt: on result = await bot.repository_query(query) mods: list[dict] = result["data"]["getMods"]["mods"] - logging.debug(mods) + logger.debug(mods) if not mods: return None, None, None diff --git a/fred/libraries/view/mod_picker.py b/fred/libraries/view/mod_picker.py index fbba682..4c67128 100644 --- a/fred/libraries/view/mod_picker.py +++ b/fred/libraries/view/mod_picker.py @@ -1,5 +1,3 @@ -import logging - import nextcord.ui diff --git a/fred/migrations/0-example_migration.down.sql b/fred/migrations/0-example_migration.down.sql new file mode 100644 index 0000000..253b4ea --- /dev/null +++ b/fred/migrations/0-example_migration.down.sql @@ -0,0 +1 @@ +-- SQL code for when we want to undo a migration goes here \ No newline at end of file diff --git a/fred/migrations/0-example_migration.up.sql b/fred/migrations/0-example_migration.up.sql new file mode 100644 index 0000000..4963365 --- /dev/null +++ b/fred/migrations/0-example_migration.up.sql @@ -0,0 +1 @@ +-- SQL code for when we want to do a migration goes here \ No newline at end of file diff --git a/fred/migrations/1-drop_full_name_from_users.sql b/fred/migrations/1-drop_full_name_from_users.sql new file mode 100644 index 0000000..905ff6c --- /dev/null +++ b/fred/migrations/1-drop_full_name_from_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +DROP COLUMN IF EXISTS full_name; \ No newline at end of file diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 0000000..efa46ec --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f644f5b..6b0a5da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,29 +1,31 @@ [tool.poetry] -name = "FICSIT-Fred" -version = "2.18.10" +name = "fred" +version = "2.21.0" description = "A Discord bot for the Satisfactory Modding Discord " -authors = ["Feyko ", "Mircea Roata ", "Astro Floof "] +authors = ["Feyko ", "Mircea Roata ", "Borketh "] license = "MIT License" [tool.poetry.dependencies] -python = "^3.10" -nextcord = "^2.1.0" -Pillow = "^9.0.0" +python = "^3.12" +nextcord = {extras = ["speed"], version = "^2.6.0"} +Pillow = "^10.4.0" pytesseract = "^0.3.8" -psycopg2 = "^2.9.3" -python-dotenv = "^0.19.2" -jsonpickle = "^2.1.0" -algoliasearch = "^2.6.1" +python-dotenv = "^1.0.0" +algoliasearch = "^3.0.0" SQLObject = "^3.9.1" google-cloud-dialogflow = "^2.11.0" -regex = "^2022.3.15" +regex = "^2024.7.0" +aiohttp = "^3.10.0" +setuptools = "^72.1.0" +psycopg2-binary = "^2.9.9" -[tool.poetry.dev-dependencies] -black = "^22.1.0" +[tool.poetry.group.dev.dependencies] +black = {extras = ["d"], version = "^24.8.0"} +jsonpickle = "^3.0.0" [tool.black] line-length = 120 -target-version = ['py310'] +target-version = ['py312'] [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/testing/embeds.py b/testing/embeds.py index c57dd00..f0dafb5 100644 --- a/testing/embeds.py +++ b/testing/embeds.py @@ -6,7 +6,7 @@ import sys sys.path.insert(0, abspath("./bot")) -from libraries.createembed import run +from fred.libraries.createembed import run from asyncio import run as nonawait from json import load from os import getenv