From 45741aaded3dff251079a25f022d2dae97ec9b19 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 8 Dec 2024 18:19:43 -0500 Subject: [PATCH] feat(github): add github integration --- .github/workflows/ci.yml | 8 +- Dockerfile | 29 +- README.md | 83 ++--- assets/favicon.ico | Bin 0 -> 114227 bytes requirements-dev.txt | 1 + requirements.txt | 2 + sample.env | 17 +- src/__main__.py | 33 +- src/common/__init__.py | 0 src/{ => common}/common.py | 21 +- src/common/crypto.py | 69 +++++ src/common/database.py | 22 ++ src/common/globals.py | 2 + src/common/sponsors.py | 73 +++++ src/common/time.py | 19 ++ src/common/webapp.py | 338 +++++++++++++++++++++ src/discord/bot.py | 213 ++++++++++++- src/discord/cogs/base_commands.py | 4 +- src/discord/cogs/fun_commands.py | 6 +- src/discord/cogs/github_commands.py | 145 +++++++++ src/discord/cogs/moderator_commands.py | 101 ++++--- src/discord/cogs/support_commands.py | 6 +- src/discord/tasks.py | 404 +++++++++++++++---------- src/discord/views.py | 8 +- src/keep_alive.py | 20 -- src/reddit/bot.py | 79 ++--- tests/conftest.py | 64 ++++ tests/fixtures/certs/expired/cert.pem | 28 ++ tests/fixtures/certs/expired/key.pem | 51 ++++ tests/unit/common/test_crypto.py | 65 ++++ tests/unit/common/test_sponsors.py | 17 ++ tests/unit/common/test_time.py | 17 ++ tests/unit/common/test_webapp.py | 349 +++++++++++++++++++++ tests/unit/discord/test_discord_bot.py | 103 +++++-- tests/unit/discord/test_tasks.py | 130 ++++++++ tests/unit/reddit/test_reddit_bot.py | 6 +- 36 files changed, 2136 insertions(+), 397 deletions(-) create mode 100644 assets/favicon.ico create mode 100644 src/common/__init__.py rename src/{ => common}/common.py (79%) create mode 100644 src/common/crypto.py create mode 100644 src/common/database.py create mode 100644 src/common/globals.py create mode 100644 src/common/sponsors.py create mode 100644 src/common/time.py create mode 100644 src/common/webapp.py create mode 100644 src/discord/cogs/github_commands.py delete mode 100644 src/keep_alive.py create mode 100644 tests/fixtures/certs/expired/cert.pem create mode 100644 tests/fixtures/certs/expired/key.pem create mode 100644 tests/unit/common/test_crypto.py create mode 100644 tests/unit/common/test_sponsors.py create mode 100644 tests/unit/common/test_time.py create mode 100644 tests/unit/common/test_webapp.py create mode 100644 tests/unit/discord/test_tasks.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 472e096..58be6ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,10 +41,16 @@ jobs: - name: Test with pytest id: test env: + CI_EVENT_ID: ${{ github.event.number || github.sha }} GITHUB_PYTEST: "true" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_TEST_BOT_TOKEN }} - DISCORD_WEBHOOK: ${{ secrets.DISCORD_TEST_BOT_WEBHOOK }} + DISCORD_GITHUB_STATUS_CHANNEL_ID: ${{ vars.DISCORD_GITHUB_STATUS_CHANNEL_ID }} + DISCORD_REDDIT_CHANNEL_ID: ${{ vars.DISCORD_REDDIT_CHANNEL_ID }} + DISCORD_SPONSORS_CHANNEL_ID: ${{ vars.DISCORD_SPONSORS_CHANNEL_ID }} GRAVATAR_EMAIL: ${{ secrets.GRAVATAR_EMAIL }} + IGDB_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }} + IGDB_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }} PRAW_CLIENT_ID: ${{ secrets.REDDIT_CLIENT_ID }} PRAW_CLIENT_SECRET: ${{ secrets.REDDIT_CLIENT_SECRET }} REDDIT_USERNAME: ${{ secrets.REDDIT_USERNAME }} diff --git a/Dockerfile b/Dockerfile index 8cf0ff5..9f14448 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,19 +17,26 @@ ENV COMMIT=${COMMIT} ARG DAILY_TASKS=true ARG DAILY_RELEASES=true ARG DAILY_TASKS_UTC_HOUR=12 +ARG DISCORD_GITHUB_STATUS_CHANNEL_ID +ARG DISCORD_REDDIT_CHANNEL_ID +ARG DISCORD_SPONSORS_CHANNEL_ID # Secret config -ARG DISCORD_BOT_TOKEN ARG DAILY_CHANNEL_ID +ARG DISCORD_BOT_TOKEN +ARG DISCORD_CLIENT_ID +ARG DISCORD_CLIENT_SECRET +ARG DISCORD_REDIRECT_URI +ARG GITHUB_CLIENT_ID +ARG GITHUB_CLIENT_SECRET +ARG GITHUB_REDIRECT_URI +ARG GITHUB_WEBHOOK_SECRET_KEY ARG GRAVATAR_EMAIL ARG IGDB_CLIENT_ID ARG IGDB_CLIENT_SECRET ARG PRAW_CLIENT_ID ARG PRAW_CLIENT_SECRET ARG PRAW_SUBREDDIT -ARG DISCORD_WEBHOOK -ARG GRAVATAR_EMAIL -ARG REDIRECT_URI # Environment variables ENV DAILY_TASKS=$DAILY_TASKS @@ -37,6 +44,16 @@ ENV DAILY_RELEASES=$DAILY_RELEASES ENV DAILY_CHANNEL_ID=$DAILY_CHANNEL_ID ENV DAILY_TASKS_UTC_HOUR=$DAILY_TASKS_UTC_HOUR ENV DISCORD_BOT_TOKEN=$DISCORD_BOT_TOKEN +ENV DISCORD_CLIENT_ID=$DISCORD_CLIENT_ID +ENV DISCORD_CLIENT_SECRET=$DISCORD_CLIENT_SECRET +ENV DISCORD_GITHUB_STATUS_CHANNEL_ID=$DISCORD_GITHUB_STATUS_CHANNEL_ID +ENV DISCORD_REDDIT_CHANNEL_ID=$DISCORD_REDDIT_CHANNEL_ID +ENV DISCORD_REDIRECT_URI=$DISCORD_REDIRECT_URI +ENV DISCORD_SPONSORS_CHANNEL_ID=$DISCORD_SPONSORS_CHANNEL_ID +ENV GITHUB_CLIENT_ID=$GITHUB_CLIENT_ID +ENV GITHUB_CLIENT_SECRET=$GITHUB_CLIENT_SECRET +ENV GITHUB_REDIRECT_URI=$GITHUB_REDIRECT_URI +ENV GITHUB_WEBHOOK_SECRET_KEY=$GITHUB_WEBHOOK_SECRET_KEY ENV GRAVATAR_EMAIL=$GRAVATAR_EMAIL ENV IGDB_CLIENT_ID=$IGDB_CLIENT_ID ENV IGDB_CLIENT_SECRET=$IGDB_CLIENT_SECRET @@ -44,8 +61,6 @@ ENV PRAW_CLIENT_ID=$PRAW_CLIENT_ID ENV PRAW_CLIENT_SECRET=$PRAW_CLIENT_SECRET ENV PRAW_SUBREDDIT=$PRAW_SUBREDDIT ENV DISCORD_WEBHOOK=$DISCORD_WEBHOOK -ENV GRAVATAR_EMAIL=$GRAVATAR_EMAIL -ENV REDIRECT_URI=$REDIRECT_URI SHELL ["/bin/bash", "-o", "pipefail", "-c"] # install dependencies @@ -69,7 +84,7 @@ RUN <<_SETUP set -e # replace the version in the code -sed -i "s/version = '0.0.0'/version = '${BUILD_VERSION}'/g" src/common.py +sed -i "s/version = '0.0.0'/version = '${BUILD_VERSION}'/g" src/common/common.py # install dependencies python -m pip install --no-cache-dir -r requirements.txt diff --git a/README.md b/README.md index 2d1f458..a3665f2 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ [![GitHub Workflow Status (CI)](https://img.shields.io/github/actions/workflow/status/lizardbyte/support-bot/ci.yml.svg?branch=master&label=CI%20build&logo=github&style=for-the-badge)](https://github.com/LizardByte/support-bot/actions/workflows/ci.yml?query=branch%3Amaster) [![Codecov](https://img.shields.io/codecov/c/gh/LizardByte/support-bot.svg?token=900Q93P1DE&style=for-the-badge&logo=codecov&label=codecov)](https://app.codecov.io/gh/LizardByte/support-bot) -Support bot written in python to help manage LizardByte communities. The current focus is discord and reddit, but other -platforms such as GitHub discussions/issues could be added. +Support bot written in python to help manage LizardByte communities. The current focus is Discord and Reddit, but other +platforms such as GitHub discussions/issues might be added in the future. ## Overview @@ -24,45 +24,52 @@ platforms such as GitHub discussions/issues could be added. * Presence Intent * Server Members Intent * Copy the `Token` -* Add the following as environment variables or in a `.env` file (use `sample.env` as an example). - :exclamation: if using Docker these can be arguments. - :warning: Never publicly expose your tokens, secrets, or ids. - -| variable | required | default | description | -|-------------------------|----------|------------------------------------------------------|---------------------------------------------------------------| -| DISCORD_BOT_TOKEN | True | `None` | Token from Bot page on discord developer portal. | -| DAILY_TASKS | False | `true` | Daily tasks on or off. | -| DAILY_RELEASES | False | `true` | Send a message for each game released on this day in history. | -| DAILY_CHANNEL_ID | False | `None` | Required if daily_tasks is enabled. | -| DAILY_TASKS_UTC_HOUR | False | `12` | The hour to run daily tasks. | -| GRAVATAR_EMAIL | False | `None` | Gravatar email address for bot avatar. | -| IGDB_CLIENT_ID | False | `None` | Required if daily_releases is enabled. | -| IGDB_CLIENT_SECRET | False | `None` | Required if daily_releases is enabled. | -| SUPPORT_COMMANDS_REPO | False | `https://github.com/LizardByte/support-bot-commands` | Repository for support commands. | -| SUPPORT_COMMANDS_BRANCH | False | `master` | Branch for support commands. | - -* Running bot: - * `python -m src` -* Invite bot to server: - * `https://discord.com/api/oauth2/authorize?client_id=&permissions=8&scope=bot%20applications.commands` - ### Reddit * Set up an application at [reddit apps](https://www.reddit.com/prefs/apps/). * The redirect uri should be https://localhost:8080 * Take note of the `client_id` and `client_secret` -* Enter the following as environment variables - - | Parameter | Required | Default | Description | - |--------------------|----------|---------|-------------------------------------------------------------------------| - | PRAW_CLIENT_ID | True | None | `client_id` from reddit app setup page. | - | PRAW_CLIENT_SECRET | True | None | `client_secret` from reddit app setup page. | - | PRAW_SUBREDDIT | True | None | Subreddit to monitor (reddit user should be moderator of the subreddit) | - | DISCORD_WEBHOOK | False | None | URL of webhook to send discord notifications to | - | GRAVATAR_EMAIL | False | None | Gravatar email address to get avatar from | - | REDDIT_USERNAME | True | None | Reddit username | -* | REDDIT_PASSWORD | True | None | Reddit password | - -* Running bot: - * `python -m src` + +### Environment Variables + +* Add the following as environment variables or in a `.env` file (use `sample.env` as an example). + :exclamation: if using Docker these can be arguments. + :warning: Never publicly expose your tokens, secrets, or ids. + +| variable | required | default | description | +|----------------------------------|----------|------------------------------------------------------|-------------------------------------------------------------------------| +| DAILY_TASKS | False | `true` | Daily tasks on or off. | +| DAILY_RELEASES | False | `true` | Send a message for each game released on this day in history. | +| DAILY_CHANNEL_ID | False | `None` | Required if daily_tasks is enabled. | +| DAILY_TASKS_UTC_HOUR | False | `12` | The hour to run daily tasks. | +| DISCORD_BOT_TOKEN | True | `None` | Token from Bot page on discord developer portal. | +| DISCORD_CLIENT_ID | True | `None` | Discord OAuth2 client id. | +| DISCORD_CLIENT_SECRET | True | `None` | Discord OAuth2 client secret. | +| DISCORD_GITHUB_STATUS_CHANNEL_ID | True | `None` | Channel ID to send GitHub status updates to. | +| DISCORD_REDDIT_CHANNEL_ID | True | `None` | Channel ID to send Reddit post updates to. | +| DISCORD_REDIRECT_URI | False | `https://localhost:8080/discord/callback` | The redirect uri for OAuth2. Must be publicly accessible. | +| DISCORD_SPONSORS_CHANNEL_ID | True | `None` | Channel ID to send sponsorship updates to. | +| GITHUB_CLIENT_ID | True | `None` | GitHub OAuth2 client id. | +| GITHUB_CLIENT_SECRET | True | `None` | GitHub OAuth2 client secret. | +| GITHUB_REDIRECT_URI | False | `https://localhost:8080/github/callback` | The redirect uri for OAuth2. Must be publicly accessible. | +| GITHUB_WEBHOOK_SECRET_KEY | True | `None` | A secret value to ensure webhooks are from trusted sources. | +| GRAVATAR_EMAIL | False | `None` | Gravatar email address for bot avatar. | +| IGDB_CLIENT_ID | False | `None` | Required if daily_releases is enabled. | +| IGDB_CLIENT_SECRET | False | `None` | Required if daily_releases is enabled. | +| PRAW_CLIENT_ID | True | None | `client_id` from reddit app setup page. | +| PRAW_CLIENT_SECRET | True | None | `client_secret` from reddit app setup page. | +| PRAW_SUBREDDIT | True | None | Subreddit to monitor (reddit user should be moderator of the subreddit) | +| REDDIT_USERNAME | True | None | Reddit username | +| REDDIT_PASSWORD | True | None | Reddit password | +| SUPPORT_COMMANDS_REPO | False | `https://github.com/LizardByte/support-bot-commands` | Repository for support commands. | +| SUPPORT_COMMANDS_BRANCH | False | `master` | Branch for support commands. | + +### Start + +```bash +python -m src +``` + +* Invite bot to server: + * `https://discord.com/api/oauth2/authorize?client_id=&permissions=8&scope=bot%20applications.commands` diff --git a/assets/favicon.ico b/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..79620bf5b76dbc9bd4c49dd68c0e9fecac911f2f GIT binary patch literal 114227 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 z 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 xL|2M6lu}g4yyB}K str: """ Get Gravatar image url. @@ -36,15 +47,17 @@ def get_avatar_bytes(): return avatar_img -def get_data_dir(): +def get_app_dirs(): # parent directory name of this file, not full path - parent_dir = os.path.dirname(os.path.abspath(__file__)).split(os.sep)[-2] + parent_dir = os.path.dirname(os.path.abspath(__file__)).split(os.sep)[-3] if parent_dir == 'app': # running in Docker container + a = '/app' d = '/data' else: # running locally + a = os.getcwd() d = os.path.join(os.getcwd(), 'data') os.makedirs(d, exist_ok=True) - return d + return a, d # constants @@ -52,5 +65,5 @@ def get_data_dir(): org_name = 'LizardByte' bot_name = f'{org_name}-Bot' bot_url = 'https://app.lizardbyte.dev' -data_dir = get_data_dir() +app_dir, data_dir = get_app_dirs() version = '0.0.0' diff --git a/src/common/crypto.py b/src/common/crypto.py new file mode 100644 index 0000000..a59cf77 --- /dev/null +++ b/src/common/crypto.py @@ -0,0 +1,69 @@ +# standard imports +import os + +# lib imports +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption +from datetime import datetime, timedelta, UTC + +# local imports +from src.common import common + +CERT_FILE = os.path.join(common.data_dir, "cert.pem") +KEY_FILE = os.path.join(common.data_dir, "key.pem") + + +def check_expiration(cert_path: str) -> int: + with open(cert_path, "rb") as cert_file: + cert_data = cert_file.read() + cert = x509.load_pem_x509_certificate(cert_data, default_backend()) + expiry_date = cert.not_valid_after_utc + return (expiry_date - datetime.now(UTC)).days + + +def generate_certificate(): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + ) + subject = issuer = x509.Name([ + x509.NameAttribute(x509.NameOID.COMMON_NAME, u"localhost"), + ]) + cert = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.now(UTC) + ).not_valid_after( + datetime.now(UTC) + timedelta(days=365) + ).sign(private_key, hashes.SHA256()) + + with open(KEY_FILE, "wb") as f: + f.write(private_key.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + )) + + with open(CERT_FILE, "wb") as f: + f.write(cert.public_bytes(Encoding.PEM)) + + +def initialize_certificate() -> tuple[str, str]: + print("Initializing SSL certificate") + if os.path.exists(CERT_FILE) and os.path.exists(KEY_FILE): + cert_expires_in = check_expiration(CERT_FILE) + print(f"Certificate expires in {cert_expires_in} days.") + if cert_expires_in >= 90: + return CERT_FILE, KEY_FILE + print("Generating new certificate") + generate_certificate() + return CERT_FILE, KEY_FILE diff --git a/src/common/database.py b/src/common/database.py new file mode 100644 index 0000000..b27fdd4 --- /dev/null +++ b/src/common/database.py @@ -0,0 +1,22 @@ +# standard imports +import shelve +import threading + + +class Database: + def __init__(self, db_path): + self.db_path = db_path + self.lock = threading.Lock() + + def __enter__(self): + self.lock.acquire() + self.db = shelve.open(self.db_path, writeback=True) + return self.db + + def __exit__(self, exc_type, exc_val, exc_tb): + self.sync() + self.db.close() + self.lock.release() + + def sync(self): + self.db.sync() diff --git a/src/common/globals.py b/src/common/globals.py new file mode 100644 index 0000000..f185cab --- /dev/null +++ b/src/common/globals.py @@ -0,0 +1,2 @@ +DISCORD_BOT = None +REDDIT_BOT = None diff --git a/src/common/sponsors.py b/src/common/sponsors.py new file mode 100644 index 0000000..56b342a --- /dev/null +++ b/src/common/sponsors.py @@ -0,0 +1,73 @@ +# standard imports +import os +from typing import Union + +# lib imports +import requests + + +tier_map = { + 't4-sponsors': 15, + 't3-sponsors': 10, + 't2-sponsors': 5, + 't1-sponsors': 3, +} + + +def get_github_sponsors() -> Union[dict, False]: + """ + Get list of GitHub sponsors. + + Returns + ------- + Union[dict, False] + JSON response containing the list of sponsors. False if an error occurred. + """ + token = os.getenv("GITHUB_TOKEN") + org_name = os.getenv("GITHUB_ORG_NAME", "LizardByte") + + graphql_url = "https://api.github.com/graphql" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + query = """ + query { + organization(login: "%s") { + sponsorshipsAsMaintainer(first: 100) { + edges { + node { + sponsorEntity { + ... on User { + login + name + avatarUrl + url + } + ... on Organization { + login + name + avatarUrl + url + } + } + tier { + name + monthlyPriceInDollars + } + } + } + } + } + } + """ % org_name + + response = requests.post(graphql_url, json={'query': query}, headers=headers) + data = response.json() + + if 'errors' in data or 'message' in data: + print(data) + print('::error::An error occurred while fetching sponsors.') + return False + + return data diff --git a/src/common/time.py b/src/common/time.py new file mode 100644 index 0000000..d0f0f86 --- /dev/null +++ b/src/common/time.py @@ -0,0 +1,19 @@ +# standard imports +import datetime + + +def iso_to_datetime(iso_str): + """ + Convert an ISO 8601 string to a datetime object. + + Parameters + ---------- + iso_str : str + The ISO 8601 string to convert. + + Returns + ------- + datetime.datetime + The datetime object. + """ + return datetime.datetime.fromisoformat(iso_str) diff --git a/src/common/webapp.py b/src/common/webapp.py new file mode 100644 index 0000000..921fcbd --- /dev/null +++ b/src/common/webapp.py @@ -0,0 +1,338 @@ +# standard imports +import asyncio +import html +import os +from threading import Thread +from typing import Tuple + +# lib imports +import discord +from flask import Flask, jsonify, redirect, request, Response, send_from_directory +from requests_oauthlib import OAuth2Session +from werkzeug.middleware.proxy_fix import ProxyFix + +# local imports +from src.common.common import app_dir, colors +from src.common import crypto +from src.common import globals +from src.common import time + + +DISCORD_CLIENT_ID = os.getenv("DISCORD_CLIENT_ID") +DISCORD_CLIENT_SECRET = os.getenv("DISCORD_CLIENT_SECRET") +DISCORD_REDIRECT_URI = os.getenv("DISCORD_REDIRECT_URI", "https://localhost:8080/discord/callback") + +GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") +GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET") +GITHUB_REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI", "https://localhost:8080/github/callback") + +app = Flask( + import_name='LizardByte-bot', + static_folder=os.path.join(app_dir, 'assets'), +) + +# this allows us to log the real IP address of the client, instead of the IP address of the proxy host +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) + + +status_colors_map = { + 'investigating': colors['red'], + 'identified': colors['orange'], + 'monitoring': colors['yellow'], + 'resolved': colors['green'], + 'operational': colors['green'], + 'major_outage': colors['red'], + 'partial_outage': colors['orange'], + 'degraded_performance': colors['yellow'], +} + + +def html_to_md(html: str) -> str: + """ + Convert HTML to markdown. + + Parameters + ---------- + html : str + The HTML string to convert to markdown. + + Returns + ------- + str + The markdown string. + """ + replacements = { + '
': '\n', + '
': '\n', + '
': '\n', + '': '**', + '': '**', + } + + for old, new in replacements.items(): + html = html.replace(old, new) + + return html + + +@app.route('/status') +def status(): + return "LizardByte-bot is live!" + + +@app.route("/favicon.ico") +def favicon(): + return send_from_directory( + directory=app.static_folder, + path="favicon.ico", + mimetype="image/vnd.microsoft.icon", + ) + + +@app.route("/discord/callback") +def discord_callback(): + # errors will be in the query parameters + if 'error' in request.args: + return Response(html.escape(request.args['error_description']), status=400) + + # get all active states from the global state manager + with globals.DISCORD_BOT.db as db: + active_states = db['oauth_states'] + + discord_oauth = OAuth2Session(DISCORD_CLIENT_ID, redirect_uri=DISCORD_REDIRECT_URI) + token = discord_oauth.fetch_token( + token_url="https://discord.com/api/oauth2/token", + client_secret=DISCORD_CLIENT_SECRET, + authorization_response=request.url + ) + + # Fetch the user's Discord profile + response = discord_oauth.get( + url="https://discord.com/api/users/@me", + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token['access_token']}", + }, + ) + discord_user = response.json() + + # if the user is not in the active states, return an error + if discord_user['id'] not in active_states: + globals.DISCORD_BOT.update_cached_message( + author_id=discord_user['id'], + reason='failure', + ) + return Response("Invalid state", status=400) + + # remove the user from the active states + del active_states[discord_user['id']] + + # Fetch the user's connected accounts + connections_response = discord_oauth.get("https://discord.com/api/users/@me/connections") + connections = connections_response.json() + + with globals.DISCORD_BOT.db as db: + db['discord_users'] = db.get('discord_users', {}) + db['discord_users'][discord_user['id']] = { + 'discord_username': discord_user['username'], + 'discord_global_name': discord_user['global_name'], + 'github_id': None, + 'github_username': None, + } + + for connection in connections: + if connection['type'] == 'github': + db['discord_users'][discord_user['id']]['github_id'] = connection['id'] + db['discord_users'][discord_user['id']]['github_username'] = connection['name'] + + globals.DISCORD_BOT.update_cached_message( + author_id=discord_user['id'], + reason='success', + ) + + # Redirect to our main website + return redirect("https://app.lizardbyte.dev") + + +@app.route("/github/callback") +def github_callback(): + # errors will be in the query parameters + if 'error' in request.args: + return Response(html.escape(request.args['error_description']), status=400) + + # the state is sent as a query parameter in the redirect URL + state = request.args.get('state') + + # get all active states from the global state manager + with globals.DISCORD_BOT.db as db: + active_states = db['oauth_states'] + + github_oauth = OAuth2Session(GITHUB_CLIENT_ID, redirect_uri=GITHUB_REDIRECT_URI) + token = github_oauth.fetch_token( + token_url="https://github.com/login/oauth/access_token", + client_secret=GITHUB_CLIENT_SECRET, + authorization_response=request.url + ) + + # Fetch the user's GitHub profile + response = github_oauth.get( + url="https://api.github.com/user", + headers={ + "Accept": "application/vnd.github.v3+json", + "Authorization": f"token {token['access_token']}", + }, + ) + github_user = response.json() + + # if the user is not in the active states, return an error + for discord_user_id, _state in active_states.items(): + if state == _state: + break + else: + return Response("Invalid state", status=400) + + # remove the user from the active states + del active_states[discord_user_id] + + # get discord user data + discord_user_future = asyncio.run_coroutine_threadsafe( + globals.DISCORD_BOT.fetch_user(int(discord_user_id)), + globals.DISCORD_BOT.loop + ) + discord_user = discord_user_future.result() + + with globals.DISCORD_BOT.db as db: + db['discord_users'] = db.get('discord_users', {}) + db['discord_users'][discord_user_id] = { + 'discord_username': discord_user.name, + 'discord_global_name': discord_user.global_name, + 'github_id': github_user['id'], + 'github_username': github_user['login'], + } + + globals.DISCORD_BOT.update_cached_message( + author_id=discord_user_id, + reason='success', + ) + + # Redirect to our main website + return redirect("https://app.lizardbyte.dev") + + +@app.route("/webhook//", methods=["POST"]) +def webhook(source: str, key: str) -> Tuple[Response, int]: + """ + Process webhooks from various sources. + + * GitHub sponsors: https://github.com/sponsors/LizardByte/dashboard/webhooks + * GitHub status: https://www.githubstatus.com + + Parameters + ---------- + source : str + The source of the webhook (e.g., 'github_sponsors', 'github_status'). + key : str + The secret key for the webhook. This must match an environment variable. + + Returns + ------- + flask.Response + Response to the webhook request + """ + valid_sources = [ + "github_sponsors", + "github_status", + ] + + if source not in valid_sources: + return jsonify({"status": "error", "message": "Invalid source"}), 400 + + if key != os.getenv("GITHUB_WEBHOOK_SECRET_KEY"): + return jsonify({"status": "error", "message": "Invalid key"}), 400 + + print(f"received webhook from {source}") + data = request.json + print(f"received webhook data: \n{data}") + + # process the webhook data + if source == "github_sponsors": + if data['action'] == "created": + embed = discord.Embed( + author=discord.EmbedAuthor( + name=data["sponsorship"]["sponsor"]["login"], + url=data["sponsorship"]["sponsor"]["url"], + icon_url=data["sponsorship"]["sponsor"]["avatar_url"], + ), + color=colors['green'], + timestamp=time.iso_to_datetime(data['sponsorship']['created_at']), + title="New GitHub Sponsor", + ) + globals.DISCORD_BOT.send_message( + channel_id=os.getenv("DISCORD_SPONSORS_CHANNEL_ID"), + embed=embed, + ) + + elif source == "github_status": + # https://support.atlassian.com/statuspage/docs/enable-webhook-notifications + + embed = discord.Embed( + title="GitHub Status Update", + description=data['page']['status_description'], + color=colors['green'], + ) + + # handle component updates + if 'component_update' in data: + component_update = data['component_update'] + component = data['component'] + embed = discord.Embed( + color=status_colors_map.get(component_update['new_status'], colors['orange']), + description=f"Status changed from {component_update['old_status']} to {component_update['new_status']}", + timestamp=time.iso_to_datetime(component_update['created_at']), + title=f"Component Update: {component['name']}", + ) + embed.add_field(name="Component ID", value=component['id']) + embed.add_field(name="Component Status", value=component['status']) + + # handle incident updates + if 'incident' in data: + incident = data['incident'] + try: + update = incident['incident_updates'][0] + except (IndexError, KeyError): + return jsonify({"status": "error", "message": "No incident updates"}), 400 + + embed = discord.Embed( + color=status_colors_map.get(update['status'], colors['orange']), + timestamp=time.iso_to_datetime(incident['created_at']), + title=f"Incident: {incident['name']}", + url=incident.get('shortlink', 'https://www.githubstatus.com'), + ) + embed.add_field(name="Level", value=incident['impact'], inline=False) + embed.add_field(name=update['status'], value=html_to_md(update['body']), inline=False) + + globals.DISCORD_BOT.send_message( + channel_id=os.getenv("DISCORD_GITHUB_STATUS_CHANNEL_ID"), + embed=embed, + ) + + return jsonify({"status": "success"}), 200 + + +def run(): + cert_file, key_file = crypto.initialize_certificate() + + app.run( + host="0.0.0.0", + port=8080, + ssl_context=(cert_file, key_file) + ) + + +def start(): + server = Thread( + name="Flask", + daemon=True, + target=run, + ) + server.start() diff --git a/src/discord/bot.py b/src/discord/bot.py index a9baf6c..390558d 100644 --- a/src/discord/bot.py +++ b/src/discord/bot.py @@ -2,13 +2,14 @@ import asyncio import os import threading +from typing import Literal, Optional # lib imports import discord # local imports -from src.common import bot_name, get_avatar_bytes, org_name -from src.discord.tasks import daily_task +from src.common.common import bot_name, data_dir, get_avatar_bytes, org_name +from src.common.database import Database from src.discord.views import DonateCommandView @@ -21,6 +22,9 @@ class Bot(discord.Bot): when the bot is ready. """ def __init__(self, *args, **kwargs): + # tasks need to be imported here to avoid circular imports + from src.discord import tasks + if 'intents' not in kwargs: intents = discord.Intents.all() kwargs['intents'] = intents @@ -30,6 +34,11 @@ def __init__(self, *args, **kwargs): self.bot_thread = threading.Thread(target=lambda: None) self.token = os.environ['DISCORD_BOT_TOKEN'] + self.db = Database(db_path=os.path.join(data_dir, 'discord_bot_database')) + self.ephemeral_db = {} + self.clean_ephemeral_cache = tasks.clean_ephemeral_cache + self.daily_task = tasks.daily_task + self.role_update_task = tasks.role_update_task self.load_extension( name='src.discord.cogs', @@ -37,12 +46,15 @@ def __init__(self, *args, **kwargs): store=False, ) + with self.db as db: + db['oauth_states'] = {} # clear any oauth states from previous sessions + async def on_ready(self): """ Bot on ready event. This function runs when the discord bot is ready. The function will update the bot presence, update the username - and avatar, and start daily tasks. + and avatar, and start tasks. """ print(f'py-cord version: {discord.__version__}') print(f'Logged in as {self.user.name} (ID: {self.user.id})') @@ -59,18 +71,197 @@ async def on_ready(self): self.add_view(DonateCommandView()) # register view for persistent listening - await self.sync_commands() + self.clean_ephemeral_cache.start(bot=self) + self.role_update_task.start(bot=self) try: os.environ['DAILY_TASKS'] except KeyError: - daily_task.start(bot=self) + self.daily_task.start(bot=self) else: if os.environ['DAILY_TASKS'].lower() == 'true': - daily_task.start(bot=self) + self.daily_task.start(bot=self) else: print("'DAILY_TASKS' environment variable is disabled") + await self.sync_commands() + + async def async_send_message( + self, + channel_id: int, + message: str = None, + embed: discord.Embed = None, + ) -> Optional[discord.Message]: + """ + Send a message to a specific channel asynchronously. If the embeds are too large, they will be shortened. + Additionally, if the total size of the embeds is too large, they will be sent in separate messages. + + Parameters + ---------- + channel_id : int + The ID of the channel to send the message to. + message : str, optional + The message to send. + embed : discord.Embed, optional + The embed to send. + + Returns + ------- + discord.Message + The message that was sent. + """ + # ensure we have a message or embeds to send + if not message and not embed: + return + + if embed and len(embed) > 6000: + cut_length = len(embed) - 6000 + 3 + embed.description = embed.description[:-cut_length] + "..." + if embed and embed.description and len(embed.description) > 4096: + cut_length = len(embed.description) - 4096 + 3 + embed.description = embed.description[:-cut_length] + "..." + + channel = await self.fetch_channel(channel_id) + return await channel.send(content=message, embed=embed) + + def send_message( + self, + channel_id: int, + message: str = None, + embed: discord.Embed = None, + ) -> discord.Message: + """ + Send a message to a specific channel synchronously. + + Parameters + ---------- + channel_id : int + The ID of the channel to send the message to. + message : str, optional + The message to send. + embed : discord.Embed, optional + The embed to send. + + Returns + ------- + discord.Message + The message that was sent. + """ + future = asyncio.run_coroutine_threadsafe( + self.async_send_message( + channel_id=channel_id, + message=message, + embed=embed, + ), self.loop) + return future.result() + + async def async_update_cached_message( + self, + author_id: int, + reason: str, + ) -> bool: + """ + Update the original message with the reason asynchronously. + + After the message is updated, it will be removed from the cache. + + Parameters + ---------- + author_id : int + Author ID to update the cache. + reason : str + Reason to update the cache. Must be one of the following: 'duplicate', 'failure', 'success', 'timeout'. + + Returns + ------- + bool + True if the message was updated, False otherwise. + """ + reasons = { + 'duplicate': "This request was invalidated due to a new request.", + 'failure': "An error occurred while linking your GitHub account.", + 'success': "Your GitHub account is now linked.", + 'timeout': "The request has timed out.", + } + + db = self.ephemeral_db + db['github_cache_context'] = db.get('github_cache_context', {}) + + if str(author_id) not in db['github_cache_context']: + return False + + await db['github_cache_context'][str(author_id)]['response'].edit( + content=reasons[reason], + ) + + # remove the context from the cache + del db['github_cache_context'][str(author_id)] + + return True + + def update_cached_message( + self, + author_id: int, + reason: str, + ) -> bool: + """ + Update the original message with the reason synchronously. + + After the message is updated, it will be removed from the cache. + + Parameters + ---------- + author_id : int + Author ID to update the cache. + reason : str + Reason to update the cache. Must be one of the following: 'duplicate', 'failure', 'success', 'timeout'. + + Returns + ------- + bool + True if the message was updated, False otherwise. + """ + future = asyncio.run_coroutine_threadsafe( + self.async_update_cached_message( + author_id=author_id, + reason=reason, + ), self.loop) + return future.result() + + def create_thread( + self, + message: discord.Message, + name: str, + auto_archive_duration: Literal[60, 1440, 4320, 10080] = discord.MISSING, + slowmode_delay: int = discord.MISSING, + ) -> discord.Thread: + """ + Create a thread from a message. + + Parameters + ---------- + message : discord.Message + The message to create the thread from. + name : str + The name of the thread. + auto_archive_duration : Literal[60, 1440, 4320, 10080], optional + The duration in minutes before the thread is automatically archived. + slowmode_delay : int, optional + The slowmode delay for the thread. + + Returns + ------- + discord.Thread + The thread that was created. + """ + future = asyncio.run_coroutine_threadsafe( + message.create_thread( + name=name, + auto_archive_duration=auto_archive_duration, + slowmode_delay=slowmode_delay, + ), self.loop) + return future.result() + def start_threaded(self): try: # Login the bot in a separate thread @@ -85,14 +276,12 @@ def start_threaded(self): self.stop() def stop(self, future: asyncio.Future = None): - print("Attempting to stop daily tasks") - daily_task.stop() + print("Attempting to stop tasks") + self.daily_task.stop() + self.role_update_task.stop() + self.clean_ephemeral_cache.stop() print("Attempting to close bot connection") if self.bot_thread is not None and self.bot_thread.is_alive(): asyncio.run_coroutine_threadsafe(self.close(), self.loop) self.bot_thread.join() print("Closed bot") - - # Set a result for the future to mark it as done (unit testing) - if future and not future.done(): - future.set_result(None) diff --git a/src/discord/cogs/base_commands.py b/src/discord/cogs/base_commands.py index 99734d8..fd87cf7 100644 --- a/src/discord/cogs/base_commands.py +++ b/src/discord/cogs/base_commands.py @@ -3,7 +3,7 @@ from discord.commands import Option # local imports -from src.common import avatar, bot_name, org_name, version +from src.common.common import avatar, bot_name, colors, org_name, version from src.discord.views import DonateCommandView from src.discord import cogs_common @@ -39,7 +39,7 @@ async def help_command( description += f"\n\nVersion: {version}\n" - embed = discord.Embed(description=description, color=0xE5A00D) + embed = discord.Embed(description=description, color=colors['orange']) embed.set_footer(text=bot_name, icon_url=avatar) await ctx.respond(embed=embed, ephemeral=True) diff --git a/src/discord/cogs/fun_commands.py b/src/discord/cogs/fun_commands.py index 98e53f2..395595d 100644 --- a/src/discord/cogs/fun_commands.py +++ b/src/discord/cogs/fun_commands.py @@ -7,7 +7,7 @@ import requests # local imports -from src.common import avatar, bot_name +from src.common.common import avatar, bot_name, colors from src.discord.views import RefundCommandView from src.discord import cogs_common @@ -56,7 +56,7 @@ async def random_command( else: description = None - embed = discord.Embed(title=quote, description=description, color=0x00ff00) + embed = discord.Embed(title=quote, description=description, color=colors['green']) embed.set_footer(text=bot_name, icon_url=avatar) if user: @@ -91,7 +91,7 @@ async def refund_command( embed = discord.Embed(title="Refund request", description="Original purchase price: $0.00\n\n" "Select the button below to request a full refund!", - color=0xDC143C) + color=colors['red']) embed.set_footer(text=bot_name, icon_url=avatar) if user: diff --git a/src/discord/cogs/github_commands.py b/src/discord/cogs/github_commands.py new file mode 100644 index 0000000..0e85dad --- /dev/null +++ b/src/discord/cogs/github_commands.py @@ -0,0 +1,145 @@ +# standard imports +from datetime import datetime, timedelta, UTC +import os + +# lib imports +import discord +from requests_oauthlib import OAuth2Session + +# local imports +from src.common.common import colors +from src.common import sponsors + + +link_github_platform_description = 'Platform to link' # hack for flake8 F722 +link_github_platform_choices = [ # hack for flake8 F821 + "discord", + "github", +] + + +class GitHubCommandsCog(discord.Cog): + def __init__(self, bot): + self.bot = bot + + @discord.slash_command( + name="get_sponsors", + description="Get list of GitHub sponsors", + default_member_permissions=discord.Permissions(manage_guild=True), + ) + async def get_sponsors( + self, + ctx: discord.ApplicationContext, + ): + """ + Get list of GitHub sponsors. + + Parameters + ---------- + ctx : discord.ApplicationContext + Request message context. + """ + data = sponsors.get_github_sponsors() + + if not data: + await ctx.respond("An error occurred while fetching sponsors.", ephemeral=True) + return + + message = "List of GitHub sponsors" + for edge in data['data']['organization']['sponsorshipsAsMaintainer']['edges']: + sponsor = edge['node']['sponsorEntity'] + tier = edge['node'].get('tier', {}) + tier_info = f" - Tier: {tier.get('name', 'N/A')} (${tier.get('monthlyPriceInDollars', 'N/A')}/month)" + message += f"\n* [{sponsor['login']}]({sponsor['url']}){tier_info}" + + embed = discord.Embed(title="GitHub Sponsors", color=colors['green'], description=message) + + await ctx.respond(embed=embed, ephemeral=True) + + @discord.slash_command( + name="link_github", + description="Validate GitHub sponsor status" + ) + async def link_github( + self, + ctx: discord.ApplicationContext, + platform: discord.Option( + str, + description=link_github_platform_description, + choices=link_github_platform_choices, + required=True, + ), + ): + """ + Link Discord account with GitHub account. + + This works by authenticating to GitHub or to Discord and checking the user's "GitHub" connected account status. + + User to login via OAuth2. + If the Discord option is selected, then check if their connected GitHub account is a sponsor of the project. + + Parameters + ---------- + ctx : discord.ApplicationContext + Request message context. + platform : str + Platform to link. + """ + platform_map = { + 'discord': { + 'auth_url': "https://discord.com/api/oauth2/authorize", + 'client_id': os.environ['DISCORD_CLIENT_ID'], + 'redirect_uri': os.environ['DISCORD_REDIRECT_URI'], + 'scope': [ + "identify", + "connections", + ], + }, + 'github': { + 'auth_url': "https://github.com/login/oauth/authorize", + 'client_id': os.environ['GITHUB_CLIENT_ID'], + 'redirect_uri': os.environ['GITHUB_REDIRECT_URI'], + 'scope': [ + "read:user", + ], + }, + } + + auth = OAuth2Session( + client_id=platform_map[platform]['client_id'], + redirect_uri=platform_map[platform]['redirect_uri'], + scope=platform_map[platform]['scope'], + ) + authorization_url, state = auth.authorization_url(platform_map[platform]['auth_url']) + + # Store the state in the user's session or database + with self.bot.db as db: + db['oauth_states'] = db.get('oauth_states', {}) + db['oauth_states'][str(ctx.author.id)] = state + db.sync() + + response = await ctx.respond( + f"Please authorize the application by clicking [here]({authorization_url}).", + ephemeral=True, + ) + + now = datetime.now(UTC) + db = self.bot.ephemeral_db + db['github_cache_context'] = db.get('github_cache_context', {}) + + # if there is a current context, update the original response on discord + if str(ctx.author.id) in db['github_cache_context']: + await self.bot.async_update_cached_message( + author_id=ctx.author.id, + reason='duplicate', + ) + + db['github_cache_context'][str(ctx.author.id)] = { + 'created_at': now, + 'expires_at': now + timedelta(seconds=300), + 'response': response, + } + + +def setup(bot: discord.Bot): + bot.add_cog(GitHubCommandsCog(bot=bot)) diff --git a/src/discord/cogs/moderator_commands.py b/src/discord/cogs/moderator_commands.py index 2464b7d..22ef8db 100644 --- a/src/discord/cogs/moderator_commands.py +++ b/src/discord/cogs/moderator_commands.py @@ -7,7 +7,7 @@ from discord.commands import Option # local imports -from src.common import avatar, bot_name +from src.common.common import avatar, bot_name, colors # constants recommended_channel_desc = 'Select the recommended channel' # hack for flake8 F722 @@ -56,7 +56,7 @@ async def channel_command( embed = discord.Embed( title="Incorrect channel", description=f"Please move discussion to {recommended_channel.mention}", - color=0x00ff00, + color=colors['orange'], ) permission_ch_id = '' @@ -150,52 +150,63 @@ async def user_info_command( embed.set_author(name=user.name) embed.set_thumbnail(url=user.display_avatar.url) - if user.colour.value: # If user has a role with a color - embed.colour = user.colour + embed.colour = user.color if user.color.value else colors['white'] + + with self.bot.db as db: + user_data = db.get('discord_users', {}).get(str(user.id)) + if user_data and user_data.get('github_username'): + embed.add_field( + name="GitHub", + value=f"[{user_data['github_username']}](https://github.com/{user_data['github_username']})", + inline=False, + ) if isinstance(user, discord.User): # Checks if the user in the server embed.set_footer(text="This user is not in this server.") - else: # We end up here if the user is a discord.Member object - embed.add_field( - name="Joined Server at", - value=f'{discord.utils.format_dt(user.joined_at, "R")}\n' - f'{discord.utils.format_dt(user.joined_at, "F")}', - inline=False, - ) # When the user joined the server - - # get User Roles - roles = [role.name for role in user.roles] - roles.pop(0) # remove @everyone role - embed.add_field( - name="Server Roles", - value='\n'.join(roles) if roles else "No roles", - inline=False, - ) - - # get User Status, such as Server Owner, Server Moderator, Server Admin, etc. - user_status = [] - if user.guild.owner_id == user.id: - user_status.append("Server Owner") - if user.guild_permissions.administrator: - user_status.append("Server Admin") - if user.guild_permissions.manage_guild: - user_status.append("Server Moderator") - embed.add_field( - name="User Status", - value='\n'.join(user_status), - inline=False, - ) - - if user.premium_since: # If the user is boosting the server - boosting_value = (f'{discord.utils.format_dt(user.premium_since, "R")}\n' - f'{discord.utils.format_dt(user.premium_since, "F")}') - else: - boosting_value = "Not boosting" - embed.add_field( - name="Boosting Since", - value=boosting_value, - inline=False, - ) + await ctx.respond(embeds=[embed]) + return + + # We end up here if the user is a discord.Member object + embed.add_field( + name="Joined Server at", + value=f'{discord.utils.format_dt(user.joined_at, "R")}\n' + f'{discord.utils.format_dt(user.joined_at, "F")}', + inline=False, + ) # When the user joined the server + + # get User Roles + roles = [role.name for role in user.roles] + roles.pop(0) # remove @everyone role + embed.add_field( + name="Server Roles", + value='\n'.join(roles) if roles else "No roles", + inline=False, + ) + + # get User Status, such as Server Owner, Server Moderator, Server Admin, etc. + user_status = [] + if user.guild.owner_id == user.id: + user_status.append("Server Owner") + if user.guild_permissions.administrator: + user_status.append("Server Admin") + if user.guild_permissions.manage_guild: + user_status.append("Server Moderator") + embed.add_field( + name="User Status", + value='\n'.join(user_status), + inline=False, + ) + + if user.premium_since: # If the user is boosting the server + boosting_value = (f'{discord.utils.format_dt(user.premium_since, "R")}\n' + f'{discord.utils.format_dt(user.premium_since, "F")}') + else: + boosting_value = "Not boosting" + embed.add_field( + name="Boosting Since", + value=boosting_value, + inline=False, + ) await ctx.respond(embeds=[embed]) # Sends the embed diff --git a/src/discord/cogs/support_commands.py b/src/discord/cogs/support_commands.py index edb1502..ace82f8 100644 --- a/src/discord/cogs/support_commands.py +++ b/src/discord/cogs/support_commands.py @@ -11,7 +11,7 @@ from mistletoe.markdown_renderer import MarkdownRenderer # local imports -from src.common import avatar, bot_name, data_dir +from src.common.common import avatar, bot_name, colors, data_dir from src.discord.views import DocsCommandView from src.discord import cogs_common @@ -130,7 +130,7 @@ async def project_command(ctx: discord.ApplicationContext, command: str): f"{project}/{command}.md") embed = discord.Embed( - color=0xF1C232, + color=colors['yellow'], description=description, timestamp=datetime.datetime.now(tz=datetime.timezone.utc), title="See on GitHub", @@ -165,7 +165,7 @@ async def docs_command( user : discord.Member Username to mention in response. """ - embed = discord.Embed(title="Select a project", color=0xF1C232) + embed = discord.Embed(title="Select a project", color=colors['yellow']) embed.set_footer(text=bot_name, icon_url=avatar) if user: diff --git a/src/discord/tasks.py b/src/discord/tasks.py index d4249dd..8e63a1d 100644 --- a/src/discord/tasks.py +++ b/src/discord/tasks.py @@ -1,5 +1,7 @@ # standard imports -from datetime import datetime +import asyncio +import copy +from datetime import datetime, UTC import json import os @@ -9,165 +11,263 @@ from igdb.wrapper import IGDBWrapper # local imports -from src.common import avatar, bot_name, bot_url +from src.common.common import avatar, bot_name, bot_url, colors +from src.common import sponsors +from src.discord.bot import Bot from src.discord.helpers import igdb_authorization, month_dictionary +@tasks.loop(seconds=30) +async def clean_ephemeral_cache(bot: Bot) -> bool: + """ + Clean ephemeral messages in cache. + + This function runs on a schedule, every 30 seconds. + Check the ephemeral database for expired messages and delete them. + """ + for key, value in copy.deepcopy(bot.ephemeral_db.get('github_cache_context', {})).items(): + if value['expires_at'] < datetime.now(UTC): + bot.update_cached_message(author_id=int(key), reason='timeout') + + return True + + @tasks.loop(minutes=60.0) -async def daily_task(bot: discord.Bot): +async def daily_task(bot: Bot) -> bool: """ Run daily task loop. This function runs on a schedule, every 60 minutes. Create an embed and thread for each game released on this day in history (according to IGDB), if enabled. + + Returns + ------- + bool + True if the task ran successfully, False otherwise. + """ + date = datetime.now(UTC) + if date.hour != int(os.getenv(key='DAILY_TASKS_UTC_HOUR', default=12)): + return False + + daily_releases = True if os.getenv(key='DAILY_RELEASES', default='true').lower() == 'true' else False + if not daily_releases: + print("'DAILY_RELEASES' environment variable is disabled") + return False + + try: + channel_id = int(os.environ['DAILY_CHANNEL_ID']) + except KeyError: + print("'DAILY_CHANNEL_ID' not defined in environment variables.") + return False + + igdb_auth = igdb_authorization(client_id=os.environ['IGDB_CLIENT_ID'], + client_secret=os.environ['IGDB_CLIENT_SECRET']) + wrapper = IGDBWrapper(client_id=os.environ['IGDB_CLIENT_ID'], auth_token=igdb_auth['access_token']) + + end_point = 'release_dates' + fields = [ + 'human', + 'game.name', + 'game.summary', + 'game.url', + 'game.genres.name', + 'game.rating', + 'game.cover.url', + 'game.artworks.url', + 'game.platforms.name', + 'game.platforms.url' + ] + + where = f'human="{month_dictionary[date.month]} {date.day:02d}"*' + limit = 500 + query = f'fields {", ".join(fields)}; where {where}; limit {limit};' + + byte_array = bytes(wrapper.api_request(endpoint=end_point, query=query)) + json_result = json.loads(byte_array) + + game_ids = [] + + for game in json_result: + try: + game_id = game['game']['id'] + except KeyError: + continue + + if game_id in game_ids: + continue # do not repeat the same game... even though it could be a different platform + game_ids.append(game_id) + + try: + embed = discord.Embed( + title=game['game']['name'], + url=game['game']['url'], + description=game['game']['summary'][0:2000 - 1], + color=colors['purple'] + ) + except KeyError: + continue + + try: + rating = round(game['game']['rating'] / 20, 1) + embed.add_field( + name='Average Rating', + value=f'⭐{rating}', + inline=True + ) + except KeyError: + continue + if rating < 4.0: # reduce the number of messages per day + continue + + try: + embed.add_field( + name='Release Date', + value=game['human'], + inline=True + ) + except KeyError: + pass + + try: + embed.set_thumbnail(url=f"https:{game['game']['cover']['url'].replace('_thumb', '_original')}") + except KeyError: + pass + + try: + embed.set_image(url=f"https:{game['game']['artworks'][0]['url'].replace('_thumb', '_original')}") + except KeyError: + pass + + try: + platforms = ', '.join(platform['name'] for platform in game['game']['platforms']) + name = 'Platforms' if len(game['game']['platforms']) > 1 else 'Platform' + + embed.add_field( + name=name, + value=platforms, + inline=False + ) + except KeyError: + pass + + try: + genres = ', '.join(genre['name'] for genre in game['game']['genres']) + name = 'Genres' if len(game['game']['genres']) > 1 else 'Genre' + + embed.add_field( + name=name, + value=genres, + inline=False + ) + except KeyError: + pass + + embed.set_author( + name=bot_name, + url=bot_url, + icon_url=avatar + ) + + embed.set_footer( + text='Data provided by IGDB', + icon_url='https://www.igdb.com/favicon-196x196.png' + ) + + message = bot.send_message(channel_id=channel_id, embed=embed) + thread = bot.create_thread(message=message, name=embed.title) + + print(f'thread created: {thread.name}') + + return True + + +@tasks.loop(minutes=1.0) +async def role_update_task(bot: Bot) -> bool: """ - if datetime.utcnow().hour == int(os.getenv(key='DAILY_TASKS_UTC_HOUR', default=12)): - daily_releases = True if os.getenv(key='DAILY_RELEASES', default='true').lower() == 'true' else False - if not daily_releases: - print("'DAILY_RELEASES' environment variable is disabled") + Run the role update task. + + This function runs on a schedule, every 1 minute. + If the current time is not divisible by 10, return False. e.g. Run every 10 minutes. + + Returns + ------- + bool + True if the task ran successfully, False otherwise. + """ + if datetime.now(UTC).minute not in list(range(0, 60, 10)): + return False + + # check each user in the database for their GitHub sponsor status + with bot.db as db: + discord_users = db.get('discord_users', {}) + + if not discord_users: + return False + + github_sponsors = sponsors.get_github_sponsors() + + for user_id, user_data in discord_users.items(): + # get the currently revocable roles, to ensure we don't remove roles that were added by another integration + # i.e.; any role that was added by our bot is safe to remove + revocable_roles = user_data.get('roles', []).copy() + + # check if the user is a GitHub sponsor + for edge in github_sponsors['data']['organization']['sponsorshipsAsMaintainer']['edges']: + sponsor = edge['node']['sponsorEntity'] + if sponsor['login'] == user_data['github_username']: + # user is a sponsor + user_data['github_sponsor'] = True + + monthly_amount = edge['node'].get('tier', {}).get('monthlyPriceInDollars', 0) + + for tier, amount in sponsors.tier_map.items(): + if monthly_amount >= amount: + user_data['roles'] = [tier, 'supporters'] + break + else: + user_data['roles'] = [] + + break else: - try: - channel = bot.get_channel(int(os.environ['DAILY_CHANNEL_ID'])) - except KeyError: - print("'DAILY_CHANNEL_ID' not defined in environment variables.") - else: - igdb_auth = igdb_authorization(client_id=os.environ['IGDB_CLIENT_ID'], - client_secret=os.environ['IGDB_CLIENT_SECRET']) - wrapper = IGDBWrapper(client_id=os.environ['IGDB_CLIENT_ID'], auth_token=igdb_auth['access_token']) - - end_point = 'release_dates' - fields = [ - 'human', - 'game.name', - 'game.summary', - 'game.url', - 'game.genres.name', - 'game.rating', - 'game.cover.url', - 'game.artworks.url', - 'game.platforms.name', - 'game.platforms.url' - ] - - where = f'human="{month_dictionary[datetime.utcnow().month]} {datetime.utcnow().day:02d}"*' - limit = 500 - query = f'fields {", ".join(fields)}; where {where}; limit {limit};' - - byte_array = bytes(wrapper.api_request(endpoint=end_point, query=query)) - json_result = json.loads(byte_array) - - game_ids = [] - - for game in json_result: - color = 0x9147FF - - try: - game_id = game['game']['id'] - except KeyError: - continue - else: - if game_id not in game_ids: - game_ids.append(game_id) - else: # do not repeat the same game... even though it could be a different platform - continue - - try: - embed = discord.Embed( - title=game['game']['name'], - url=game['game']['url'], - description=game['game']['summary'][0:2000 - 1], - color=color - ) - except KeyError: - continue - - try: - embed.add_field( - name='Release Date', - value=game['human'], - inline=True - ) - except KeyError: - pass - - try: - rating = round(game['game']['rating'] / 20, 1) - embed.add_field( - name='Average Rating', - value=f'⭐{rating}', - inline=True - ) - - if rating < 4.0: # reduce number of messages per day - continue - except KeyError: - continue - - try: - embed.set_thumbnail( - url=f"https:{game['game']['cover']['url'].replace('_thumb', '_original')}" - ) - except KeyError: - pass - - try: - embed.set_image( - url=f"https:{game['game']['artworks'][0]['url'].replace('_thumb', '_original')}" - ) - except KeyError: - pass - - try: - platforms = '' - name = 'Platform' - - for platform in game['game']['platforms']: - if platforms: - platforms += ", " - name = 'Platforms' - platforms += platform['name'] - - embed.add_field( - name=name, - value=platforms, - inline=False - ) - except KeyError: - pass - - try: - genres = '' - name = 'Genre' - - for genre in game['game']['genres']: - if genres: - genres += ", " - name = 'Genres' - genres += genre['name'] - - embed.add_field( - name=name, - value=genres, - inline=False - ) - except KeyError: - pass - - try: - embed.set_author( - name=bot_name, - url=bot_url, - icon_url=avatar - ) - except KeyError: - pass - - embed.set_footer( - text='Data provided by IGDB', - icon_url='https://www.igdb.com/favicon-196x196.png' - ) - - message = await channel.send(embed=embed) - thread = await message.create_thread(name=embed.title) - - print(f'thread created: {thread.name}') + # user is not a sponsor + user_data['github_sponsor'] = False + user_data['roles'] = [] + + if user_data.get('github_username'): + user_data['roles'].append('github-users') + + # update the discord user roles + for g in bot.guilds: + roles = g.roles + + role_map = { + 'github-users': discord.utils.get(roles, name='github-users'), + 'supporters': discord.utils.get(roles, name='supporters'), + 't1-sponsors': discord.utils.get(roles, name='t1-sponsors'), + 't2-sponsors': discord.utils.get(roles, name='t2-sponsors'), + 't3-sponsors': discord.utils.get(roles, name='t3-sponsors'), + 't4-sponsors': discord.utils.get(roles, name='t4-sponsors'), + } + + user_roles = user_data['roles'] + + for user_role, role in role_map.items(): + member = g.get_member(int(user_id)) + role = role_map.get(user_role, None) + if not member or not role: + continue + + if user_role in user_roles: + # await member.add_roles(role) + add_future = asyncio.run_coroutine_threadsafe(member.add_roles(role), bot.loop) + add_future.result() + elif user_role in revocable_roles: + # await member.remove_roles(role) + remove_future = asyncio.run_coroutine_threadsafe(member.remove_roles(role), bot.loop) + remove_future.result() + + with bot.db as db: + db['discord_users'] = discord_users + db.sync() + + return True diff --git a/src/discord/views.py b/src/discord/views.py index 4435d8e..c756e1c 100644 --- a/src/discord/views.py +++ b/src/discord/views.py @@ -7,7 +7,7 @@ from discord.ui.button import Button # local imports -from src.common import avatar, bot_name +from src.common.common import avatar, bot_name, colors from src.discord.helpers import get_json from src.discord.modals import RefundModal @@ -89,13 +89,13 @@ def check_completion_status(self) -> Tuple[bool, discord.Embed]: if complete: embed.title = self.docs_project embed.description = f'The selected docs are available at {url}' - embed.color = 0x39FF14 + embed.color = colors['green'] embed.url = url else: # info is not complete embed.title = "Select the remaining values" embed.description = None - embed.color = 0xF1C232 + embed.color = colors['orange'] embed.url = None return complete, embed @@ -113,7 +113,7 @@ async def on_timeout(self): if not complete: embed.title = "Command timed out..." - embed.color = 0xDC143C + embed.color = colors['red'] delete_after = 30 # delete after 30 seconds else: delete_after = None # do not delete diff --git a/src/keep_alive.py b/src/keep_alive.py deleted file mode 100644 index 74ab1c9..0000000 --- a/src/keep_alive.py +++ /dev/null @@ -1,20 +0,0 @@ -from flask import Flask -from threading import Thread -import os - -app = Flask('') - - -@app.route('/') -def main(): - return f"{os.environ['REPL_SLUG']} is live!" - - -def run(): - app.run(host="0.0.0.0", port=8080) - - -def keep_alive(): - server = Thread(name="Flask", target=run) - server.setDaemon(daemonic=True) - server.start() diff --git a/src/reddit/bot.py b/src/reddit/bot.py index 7520b9e..e0c5755 100644 --- a/src/reddit/bot.py +++ b/src/reddit/bot.py @@ -1,18 +1,19 @@ # standard imports from datetime import datetime import os -import requests import shelve import sys import threading import time # lib imports +import discord import praw from praw import models # local imports -from src import common +from src.common import common +from src.common import globals class Bot: @@ -31,14 +32,7 @@ def __init__(self, **kwargs): self.user_agent = kwargs.get('user_agent', f'{common.bot_name} {self.version}') self.avatar = kwargs.get('avatar', common.get_bot_avatar(gravatar=os.environ['GRAVATAR_EMAIL'])) self.subreddit_name = kwargs.get('subreddit', os.getenv('PRAW_SUBREDDIT', 'LizardByte')) - - if not kwargs.get('redirect_uri', None): - try: # for running in replit - self.redirect_uri = f'https://{os.environ["REPL_SLUG"]}.{os.environ["REPL_OWNER"].lower()}.repl.co' - except KeyError: - self.redirect_uri = os.getenv('REDIRECT_URI', 'http://localhost:8080') - else: - self.redirect_uri = kwargs['redirect_uri'] + self.redirect_uri = kwargs.get('redirect_uri', os.getenv('REDIRECT_URI', 'http://localhost:8080')) # directories self.data_dir = common.data_dir @@ -66,7 +60,7 @@ def __init__(self, **kwargs): @staticmethod def validate_env() -> bool: required_env = [ - 'DISCORD_WEBHOOK', + 'DISCORD_REDDIT_CHANNEL_ID', 'PRAW_CLIENT_ID', 'PRAW_CLIENT_SECRET', 'REDDIT_PASSWORD', @@ -141,7 +135,7 @@ def process_submission(self, submission: models.Submission): print(f'submission id: {submission.id}') print(f'submission title: {submission.title}') print('---------') - if os.getenv('DISCORD_WEBHOOK'): + if os.getenv('DISCORD_REDDIT_CHANNEL_ID'): self.discord(submission=submission) self.flair(submission=submission) self.karma(submission=submission) @@ -166,46 +160,39 @@ def discord(self, submission: models.Submission): try: color = int(submission.link_flair_background_color, 16) except Exception: - color = int('ffffff', 16) + color = common.colors['white'] try: redditor = self.reddit.redditor(name=submission.author) except Exception: return - submission_time = datetime.fromtimestamp(submission.created_utc) - - # create the discord message - # todo: use the running discord bot, directly instead of using a webhook - discord_webhook = { - 'username': 'LizardByte-Bot', - 'avatar_url': self.avatar, - 'embeds': [ - { - 'author': { - 'name': str(submission.author), - 'url': f'https://www.reddit.com/user/{submission.author}', - 'icon_url': str(redditor.icon_img) - }, - 'title': str(submission.title), - 'url': str(submission.url), - 'description': str(submission.selftext), - 'color': color, - 'thumbnail': { - 'url': 'https://www.redditstatic.com/desktop2x/img/snoo_discovery@1x.png' - }, - 'footer': { - 'text': f'Posted on r/{self.subreddit_name} at {submission_time}', - 'icon_url': 'https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png' - } - } - ] - } - - # actually send the message - r = requests.post(os.environ['DISCORD_WEBHOOK'], json=discord_webhook) - - if r.status_code == 204: # successful completion of request, no additional content + # create the discord embed + embed = discord.Embed( + author=discord.EmbedAuthor( + name=str(submission.author), + url=f'https://www.reddit.com/user/{submission.author}', + icon_url=str(redditor.icon_img), + ), + color=color, + description=submission.selftext, + footer=discord.EmbedFooter( + text=f'Posted on r/{self.subreddit_name}', + icon_url='https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png' + ), + title=submission.title, + url=f"https://www.reddit.com{submission.permalink}", + timestamp=datetime.fromtimestamp(submission.created_utc), + thumbnail='https://www.redditstatic.com/desktop2x/img/snoo_discovery@1x.png', + ) + + # actually send the embed + message = globals.DISCORD_BOT.send_message( + channel_id=os.getenv("DISCORD_REDDIT_CHANNEL_ID"), + embed=embed, + ) + + if message: with self.lock, shelve.open(self.db) as db: # the shelve doesn't update unless we recreate the main key submissions = db['submissions'] diff --git a/tests/conftest.py b/tests/conftest.py index a9455c6..6a8d14a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,68 @@ +# standard imports +import os +import time + # lib imports import dotenv +import pytest + +# local imports +from src.common import globals dotenv.load_dotenv(override=False) # environment secrets take priority over .env file + +# import after env loaded +from src.discord import bot as d_bot # noqa: E402 + + +@pytest.fixture(scope='session') +def discord_bot(): + bot = d_bot.Bot() + bot.start_threaded() + globals.DISCORD_BOT = bot + + while not bot.is_ready(): # Wait until the bot is ready + time.sleep(1) + + bot.role_update_task.stop() + bot.daily_task.stop() + bot.clean_ephemeral_cache.stop() + + yield bot + + bot.stop() + globals.DISCORD_BOT = None + + +@pytest.fixture(scope='function') +def discord_db_users(discord_bot): + with discord_bot.db as db: + db['discord_users'] = { + '939171917578002502': { + 'discord_username': 'test_user', + 'discord_global_name': 'Test User', + 'github_id': 'test_user', + 'github_username': 'test_user', + 'roles': [ + 'supporters', + ] + } + } + db['oauth_states'] = {'939171917578002502': 'valid_state'} + db.sync() # Ensure the data is written to the shelve + + yield + + with discord_bot.db as db: + db['discord_users'] = {} + db['oauth_states'] = {} + db.sync() # Ensure the data is written to the shelve + + +@pytest.fixture(scope='function') +def no_github_token(): + og_token = os.getenv('GITHUB_TOKEN') + del os.environ['GITHUB_TOKEN'] + yield + + os.environ['GITHUB_TOKEN'] = og_token diff --git a/tests/fixtures/certs/expired/cert.pem b/tests/fixtures/certs/expired/cert.pem new file mode 100644 index 0000000..4084b6f --- /dev/null +++ b/tests/fixtures/certs/expired/cert.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEtDCCApygAwIBAgIUO9/D0xVF8jI0w7lJQEbuOAkpeXIwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI0MTEyMzAwNDUwM1oXDTI0MTEy +MzAwNDUwM1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEAx2ytodLmx/I7DRe6JTGn98I/DEcRdow+f+6UjjIQczPB +jD97JsfV45eVaIWmRMjqn+A8zAKnsBdRpGlFwbAdG174cu/BLdNb/OoVxCSkiZpH +wtmuRVofOgo2VnFTjgG7gu/4GV7SIOsngz/uB+W7xw/GVQfEsDld3cjiLObn0aFv +D7oKE6wAP0gmbGrNDmvFikVUQIU0tuWfc6DLK2QEh00KOsOAjX9bwk7cOO6+W4Xg +EAqYx0XsyNPkWOG8D9FzttnC1UESSoOyr41ne6sn5knkxdk00dxAVpabt7Z1Do4u +NlAR324+u62GkMP9Pv0tXU8YZxnHFT5njXDJMKXwj7vWiHPzR7Ykw6/fCUOYOcop +rOweSUmevmUIGZKQVyKDrcLumGC4IPfv+UCPQyA3eUEyTPnkDawDRepha0FsZ7Pt +R/0Ftm7XW5u4HFMhRyrnDrHBGNywUg+bYGl7MWIqr0p3o9CVENDIRgLyuDrCnnMB +UFrqpbPp7Q6Z64ohdpvb+eJRYBCUJkbbFawUa/SXe6c5/cFAFwoNgN0UHcBvWKpV +INc5WJPgiaHsauADeUuiU4+n3ZdOu8YMCpei+lM+eRR3KadZ2/UE9lzWQ7PfKDtM +iIeon6oudZIlaTJPsV/AFIwJadJKpxYhgJ6JtlcORBUgzaWFEXRL+/rhVRZInNUC +AwEAATANBgkqhkiG9w0BAQsFAAOCAgEAOzJmOXAcERo/kuB11AnrNqk5bpxqF1Gl +ORNxUQflB0f3qooHkuPH6CdrrZ32yUIN+54fcVCCnQfx04PCC4bPFRreTCqyPCtb +Oinfk5BgEmIvE4x9PibPcmQG6zQfHHqOQzsxio6Fjhfk+iL9Fy30W2K3RBvIicOM +BQ+kGysltV+9tMX4wI/VnLCN5LORbBX7fiMnFtmVKeLZalnOWcMqZuc6opQFjWzg +r4vqu6//STkrCvze4tLUMipS8uKXQ9hvrdiXgQGHOZDhRaQCC+TAXYxPn2pxvYYK +l7dlQS1mWY8pPB7X9FMsACmZR2myBIqbHzFsde+Mqyf5fWHihtWwNYPYreCXKZdr +A7LtgQG9KhTUQO9HjFkbG/VYiH5rPUlewd+qLVvdZ8vFS6ZMvMH7eJPdL0ubuM4s +vTDgPXxqE4GqfzuT0d+vmJujllkiOYdbkDNRYv0rekojNbJcNyyDCs1056ke5JPr +//XfgeW1Lwz1yL9xB5U1lqVUaGIifzihO69yNESUSh/niuwDeWYkz/bgo9oM3L9+ +f1WznzC/tcibq+d9V6PE7KRiGfS5ZbRxAm95wrnRurZYkM+eeZHDmPs3InfYe0Zj +WarJjoO+x/+/ErjgsVUHt9JqB8GdXO3Xg7c5bkrt6LqgYxZ2GUDZZSbe/MTktYsp +E/Y7rCRq6LQ= +-----END CERTIFICATE----- diff --git a/tests/fixtures/certs/expired/key.pem b/tests/fixtures/certs/expired/key.pem new file mode 100644 index 0000000..a016fbb --- /dev/null +++ b/tests/fixtures/certs/expired/key.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- diff --git a/tests/unit/common/test_crypto.py b/tests/unit/common/test_crypto.py new file mode 100644 index 0000000..293d6bf --- /dev/null +++ b/tests/unit/common/test_crypto.py @@ -0,0 +1,65 @@ +# standard imports +import os +from datetime import datetime, UTC + +# lib imports +from cryptography import x509 +import pytest + +# local imports +from src.common.crypto import check_expiration, generate_certificate, initialize_certificate, CERT_FILE, KEY_FILE + + +@pytest.fixture(scope='module') +def setup_certificates(): + # Ensure the certificates are generated for testing + if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE): + generate_certificate() + yield + # Cleanup after tests + if os.path.exists(CERT_FILE): + os.remove(CERT_FILE) + if os.path.exists(KEY_FILE): + os.remove(KEY_FILE) + + +@pytest.fixture(scope='function') +def clear_certificates(): + os.remove(CERT_FILE) + os.remove(KEY_FILE) + yield + + +def test_check_expiration(setup_certificates): + days_left = check_expiration(CERT_FILE) + assert days_left <= 365 + assert days_left >= 364 + + +def test_check_expiration_expired(): + cert_file = os.path.join("tests", "fixtures", "certs", "expired", "cert.pem") + days_left = check_expiration(cert_file) + assert days_left < 0 + + +def test_generate_certificate(setup_certificates): + assert os.path.exists(CERT_FILE) + assert os.path.exists(KEY_FILE) + + with open(CERT_FILE, "rb") as cert_file: + cert_data = cert_file.read() + + cert = x509.load_pem_x509_certificate(cert_data) + assert cert.not_valid_after_utc > datetime.now(UTC) + + +@pytest.mark.parametrize("fixture", ["setup_certificates", "clear_certificates"]) +def test_initialize_certificate(request, fixture): + request.getfixturevalue(fixture) + cert_file, key_file = initialize_certificate() + assert os.path.exists(cert_file) + assert os.path.exists(key_file) + + cert_expires_in = check_expiration(cert_file) + assert cert_expires_in <= 365 + assert cert_expires_in >= 364 diff --git a/tests/unit/common/test_sponsors.py b/tests/unit/common/test_sponsors.py new file mode 100644 index 0000000..b0b0aa8 --- /dev/null +++ b/tests/unit/common/test_sponsors.py @@ -0,0 +1,17 @@ +# local imports +from src.common import sponsors + + +def test_get_github_sponsors(): + data = sponsors.get_github_sponsors() + assert data + assert 'errors' not in data + assert 'data' in data + assert 'organization' in data['data'] + assert 'sponsorshipsAsMaintainer' in data['data']['organization'] + assert 'edges' in data['data']['organization']['sponsorshipsAsMaintainer'] + + +def test_get_github_sponsors_error(no_github_token): + data = sponsors.get_github_sponsors() + assert not data diff --git a/tests/unit/common/test_time.py b/tests/unit/common/test_time.py new file mode 100644 index 0000000..ff41e9e --- /dev/null +++ b/tests/unit/common/test_time.py @@ -0,0 +1,17 @@ +# standard imports +import datetime + +# lib imports +import pytest + +# local imports +from src.common import time + + +@pytest.mark.parametrize("iso_str, expected", [ + ("2024-11-23T20:29:48", datetime.datetime(2024, 11, 23, 20, 29, 48)), + ("2023-01-01T00:00:00", datetime.datetime(2023, 1, 1, 0, 0, 0)), + ("2022-12-31T23:59:59", datetime.datetime(2022, 12, 31, 23, 59, 59)), +]) +def test_iso_to_datetime(iso_str, expected): + assert time.iso_to_datetime(iso_str) == expected diff --git a/tests/unit/common/test_webapp.py b/tests/unit/common/test_webapp.py new file mode 100644 index 0000000..ee2fce9 --- /dev/null +++ b/tests/unit/common/test_webapp.py @@ -0,0 +1,349 @@ +# standard imports +import os +from unittest.mock import Mock + +# lib imports +import pytest + +# local imports +from src.common import webapp + + +@pytest.fixture(scope='function') +def test_client(): + """Create a test client for testing webapp endpoints""" + app = webapp.app + app.testing = True + + client = app.test_client() + + # Create a test client using the Flask application configured for testing + with client as test_client: + # Establish an application context + with app.app_context(): + yield test_client # this is where the testing happens! + + +def test_status(test_client): + """ + WHEN the '/status' page is requested (GET) + THEN check that the response is valid + """ + response = test_client.get('/status') + assert response.status_code == 200 + + +def test_favicon(test_client): + """ + WHEN the '/favicon.ico' file is requested (GET) + THEN check that the response is valid + THEN check the content type is 'image/vnd.microsoft.icon' + """ + response = test_client.get('/favicon.ico') + assert response.status_code == 200 + assert response.content_type == 'image/vnd.microsoft.icon' + + +def test_discord_callback_success(test_client, mocker, discord_db_users): + """ + WHEN the '/discord/callback' endpoint is requested (GET) with valid data + THEN check that the response is a redirect to the main website + """ + mocker.patch.dict(os.environ, { + "DISCORD_CLIENT_ID": "test_client_id", + "DISCORD_CLIENT_SECRET": "test_client_secret", + "DISCORD_REDIRECT_URI": "https://localhost:8080/discord/callback" + }) + + mocker.patch('src.common.webapp.OAuth2Session.fetch_token', return_value={'access_token': 'fake_token'}) + mocker.patch('src.common.webapp.OAuth2Session.get', side_effect=[ + Mock(json=lambda: { + 'id': '939171917578002502', + 'username': 'discord_user', + 'global_name': 'discord_global_name', + }), + Mock(json=lambda: [ + { + 'type': 'github', + 'id': 'github_user_id', + 'name': 'github_user_login', + } + ]) + ]) + + response = test_client.get('/discord/callback?state=valid_state') + + assert response.status_code == 302 + assert response.location == "https://app.lizardbyte.dev" + + +def test_discord_callback_invalid_state(test_client, mocker, discord_db_users): + """ + WHEN the '/discord/callback' endpoint is requested (GET) with an invalid state + THEN check that the response is 'Invalid state' + """ + mocker.patch.dict(os.environ, { + "DISCORD_CLIENT_ID": "test_client_id", + "DISCORD_CLIENT_SECRET": "test_client_secret", + "DISCORD_REDIRECT_URI": "https://localhost:8080/discord/callback" + }) + + mocker.patch('src.common.webapp.OAuth2Session.fetch_token', return_value={'access_token': 'fake_token'}) + mocker.patch('src.common.webapp.OAuth2Session.get', return_value=Mock(json=lambda: { + 'id': '1234567890', + 'username': 'discord_user', + 'global_name': 'discord_global_name', + })) + + response = test_client.get('/discord/callback?state=invalid_state') + + assert response.data == b'Invalid state' + assert response.status_code == 400 + + +def test_discord_callback_error_in_request(test_client): + """ + WHEN the '/discord/callback' endpoint is requested (GET) with an error in the request + THEN check that the response is the error description + """ + response = test_client.get('/discord/callback?error=access_denied&error_description=The+user+denied+access') + + assert response.data == b'The user denied access' + assert response.status_code == 400 + + +def test_github_callback_success(test_client, mocker, discord_db_users): + """ + WHEN the '/github/callback' endpoint is requested (GET) with valid data + THEN check that the response is a redirect to the main website + """ + mocker.patch.dict(os.environ, { + "GITHUB_CLIENT_ID": "test_client_id", + "GITHUB_CLIENT_SECRET": "test_client_secret", + "GITHUB_REDIRECT_URI": "https://localhost:8080/github/callback" + }) + + mocker.patch('src.common.webapp.OAuth2Session.fetch_token', return_value={'access_token': 'fake_token'}) + mocker.patch('src.common.webapp.OAuth2Session.get', side_effect=[ + Mock(json=lambda: { + 'id': 'github_user_id', + 'login': 'github_user_login', + }), + Mock(json=lambda: { + 'id': 'github_user_id', + 'login': 'github_user_login', + }) + ]) + + response = test_client.get('/github/callback?state=valid_state') + + assert response.status_code == 302 + assert response.location == "https://app.lizardbyte.dev" + + +def test_github_callback_invalid_state(test_client, mocker, discord_db_users): + """ + WHEN the '/github/callback' endpoint is requested (GET) with an invalid state + THEN check that the response is 'Invalid state' + """ + mocker.patch.dict(os.environ, { + "GITHUB_CLIENT_ID": "test_client_id", + "GITHUB_CLIENT_SECRET": "test_client_secret", + "GITHUB_REDIRECT_URI": "https://localhost:8080/github/callback" + }) + + mocker.patch('src.common.webapp.OAuth2Session.fetch_token', return_value={'access_token': 'fake_token'}) + mocker.patch('src.common.webapp.OAuth2Session.get', return_value=Mock(json=lambda: { + 'id': 'github_user_id', + 'login': 'github_user_login', + })) + + response = test_client.get('/github/callback?state=invalid_state') + + assert response.data == b'Invalid state' + assert response.status_code == 400 + + +def test_github_callback_error_in_request(test_client): + """ + WHEN the '/github/callback' endpoint is requested (GET) with an error in the request + THEN check that the response is the error description + """ + response = test_client.get('/github/callback?error=access_denied&error_description=The+user+denied+access') + + assert response.data == b'The user denied access' + assert response.status_code == 400 + + +def test_webhook_invalid_source(test_client): + """ + WHEN the '/webhook//' endpoint is requested (POST) with an invalid source + THEN check that the response is 'Invalid source' + """ + response = test_client.post('/webhook/invalid_source/invalid_key') + assert response.json == {"status": "error", "message": "Invalid source"} + assert response.status_code == 400 + + +def test_webhook_invalid_key(test_client, mocker): + """ + WHEN the '/webhook//' endpoint is requested (POST) with an invalid key + THEN check that the response is 'Invalid key' + """ + mocker.patch.dict(os.environ, {"GITHUB_WEBHOOK_SECRET_KEY": "valid_key"}) + response = test_client.post('/webhook/github_sponsors/invalid_key') + assert response.json == {"status": "error", "message": "Invalid key"} + assert response.status_code == 400 + + +def test_webhook_github_sponsors(discord_bot, test_client, mocker): + """ + WHEN the '/webhook/github_sponsors/' endpoint is requested (POST) with valid data + THEN check that the response is 'success' + """ + mocker.patch.dict(os.environ, {"GITHUB_WEBHOOK_SECRET_KEY": "valid_key"}) + data = { + 'action': 'created', + 'sponsorship': { + 'sponsor': { + 'login': 'octocat', + 'url': 'https://github.com/octocat', + 'avatar_url': 'https://avatars.githubusercontent.com/u/583231', + }, + 'created_at': '1970-01-01T00:00:00Z', + }, + } + response = test_client.post('/webhook/github_sponsors/valid_key', json=data) + assert response.json == {"status": "success"} + assert response.status_code == 200 + + +@pytest.mark.parametrize("data, expected_status", [ + # https://support.atlassian.com/statuspage/docs/enable-webhook-notifications/ + ({ + "meta": { + "unsubscribe": "https://statustest.flyingkleinbrothers.com:5000/?unsubscribe=j0vqr9kl3513", + "documentation": "https://doers.statuspage.io/customer-notifications/webhooks/", + }, + "page": { + "id": "j2mfxwj97wnj", + "status_indicator": "major", + "status_description": "Partial System Outage", + }, + "component_update": { + "created_at": "2013-05-29T21:32:28Z", + "new_status": "operational", + "old_status": "major_outage", + "id": "k7730b5v92bv", + "component_id": "rb5wq1dczvbm", + }, + "component": { + "created_at": "2013-05-29T21:32:28Z", + "id": "rb5wq1dczvbm", + "name": "Some Component", + "status": "operational", + }, + }, 200), + ({ + "meta": { + "unsubscribe": "https://statustest.flyingkleinbrothers.com:5000/?unsubscribe=j0vqr9kl3513", + "documentation": "https://doers.statuspage.io/customer-notifications/webhooks/", + }, + "page": { + "id": "j2mfxwj97wnj", + "status_indicator": "critical", + "status_description": "Major System Outage", + }, + "incident": { + "backfilled": False, + "created_at": "2013-05-29T15:08:51-06:00", + "impact": "critical", + "impact_override": None, + "monitoring_at": "2013-05-29T16:07:53-06:00", + "postmortem_body": None, + "postmortem_body_last_updated_at": None, + "postmortem_ignored": False, + "postmortem_notified_subscribers": False, + "postmortem_notified_twitter": False, + "postmortem_published_at": None, + "resolved_at": None, + "scheduled_auto_transition": False, + "scheduled_for": None, + "scheduled_remind_prior": False, + "scheduled_reminded_at": None, + "scheduled_until": None, + "shortlink": "https://j.mp/18zyDQx", + "status": "monitoring", + "updated_at": "2013-05-29T16:30:35-06:00", + "id": "lbkhbwn21v5q", + "organization_id": "j2mfxwj97wnj", + "incident_updates": [ + { + "body": "A fix has been implemented and we are monitoring the results.", + "created_at": "2013-05-29T16:07:53-06:00", + "display_at": "2013-05-29T16:07:53-06:00", + "status": "monitoring", + "twitter_updated_at": None, + "updated_at": "2013-05-29T16:09:09-06:00", + "wants_twitter_update": False, + "id": "drfcwbnpxnr6", + "incident_id": "lbkhbwn21v5q", + }, + { + "body": "We are waiting for the cloud to come back online " + "and will update when we have further information", + "created_at": "2013-05-29T15:18:51-06:00", + "display_at": "2013-05-29T15:18:51-06:00", + "status": "identified", + "twitter_updated_at": None, + "updated_at": "2013-05-29T15:28:51-06:00", + "wants_twitter_update": False, + "id": "2rryghr4qgrh", + "incident_id": "lbkhbwn21v5q", + }, + { + "body": "The cloud, located in Norther Virginia, has once again gone the way of the dodo.", + "created_at": "2013-05-29T15:08:51-06:00", + "display_at": "2013-05-29T15:08:51-06:00", + "status": "investigating", + "twitter_updated_at": None, + "updated_at": "2013-05-29T15:28:51-06:00", + "wants_twitter_update": False, + "id": "qbbsfhy5s9kk", + "incident_id": "lbkhbwn21v5q", + }, + ], + "name": "Virginia Is Down", + }, + }, 200), + ({ + "meta": { + "unsubscribe": "https://statustest.flyingkleinbrothers.com:5000/?unsubscribe=j0vqr9kl3513", + "documentation": "https://doers.statuspage.io/customer-notifications/webhooks/", + }, + "page": { + "id": "j2mfxwj97wnj", + "status_indicator": "critical", + "status_description": "Major System Outage", + }, + "incident": { + "incident_updates": [], + "name": "Virginia Is Down", + }, + }, 400), +]) +def test_webhook_github_status(discord_bot, test_client, mocker, data, expected_status): + """ + WHEN the '/webhook/github_status/' endpoint is requested (POST) with valid data + THEN check that the response is 'success' + """ + mocker.patch.dict(os.environ, {"GITHUB_WEBHOOK_SECRET_KEY": "valid_key"}) + response = test_client.post('/webhook/github_status/valid_key', json=data) + assert response.status_code == expected_status + + if expected_status == 200: + assert response.json == {"status": "success"} + + if expected_status == 400: + assert response.json["status"] == "error" + assert response.json["message"] diff --git a/tests/unit/discord/test_discord_bot.py b/tests/unit/discord/test_discord_bot.py index 500722c..cb53388 100644 --- a/tests/unit/discord/test_discord_bot.py +++ b/tests/unit/discord/test_discord_bot.py @@ -1,42 +1,79 @@ # standard imports import asyncio +import os # lib imports +import discord import pytest -import pytest_asyncio # local imports -from src import common -from src.discord import bot as discord_bot - - -@pytest_asyncio.fixture -async def bot(): - # event_loop fixture is deprecated - _loop = asyncio.get_event_loop() - - bot = discord_bot.Bot(loop=_loop) - future = asyncio.run_coroutine_threadsafe(bot.start(token=bot.token), _loop) - await bot.wait_until_ready() # Wait until the bot is ready - yield bot - bot.stop(future=future) - - # wait for the bot to finish - counter = 0 - while not future.done() and counter < 30: - await asyncio.sleep(1) - counter += 1 - future.cancel() # Cancel the bot when the tests are done - - -@pytest.mark.asyncio -async def test_bot_on_ready(bot): - assert bot is not None - assert bot.guilds - assert bot.guilds[0].name == "ReenigneArcher's test server" - assert bot.user.id == 939171917578002502 - assert bot.user.name == common.bot_name - assert bot.user.avatar +from src.common import common + + +def test_bot_on_ready(discord_bot): + assert discord_bot is not None + assert discord_bot.guilds + assert discord_bot.guilds[0].name == "ReenigneArcher's test server" + assert discord_bot.user.id == 939171917578002502 + assert discord_bot.user.name == common.bot_name + assert discord_bot.user.avatar # compare the bot avatar to our intended avatar - assert await bot.user.avatar.read() == common.get_avatar_bytes() + future = asyncio.run_coroutine_threadsafe(discord_bot.user.avatar.read(), discord_bot.loop) + assert future.result() == common.get_avatar_bytes() + + +@pytest.mark.parametrize("message, embed", [ + (None, None), + (f"This is a test message from {os.getenv('CI_EVENT_ID', 'local')}.", None), + (None, discord.Embed( + title="Test Embed 1", + description="This is a test embed from the unit tests.", + color=0x00ff00, + )), + (None, discord.Embed( + title="Test Embed 2", + description=f"{'a' * 4097}", # ensure embed description is larger than 4096 characters + color=0xff0000, + )), + (None, discord.Embed( + title="Test Embed 3", + description=f"{'a' * 4096}", + color=0xff0000, + footer=discord.EmbedFooter( + text=f"{'b' * 2000}" # ensure embed total size is larger than 6000 characters + ), + )), +]) +def test_send_message(discord_bot, message, embed): + channel_id = int(os.environ['DISCORD_GITHUB_STATUS_CHANNEL_ID']) + msg = discord_bot.send_message(channel_id=channel_id, message=message, embed=embed) + + if not message and not embed: + assert msg is None + return + + if message: + assert msg.content == message + else: + assert msg.content == '' + + assert msg.channel.id == channel_id + assert msg.author.id == 939171917578002502 + assert msg.author.name == common.bot_name + + avatar_future = asyncio.run_coroutine_threadsafe(msg.author.avatar.read(), discord_bot.loop) + assert avatar_future.result() == common.get_avatar_bytes() + + assert msg.author.display_name == common.bot_name + assert msg.author.discriminator == "7085" + assert msg.author.bot is True + assert msg.author.system is False + + if embed: + assert msg.embeds[0].title == embed.title + assert msg.embeds[0].description == embed.description[:4093] + "..." if len( + embed.description) > 4096 or len(embed) > 6000 else embed.description + assert msg.embeds[0].color == embed.color + if embed.footer: + assert msg.embeds[0].footer.text == embed.footer.text diff --git a/tests/unit/discord/test_tasks.py b/tests/unit/discord/test_tasks.py new file mode 100644 index 0000000..49f16f0 --- /dev/null +++ b/tests/unit/discord/test_tasks.py @@ -0,0 +1,130 @@ +# standard imports +from datetime import datetime, timedelta, timezone, UTC +import os + +# lib imports +import pytest + +# local imports +from src.discord import tasks + + +def set_env_variable(env_var_name, request): + og_value = os.environ.get(env_var_name) + new_value = request.param + if new_value is not None: + os.environ[env_var_name] = new_value + yield + if og_value is not None: + os.environ[env_var_name] = og_value + elif env_var_name in os.environ: + del os.environ[env_var_name] + + +@pytest.fixture(scope='function') +def set_daily_channel_id(request): + yield from set_env_variable('DAILY_CHANNEL_ID', request) + + +@pytest.fixture(scope='function') +def set_daily_releases(request): + yield from set_env_variable('DAILY_RELEASES', request) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("db_start, expected_keys", [ + ( + { + '1': { + 'expires_at': datetime.now(UTC), + }, + '2': { + 'expires_at': datetime.now(UTC) - timedelta(minutes=1) + }, + '3': { + 'expires_at': datetime.now(UTC) - timedelta(minutes=2) + }, + '4': { + 'expires_at': datetime.now(UTC) - timedelta(minutes=3) + }, + '5': { + 'expires_at': datetime.now(UTC) - timedelta(minutes=4) + }, + '6': { + 'expires_at': datetime.now(UTC) - timedelta(minutes=5) + }, + '7': { + 'expires_at': datetime.now(UTC) - timedelta(minutes=10) + }, + }, + ['1', '2', '3', '4', '5'] + ) +]) +async def test_clean_ephemeral_cache(discord_bot, mocker, db_start, expected_keys): + """ + GIVEN a database with ephemeral cache entries + WHEN the clean_ephemeral_cache task is called + THEN expired entries are removed from the database + """ + # Mock the edit method of the response objects + for entry in db_start.values(): + entry['response'] = mocker.Mock() + entry['response'].edit = mocker.AsyncMock() + + # Mock the bot's ephemeral_db + discord_bot.ephemeral_db = { + 'github_cache_context': db_start + } + + # Run the clean_ephemeral_cache task + await tasks.clean_ephemeral_cache(bot=discord_bot) + + # Assert the ephemeral_db is as expected + for k, v in discord_bot.ephemeral_db['github_cache_context'].items(): + assert k in expected_keys, f"Key {k} should not be in the database" + assert v['expires_at'] >= datetime.now(UTC) - timedelta(minutes=5), f"Key {k} should not have expired" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("skip, set_daily_releases, set_daily_channel_id, expected", [ + (True, 'false', None, False), + (False, 'false', None, False), + (False, 'true', None, False), + (False, 'true', os.environ['DISCORD_GITHUB_STATUS_CHANNEL_ID'], True), +], indirect=["set_daily_releases", "set_daily_channel_id"]) +async def test_daily_task(discord_bot, mocker, skip, set_daily_releases, set_daily_channel_id, expected): + """ + WHEN the daily task is called + THEN check that the task runs without error + """ + # Patch datetime.datetime at the location where it's imported in `tasks` + mock_datetime = mocker.patch('src.discord.tasks.datetime', autospec=True) + mock_datetime.now.return_value = datetime(2023, 1, 1, 1 if skip else 12, 0, 0, tzinfo=timezone.utc) + + # Run the daily task + result = await tasks.daily_task(bot=discord_bot) + + assert result is expected + + # Verify that datetime.now() was called + mock_datetime.now.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("skip", [True, False]) +async def test_role_update_task(discord_bot, discord_db_users, mocker, skip): + """ + WHEN the role update task is called + THEN check that the task runs without error + """ + # Patch datetime.datetime at the location where it's imported in `tasks` + mock_datetime = mocker.patch('src.discord.tasks.datetime', autospec=True) + mock_datetime.now.return_value = datetime(2023, 1, 1, 0, 1 if skip else 0, 0, tzinfo=timezone.utc) + + # Run the task + result = await tasks.role_update_task(bot=discord_bot) + + assert result is not skip + + # Verify that datetime.now() was called + mock_datetime.now.assert_called_once() diff --git a/tests/unit/reddit/test_reddit_bot.py b/tests/unit/reddit/test_reddit_bot.py index 8ff1a84..07a38bc 100644 --- a/tests/unit/reddit/test_reddit_bot.py +++ b/tests/unit/reddit/test_reddit_bot.py @@ -161,7 +161,7 @@ def _submission(self, bot, recorder): def test_validate_env(self, bot): with patch.dict( os.environ, { - "DISCORD_WEBHOOK": "test", + "DISCORD_REDDIT_CHANNEL_ID": "test", "PRAW_CLIENT_ID": "test", "PRAW_CLIENT_SECRET": "test", "REDDIT_PASSWORD": "test", @@ -198,7 +198,7 @@ def test_process_comment(self, bot, recorder, request, slash_command_comment): assert db['comments'][slash_command_comment.id]['slash_command']['project'] == 'sunshine' assert db['comments'][slash_command_comment.id]['slash_command']['command'] == 'vban' - def test_process_submission(self, bot, recorder, request, _submission): + def test_process_submission(self, bot, discord_bot, recorder, request, _submission): with recorder.use_cassette(request.node.name): bot.process_submission(submission=_submission) with bot.lock, shelve.open(bot.db) as db: @@ -213,7 +213,7 @@ def test_comment_loop(self, bot, recorder, request): comment = bot._comment_loop(test=True) assert comment.author - def test_submission_loop(self, bot, recorder, request): + def test_submission_loop(self, bot, discord_bot, recorder, request): with recorder.use_cassette(request.node.name): submission = bot._submission_loop(test=True) assert submission.author