Skip to content

Commit

Permalink
feat(github): add github integration
Browse files Browse the repository at this point in the history
  • Loading branch information
ReenigneArcher committed Nov 19, 2024
1 parent 362aef2 commit 0480459
Show file tree
Hide file tree
Showing 21 changed files with 465 additions and 98 deletions.
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -31,6 +31,9 @@ platforms such as GitHub discussions/issues could be added.
| variable | required | default | description |
|-------------------------|----------|------------------------------------------------------|---------------------------------------------------------------|
| 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_REDIRECT_URI | False | `https://localhost:8080/discord/callback` | The redirect uri for OAuth2. Must be publicly accessible. |
| 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. |
Expand All @@ -41,11 +44,6 @@ platforms such as GitHub discussions/issues could be added.
| 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=<the client id of the bot>&permissions=8&scope=bot%20applications.commands`


### Reddit

Expand All @@ -62,7 +60,13 @@ platforms such as GitHub discussions/issues could be added.
| 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 |
| REDDIT_PASSWORD | True | None | Reddit password |

### Start

* Running bot:
* `python -m src`
```bash
python -m src
```

* Invite bot to server:
* `https://discord.com/api/oauth2/authorize?client_id=<the client id of the bot>&permissions=8&scope=bot%20applications.commands`
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ praw==7.8.1
py-cord==2.6.1
python-dotenv==1.0.1
requests==2.32.3
requests-oauthlib==2.0.0
26 changes: 10 additions & 16 deletions src/__main__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# standard imports
import os
import time

# development imports
Expand All @@ -8,33 +7,28 @@

# local imports
if True: # hack for flake8
from src.common import globals

Check warning on line 10 in src/__main__.py

View check run for this annotation

Codecov / codecov/patch

src/__main__.py#L10

Added line #L10 was not covered by tests
from src.discord import bot as d_bot
from src import keep_alive
from src.common import webapp

Check warning on line 12 in src/__main__.py

View check run for this annotation

Codecov / codecov/patch

src/__main__.py#L12

Added line #L12 was not covered by tests
from src.reddit import bot as r_bot


def main():
# to run in replit
try:
os.environ['REPL_SLUG']
except KeyError:
pass # not running in replit
else:
keep_alive.keep_alive() # Start the web server
webapp.start() # Start the web server

Check warning on line 17 in src/__main__.py

View check run for this annotation

Codecov / codecov/patch

src/__main__.py#L17

Added line #L17 was not covered by tests

discord_bot = d_bot.Bot()
discord_bot.start_threaded() # Start the discord bot
globals.DISCORD_BOT = d_bot.Bot()
globals.DISCORD_BOT.start_threaded() # Start the discord bot

Check warning on line 20 in src/__main__.py

View check run for this annotation

Codecov / codecov/patch

src/__main__.py#L19-L20

Added lines #L19 - L20 were not covered by tests

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

Check warning on line 23 in src/__main__.py

View check run for this annotation

Codecov / codecov/patch

src/__main__.py#L22-L23

Added lines #L22 - L23 were not covered by tests

try:
while discord_bot.bot_thread.is_alive() or reddit_bot.bot_thread.is_alive():
while globals.DISCORD_BOT.bot_thread.is_alive() or globals.REDDIT_BOT.bot_thread.is_alive():

Check warning on line 26 in src/__main__.py

View check run for this annotation

Codecov / codecov/patch

src/__main__.py#L26

Added line #L26 was not covered by tests
time.sleep(0.5)
except KeyboardInterrupt:
print("Keyboard Interrupt Detected")
discord_bot.stop()
reddit_bot.stop()
globals.DISCORD_BOT.stop()
globals.REDDIT_BOT.stop()

Check warning on line 31 in src/__main__.py

View check run for this annotation

Codecov / codecov/patch

src/__main__.py#L30-L31

Added lines #L30 - L31 were not covered by tests


if __name__ == '__main__': # pragma: no cover
Expand Down
Empty file added src/common/__init__.py
Empty file.
File renamed without changes.
69 changes: 69 additions & 0 deletions src/common/crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# standard imports
import os

Check warning on line 2 in src/common/crypto.py

View check run for this annotation

Codecov / codecov/patch

src/common/crypto.py#L2

Added line #L2 was not covered by tests

# 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

Check warning on line 10 in src/common/crypto.py

View check run for this annotation

Codecov / codecov/patch

src/common/crypto.py#L5-L10

Added lines #L5 - L10 were not covered by tests

# local imports
from src.common import common

Check warning on line 13 in src/common/crypto.py

View check run for this annotation

Codecov / codecov/patch

src/common/crypto.py#L13

Added line #L13 was not covered by tests

CERT_FILE = os.path.join(common.data_dir, "cert.pem")
KEY_FILE = os.path.join(common.data_dir, "key.pem")

Check warning on line 16 in src/common/crypto.py

View check run for this annotation

Codecov / codecov/patch

src/common/crypto.py#L15-L16

Added lines #L15 - L16 were not covered by tests


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

Check warning on line 24 in src/common/crypto.py

View check run for this annotation

Codecov / codecov/patch

src/common/crypto.py#L19-L24

Added lines #L19 - L24 were not covered by tests


def generate_certificate():
private_key = rsa.generate_private_key(

Check warning on line 28 in src/common/crypto.py

View check run for this annotation

Codecov / codecov/patch

src/common/crypto.py#L27-L28

Added lines #L27 - L28 were not covered by tests
public_exponent=65537,
key_size=4096,
)
subject = issuer = x509.Name([

Check warning on line 32 in src/common/crypto.py

View check run for this annotation

Codecov / codecov/patch

src/common/crypto.py#L32

Added line #L32 was not covered by tests
x509.NameAttribute(x509.NameOID.COMMON_NAME, u"localhost"),
])
cert = x509.CertificateBuilder().subject_name(

Check warning on line 35 in src/common/crypto.py

View check run for this annotation

Codecov / codecov/patch

src/common/crypto.py#L35

Added line #L35 was not covered by tests
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(

Check warning on line 50 in src/common/crypto.py

View check run for this annotation

Codecov / codecov/patch

src/common/crypto.py#L49-L50

Added lines #L49 - L50 were not covered by tests
encoding=Encoding.PEM,
format=PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=NoEncryption(),
))

with open(CERT_FILE, "wb") as f:
f.write(cert.public_bytes(Encoding.PEM))

Check warning on line 57 in src/common/crypto.py

View check run for this annotation

Codecov / codecov/patch

src/common/crypto.py#L56-L57

Added lines #L56 - L57 were not covered by tests


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

Check warning on line 69 in src/common/crypto.py

View check run for this annotation

Codecov / codecov/patch

src/common/crypto.py#L60-L69

Added lines #L60 - L69 were not covered by tests
22 changes: 22 additions & 0 deletions src/common/database.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions src/common/globals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DISCORD_BOT = None
REDDIT_BOT = None
150 changes: 150 additions & 0 deletions src/common/webapp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# standard imports
import asyncio
import os
from threading import Thread
from typing import Tuple

Check warning on line 5 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L2-L5

Added lines #L2 - L5 were not covered by tests

# lib imports
import discord
from flask import Flask, jsonify, redirect, request, Response
from requests_oauthlib import OAuth2Session

Check warning on line 10 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L8-L10

Added lines #L8 - L10 were not covered by tests

# local imports
from src.common import crypto
from src.common import globals

Check warning on line 14 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L13-L14

Added lines #L13 - L14 were not covered by tests


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")

Check warning on line 19 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L17-L19

Added lines #L17 - L19 were not covered by tests

app = Flask('LizardByte-bot')

Check warning on line 21 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L21

Added line #L21 was not covered by tests


@app.route('/')
def main():
return "LizardByte-bot is live!"

Check warning on line 26 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L24-L26

Added lines #L24 - L26 were not covered by tests


@app.route("/discord/callback")
def discord_callback():

Check warning on line 30 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L29-L30

Added lines #L29 - L30 were not covered by tests
# get all active states from the global state manager
with globals.DISCORD_BOT.db as db:
active_states = db['oauth_states']

Check warning on line 33 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L32-L33

Added lines #L32 - L33 were not covered by tests

discord_oauth = OAuth2Session(DISCORD_CLIENT_ID, redirect_uri=DISCORD_REDIRECT_URI)
token = discord_oauth.fetch_token("https://discord.com/api/oauth2/token",

Check warning on line 36 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L35-L36

Added lines #L35 - L36 were not covered by tests
client_secret=DISCORD_CLIENT_SECRET,
authorization_response=request.url)

# Fetch the user's Discord profile
response = discord_oauth.get("https://discord.com/api/users/@me")
discord_user = response.json()

Check warning on line 42 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L41-L42

Added lines #L41 - L42 were not covered by tests

# if the user is not in the active states, return an error
if discord_user['id'] not in active_states:
return "Invalid state"

Check warning on line 46 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L45-L46

Added lines #L45 - L46 were not covered by tests

# remove the user from the active states
del active_states[discord_user['id']]

Check warning on line 49 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L49

Added line #L49 was not covered by tests

# Fetch the user's connected accounts
connections_response = discord_oauth.get("https://discord.com/api/users/@me/connections")
connections = connections_response.json()

Check warning on line 53 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L52-L53

Added lines #L52 - L53 were not covered by tests

with globals.DISCORD_BOT.db as db:
db['discord_users'] = db.get('discord_users', {})
db['discord_users'][discord_user['id']] = {

Check warning on line 57 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L55-L57

Added lines #L55 - L57 were not covered by tests
'discord_username': discord_user['username'],
'discord_global_name': discord_user['global_name'],
'github_id': None,
'github_username': None,
'token': token, # TODO: should we store the token at all?
}

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']

Check warning on line 68 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L65-L68

Added lines #L65 - L68 were not covered by tests

# Redirect to our main website
return redirect("https://app.lizardbyte.dev")

Check warning on line 71 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L71

Added line #L71 was not covered by tests


@app.route("/webhook/<source>", methods=["POST"])
def webhook(source: str) -> Tuple[Response, int]:

Check warning on line 75 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L74-L75

Added lines #L74 - L75 were not covered by tests
"""
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').
Returns
-------
flask.Response
Response to the webhook request
"""
valid_sources = ["github_sponsors", "github_status"]

Check warning on line 92 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L92

Added line #L92 was not covered by tests

if source not in valid_sources:
return jsonify({"status": "error", "message": "Invalid source"}), 400

Check warning on line 95 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L94-L95

Added lines #L94 - L95 were not covered by tests

print(f"received webhook from {source}")
data = request.json
print(f"received webhook data: \n{data}")

Check warning on line 99 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L97-L99

Added lines #L97 - L99 were not covered by tests

if source == "github_sponsors":

Check warning on line 101 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L101

Added line #L101 was not covered by tests
# ensure the secret matches
# if data['secret'] != os.getenv("GITHUB_SPONSORS_WEBHOOK_SECRET_KEY"):
# return jsonify({"status": "error", "message": "Invalid secret"}), 400

# process the webhook data
if data['action'] == "created":
message = f'New GitHub sponsor: {data["sponsorship"]["sponsor"]["login"]}'

Check warning on line 108 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L107-L108

Added lines #L107 - L108 were not covered by tests

# create a discord embed
embed = discord.Embed(

Check warning on line 111 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L111

Added line #L111 was not covered by tests
author=discord.EmbedAuthor(
name=data["sponsorship"]["sponsor"]["login"],
url=data["sponsorship"]["sponsor"]["url"],
icon_url=data["sponsorship"]["sponsor"]["avatar_url"],
),
color=0x00ff00,
description=message,
footer=discord.EmbedFooter(
text=f"Sponsored at {data['sponsorship']['created_at']}",
),
title="New GitHub Sponsor",
)
message = asyncio.run_coroutine_threadsafe(

Check warning on line 124 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L124

Added line #L124 was not covered by tests
globals.DISCORD_BOT.send_message_to_channel(
channel_id=os.getenv("DISCORD_SPONSORS_CHANNEL_ID"),
embeds=[embed],
), globals.DISCORD_BOT.loop)
message.result() # wait for the message to be sent

Check warning on line 129 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L129

Added line #L129 was not covered by tests

return jsonify({"status": "success"}), 200

Check warning on line 131 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L131

Added line #L131 was not covered by tests


def run():
cert_file, key_file = crypto.initialize_certificate()

Check warning on line 135 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L134-L135

Added lines #L134 - L135 were not covered by tests

app.run(

Check warning on line 137 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L137

Added line #L137 was not covered by tests
host="0.0.0.0",
port=8080,
ssl_context=(cert_file, key_file)
)


def start():
server = Thread(

Check warning on line 145 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L144-L145

Added lines #L144 - L145 were not covered by tests
name="Flask",
daemon=True,
target=run,
)
server.start()

Check warning on line 150 in src/common/webapp.py

View check run for this annotation

Codecov / codecov/patch

src/common/webapp.py#L150

Added line #L150 was not covered by tests
Loading

0 comments on commit 0480459

Please sign in to comment.