Skip to content

Commit 0480459

Browse files
feat(github): add github integration
1 parent 362aef2 commit 0480459

21 files changed

+465
-98
lines changed

README.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
[![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)
33
[![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)
44

5-
Support bot written in python to help manage LizardByte communities. The current focus is discord and reddit, but other
6-
platforms such as GitHub discussions/issues could be added.
5+
Support bot written in python to help manage LizardByte communities. The current focus is Discord and Reddit, but other
6+
platforms such as GitHub discussions/issues might be added in the future.
77

88

99
## Overview
@@ -31,6 +31,9 @@ platforms such as GitHub discussions/issues could be added.
3131
| variable | required | default | description |
3232
|-------------------------|----------|------------------------------------------------------|---------------------------------------------------------------|
3333
| DISCORD_BOT_TOKEN | True | `None` | Token from Bot page on discord developer portal. |
34+
| DISCORD_CLIENT_ID | True | `None` | Discord OAuth2 client id. |
35+
| DISCORD_CLIENT_SECRET | True | `None` | Discord OAuth2 client secret. |
36+
| DISCORD_REDIRECT_URI | False | `https://localhost:8080/discord/callback` | The redirect uri for OAuth2. Must be publicly accessible. |
3437
| DAILY_TASKS | False | `true` | Daily tasks on or off. |
3538
| DAILY_RELEASES | False | `true` | Send a message for each game released on this day in history. |
3639
| DAILY_CHANNEL_ID | False | `None` | Required if daily_tasks is enabled. |
@@ -41,11 +44,6 @@ platforms such as GitHub discussions/issues could be added.
4144
| SUPPORT_COMMANDS_REPO | False | `https://github.com/LizardByte/support-bot-commands` | Repository for support commands. |
4245
| SUPPORT_COMMANDS_BRANCH | False | `master` | Branch for support commands. |
4346

44-
* Running bot:
45-
* `python -m src`
46-
* Invite bot to server:
47-
* `https://discord.com/api/oauth2/authorize?client_id=<the client id of the bot>&permissions=8&scope=bot%20applications.commands`
48-
4947

5048
### Reddit
5149

@@ -62,7 +60,13 @@ platforms such as GitHub discussions/issues could be added.
6260
| DISCORD_WEBHOOK | False | None | URL of webhook to send discord notifications to |
6361
| GRAVATAR_EMAIL | False | None | Gravatar email address to get avatar from |
6462
| REDDIT_USERNAME | True | None | Reddit username |
65-
* | REDDIT_PASSWORD | True | None | Reddit password |
63+
| REDDIT_PASSWORD | True | None | Reddit password |
64+
65+
### Start
6666

67-
* Running bot:
68-
* `python -m src`
67+
```bash
68+
python -m src
69+
```
70+
71+
* Invite bot to server:
72+
* `https://discord.com/api/oauth2/authorize?client_id=<the client id of the bot>&permissions=8&scope=bot%20applications.commands`

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ praw==7.8.1
77
py-cord==2.6.1
88
python-dotenv==1.0.1
99
requests==2.32.3
10+
requests-oauthlib==2.0.0

src/__main__.py

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
# standard imports
2-
import os
32
import time
43

54
# development imports
@@ -8,33 +7,28 @@
87

98
# local imports
109
if True: # hack for flake8
10+
from src.common import globals
1111
from src.discord import bot as d_bot
12-
from src import keep_alive
12+
from src.common import webapp
1313
from src.reddit import bot as r_bot
1414

1515

1616
def main():
17-
# to run in replit
18-
try:
19-
os.environ['REPL_SLUG']
20-
except KeyError:
21-
pass # not running in replit
22-
else:
23-
keep_alive.keep_alive() # Start the web server
17+
webapp.start() # Start the web server
2418

25-
discord_bot = d_bot.Bot()
26-
discord_bot.start_threaded() # Start the discord bot
19+
globals.DISCORD_BOT = d_bot.Bot()
20+
globals.DISCORD_BOT.start_threaded() # Start the discord bot
2721

28-
reddit_bot = r_bot.Bot()
29-
reddit_bot.start_threaded() # Start the reddit bot
22+
globals.REDDIT_BOT = r_bot.Bot()
23+
globals.REDDIT_BOT.start_threaded() # Start the reddit bot
3024

3125
try:
32-
while discord_bot.bot_thread.is_alive() or reddit_bot.bot_thread.is_alive():
26+
while globals.DISCORD_BOT.bot_thread.is_alive() or globals.REDDIT_BOT.bot_thread.is_alive():
3327
time.sleep(0.5)
3428
except KeyboardInterrupt:
3529
print("Keyboard Interrupt Detected")
36-
discord_bot.stop()
37-
reddit_bot.stop()
30+
globals.DISCORD_BOT.stop()
31+
globals.REDDIT_BOT.stop()
3832

3933

4034
if __name__ == '__main__': # pragma: no cover

src/common/__init__.py

Whitespace-only changes.
File renamed without changes.

src/common/crypto.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# standard imports
2+
import os
3+
4+
# lib imports
5+
from cryptography import x509
6+
from cryptography.hazmat.backends import default_backend
7+
from cryptography.hazmat.primitives import hashes
8+
from cryptography.hazmat.primitives.asymmetric import rsa
9+
from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption
10+
from datetime import datetime, timedelta, UTC
11+
12+
# local imports
13+
from src.common import common
14+
15+
CERT_FILE = os.path.join(common.data_dir, "cert.pem")
16+
KEY_FILE = os.path.join(common.data_dir, "key.pem")
17+
18+
19+
def check_expiration(cert_path: str) -> int:
20+
with open(cert_path, "rb") as cert_file:
21+
cert_data = cert_file.read()
22+
cert = x509.load_pem_x509_certificate(cert_data, default_backend())
23+
expiry_date = cert.not_valid_after_utc
24+
return (expiry_date - datetime.now(UTC)).days
25+
26+
27+
def generate_certificate():
28+
private_key = rsa.generate_private_key(
29+
public_exponent=65537,
30+
key_size=4096,
31+
)
32+
subject = issuer = x509.Name([
33+
x509.NameAttribute(x509.NameOID.COMMON_NAME, u"localhost"),
34+
])
35+
cert = x509.CertificateBuilder().subject_name(
36+
subject
37+
).issuer_name(
38+
issuer
39+
).public_key(
40+
private_key.public_key()
41+
).serial_number(
42+
x509.random_serial_number()
43+
).not_valid_before(
44+
datetime.now(UTC)
45+
).not_valid_after(
46+
datetime.now(UTC) + timedelta(days=365)
47+
).sign(private_key, hashes.SHA256())
48+
49+
with open(KEY_FILE, "wb") as f:
50+
f.write(private_key.private_bytes(
51+
encoding=Encoding.PEM,
52+
format=PrivateFormat.TraditionalOpenSSL,
53+
encryption_algorithm=NoEncryption(),
54+
))
55+
56+
with open(CERT_FILE, "wb") as f:
57+
f.write(cert.public_bytes(Encoding.PEM))
58+
59+
60+
def initialize_certificate() -> tuple[str, str]:
61+
print("Initializing SSL certificate")
62+
if os.path.exists(CERT_FILE) and os.path.exists(KEY_FILE):
63+
cert_expires_in = check_expiration(CERT_FILE)
64+
print(f"Certificate expires in {cert_expires_in} days.")
65+
if cert_expires_in >= 90:
66+
return CERT_FILE, KEY_FILE
67+
print("Generating new certificate")
68+
generate_certificate()
69+
return CERT_FILE, KEY_FILE

src/common/database.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# standard imports
2+
import shelve
3+
import threading
4+
5+
6+
class Database:
7+
def __init__(self, db_path):
8+
self.db_path = db_path
9+
self.lock = threading.Lock()
10+
11+
def __enter__(self):
12+
self.lock.acquire()
13+
self.db = shelve.open(self.db_path, writeback=True)
14+
return self.db
15+
16+
def __exit__(self, exc_type, exc_val, exc_tb):
17+
self.sync()
18+
self.db.close()
19+
self.lock.release()
20+
21+
def sync(self):
22+
self.db.sync()

src/common/globals.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
DISCORD_BOT = None
2+
REDDIT_BOT = None

src/common/webapp.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# standard imports
2+
import asyncio
3+
import os
4+
from threading import Thread
5+
from typing import Tuple
6+
7+
# lib imports
8+
import discord
9+
from flask import Flask, jsonify, redirect, request, Response
10+
from requests_oauthlib import OAuth2Session
11+
12+
# local imports
13+
from src.common import crypto
14+
from src.common import globals
15+
16+
17+
DISCORD_CLIENT_ID = os.getenv("DISCORD_CLIENT_ID")
18+
DISCORD_CLIENT_SECRET = os.getenv("DISCORD_CLIENT_SECRET")
19+
DISCORD_REDIRECT_URI = os.getenv("DISCORD_REDIRECT_URI", "https://localhost:8080/discord/callback")
20+
21+
app = Flask('LizardByte-bot')
22+
23+
24+
@app.route('/')
25+
def main():
26+
return "LizardByte-bot is live!"
27+
28+
29+
@app.route("/discord/callback")
30+
def discord_callback():
31+
# get all active states from the global state manager
32+
with globals.DISCORD_BOT.db as db:
33+
active_states = db['oauth_states']
34+
35+
discord_oauth = OAuth2Session(DISCORD_CLIENT_ID, redirect_uri=DISCORD_REDIRECT_URI)
36+
token = discord_oauth.fetch_token("https://discord.com/api/oauth2/token",
37+
client_secret=DISCORD_CLIENT_SECRET,
38+
authorization_response=request.url)
39+
40+
# Fetch the user's Discord profile
41+
response = discord_oauth.get("https://discord.com/api/users/@me")
42+
discord_user = response.json()
43+
44+
# if the user is not in the active states, return an error
45+
if discord_user['id'] not in active_states:
46+
return "Invalid state"
47+
48+
# remove the user from the active states
49+
del active_states[discord_user['id']]
50+
51+
# Fetch the user's connected accounts
52+
connections_response = discord_oauth.get("https://discord.com/api/users/@me/connections")
53+
connections = connections_response.json()
54+
55+
with globals.DISCORD_BOT.db as db:
56+
db['discord_users'] = db.get('discord_users', {})
57+
db['discord_users'][discord_user['id']] = {
58+
'discord_username': discord_user['username'],
59+
'discord_global_name': discord_user['global_name'],
60+
'github_id': None,
61+
'github_username': None,
62+
'token': token, # TODO: should we store the token at all?
63+
}
64+
65+
for connection in connections:
66+
if connection['type'] == 'github':
67+
db['discord_users'][discord_user['id']]['github_id'] = connection['id']
68+
db['discord_users'][discord_user['id']]['github_username'] = connection['name']
69+
70+
# Redirect to our main website
71+
return redirect("https://app.lizardbyte.dev")
72+
73+
74+
@app.route("/webhook/<source>", methods=["POST"])
75+
def webhook(source: str) -> Tuple[Response, int]:
76+
"""
77+
Process webhooks from various sources.
78+
79+
* GitHub sponsors: https://github.com/sponsors/LizardByte/dashboard/webhooks
80+
* GitHub status: https://www.githubstatus.com
81+
82+
Parameters
83+
----------
84+
source : str
85+
The source of the webhook (e.g., 'github_sponsors', 'github_status').
86+
87+
Returns
88+
-------
89+
flask.Response
90+
Response to the webhook request
91+
"""
92+
valid_sources = ["github_sponsors", "github_status"]
93+
94+
if source not in valid_sources:
95+
return jsonify({"status": "error", "message": "Invalid source"}), 400
96+
97+
print(f"received webhook from {source}")
98+
data = request.json
99+
print(f"received webhook data: \n{data}")
100+
101+
if source == "github_sponsors":
102+
# ensure the secret matches
103+
# if data['secret'] != os.getenv("GITHUB_SPONSORS_WEBHOOK_SECRET_KEY"):
104+
# return jsonify({"status": "error", "message": "Invalid secret"}), 400
105+
106+
# process the webhook data
107+
if data['action'] == "created":
108+
message = f'New GitHub sponsor: {data["sponsorship"]["sponsor"]["login"]}'
109+
110+
# create a discord embed
111+
embed = discord.Embed(
112+
author=discord.EmbedAuthor(
113+
name=data["sponsorship"]["sponsor"]["login"],
114+
url=data["sponsorship"]["sponsor"]["url"],
115+
icon_url=data["sponsorship"]["sponsor"]["avatar_url"],
116+
),
117+
color=0x00ff00,
118+
description=message,
119+
footer=discord.EmbedFooter(
120+
text=f"Sponsored at {data['sponsorship']['created_at']}",
121+
),
122+
title="New GitHub Sponsor",
123+
)
124+
message = asyncio.run_coroutine_threadsafe(
125+
globals.DISCORD_BOT.send_message_to_channel(
126+
channel_id=os.getenv("DISCORD_SPONSORS_CHANNEL_ID"),
127+
embeds=[embed],
128+
), globals.DISCORD_BOT.loop)
129+
message.result() # wait for the message to be sent
130+
131+
return jsonify({"status": "success"}), 200
132+
133+
134+
def run():
135+
cert_file, key_file = crypto.initialize_certificate()
136+
137+
app.run(
138+
host="0.0.0.0",
139+
port=8080,
140+
ssl_context=(cert_file, key_file)
141+
)
142+
143+
144+
def start():
145+
server = Thread(
146+
name="Flask",
147+
daemon=True,
148+
target=run,
149+
)
150+
server.start()

0 commit comments

Comments
 (0)