├── .gitignore
├── backend
├── __init__.py
├── database.py
└── api.py
├── requirements.txt
├── frontend
├── index.html
├── server.html
├── guilds.html
└── static
│ └── style.css
├── bot.py
├── README.md
└── main.py
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | .idea
3 | .vscode
4 | .env
5 | venv
6 | *.db
7 |
--------------------------------------------------------------------------------
/backend/__init__.py:
--------------------------------------------------------------------------------
1 | from .api import DiscordAuth
2 | from .database import db, feature_db
3 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | fastapi
2 | aiohttp
3 | jinja2
4 | uvicorn
5 | better-ipc
6 | ezcord
7 | aiosqlite
8 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Dashboard
7 |
8 |
9 |
10 | CodingKeks Dashboard
11 | Der Bot ist in {{count}} Servern
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/frontend/server.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Dashboard
7 |
8 |
9 |
10 | {{name}}
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | ID: {{id}}
19 | Member: {{count}}
20 |
21 |
22 |
23 |
24 |
{{ feature }}
25 |
26 |
27 |
--------------------------------------------------------------------------------
/frontend/guilds.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Dashboard
7 |
8 |
9 |
10 | CodingKeks Dashboard
11 | Hey {{global_name}}, bitte wähle einen Server aus.
12 |
13 |
14 |
15 |
16 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/frontend/static/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: #222727;
3 | color: #d8d8d8;
4 | text-align: center;
5 | font-family: Verdana, sans-serif;
6 | }
7 |
8 | strong {
9 | color: #ea461e;
10 | }
11 |
12 | .container {
13 | display: flex;
14 | justify-content: center;
15 | align-content: flex-start;
16 | flex-wrap: wrap;
17 | margin: 2rem 2% 3rem;
18 | }
19 |
20 | .item {
21 | margin: .85rem;
22 | width: 400px;
23 | min-width: 300px;
24 | height: 100px;
25 | border-radius: .75rem;
26 | font-size: 2rem;
27 | overflow: hidden;
28 | background-color: #3a4242;
29 | display: flex;
30 | align-items: center;
31 | }
32 |
33 | .item:hover {
34 | transform: scale(1.05);
35 | box-shadow: 0 .2rem .5rem rgba(0,0,0,0.3), 0 1rem .5rem rgba(0,0,0,0.22)
36 | }
37 |
38 | .title {
39 | margin-right: .7rem;
40 | height: 80%;
41 | font-size: 70%;
42 | display: flex;
43 | align-items: center;
44 | padding: 1rem;
45 | text-align: left;
46 | border-radius: 50%;
47 | }
48 |
49 | .button {
50 | background: #ea461e;
51 | border: 0;
52 | color: white;
53 | padding: 12px 48px;
54 | font-size: 18px;
55 | margin-top: 8px;
56 | border-radius: 8px;
57 | cursor: pointer;
58 | }
59 |
60 | .link {
61 | text-decoration: none;
62 | color: inherit;
63 | display: inline-block;
64 | }
65 |
--------------------------------------------------------------------------------
/bot.py:
--------------------------------------------------------------------------------
1 | import discord
2 | import ezcord
3 | from discord.ext.ipc import ClientPayload, Server
4 |
5 |
6 | class Bot(ezcord.Bot):
7 | def __init__(self):
8 | intents = discord.Intents.default()
9 | intents.members = True
10 |
11 | super().__init__(intents=intents)
12 | self.ipc = Server(self, secret_key="keks")
13 |
14 | async def on_ready(self):
15 | await self.ipc.start()
16 | print(f"{self.user} ist online")
17 |
18 | @Server.route()
19 | async def guild_count(self, _):
20 | return str(len(self.guilds))
21 |
22 | @Server.route()
23 | async def bot_guilds(self, _):
24 | guild_ids = [str(guild.id) for guild in self.guilds]
25 | return {"data": guild_ids}
26 |
27 | @Server.route()
28 | async def guild_stats(self, data: ClientPayload):
29 | guild = self.get_guild(data.guild_id)
30 | if not guild:
31 | return {
32 | "member_count": 69,
33 | "name": "Unbekannt"
34 | }
35 |
36 | return {
37 | "member_count": guild.member_count,
38 | "name": guild.name,
39 | }
40 |
41 | @Server.route()
42 | async def check_perms(self, data: ClientPayload):
43 | guild = self.get_guild(data.guild_id)
44 | if not guild:
45 | return {"perms": False}
46 |
47 | member = guild.get_member(int(data.user_id))
48 | if not member or not member.guild_permissions.administrator:
49 | return {"perms": False}
50 |
51 | return {"perms": True}
52 |
53 | async def on_ipc_error(self, endpoint: str, exc: Exception) -> None:
54 | raise exc
55 |
56 |
57 | bot = Bot()
58 | bot.run("TOKEN") # Hier den Bot-Token einfügen
59 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Discord Bot Dashboard Tutorial
2 | Hier findest du den Code zum Dashboard Tutorial. Für dieses Tutorial benutzen wir [FastAPI](https://fastapi.tiangolo.com/).
3 |
4 | ## Infos
5 | Das Dashboard kann mit einer beliebigen Discord-Library verwendet werden
6 | ([Pycord](https://github.com/Pycord-Development/pycord),
7 | [Discord.py](https://github.com/Rapptz/discord.py),
8 | [Nextcord](https://github.com/nextcord/nextcord),
9 | ...)
10 |
11 | 1. Nachdem du eine Discord Library installiert hast, installiere alle Packages aus `requirements.txt`
12 | ```
13 | pip install -r requirements.txt
14 | ```
15 | 2. Füge einen Redirect im [Discord Developer Portal](https://discord.com/developers/applications) hinzu
16 | ```
17 | http://localhost:8000/callback
18 | ```
19 | 3. Aktiviere den **Member Intent** im Developer Portal
20 | 4. Füge die Daten aus dem Developer Portal in `main.py` ein
21 | 5. Starte die Dashboard-API in `main.py` und den Bot in `bot.py`
22 |
23 | ## VPS Hosting
24 | Das Dashboard kann zum Beispiel auf einem VPS gehostet werden. Hier ist eine kleine Übersicht für Ubuntu.
25 |
26 | **Wichtig:** Nicht vergessen den Redirect im Dev Portal und im Code anzupassen.
27 | Dort steht dann nicht mehr `localhost`, sondern eure IP-Adresse oder eure Domain.
28 |
29 | Folgende Befehle werden auf dem VPS ausgeführt:
30 | 1. Packages aktualisieren: `apt update`
31 | 2. Pip und Tmux installieren: `apt install python3-pip tmux`
32 | 3. Requirements installieren: `pip install -r requirements.txt`
33 | 4. `bot.py` und `main.py` jeweils in einer eigenen Tmux-Session starten
34 | 5. Nginx-Konfiguration anpassen: `/etc/nginx/sites-available/`
35 | ```nginx
36 | server {
37 | listen 80;
38 | server_name _; # IP-Adresse oder Domain eintragen
39 |
40 | location / {
41 | proxy_pass http://127.0.0.1:8000;
42 | include /etc/nginx/proxy_params;
43 | proxy_redirect off;
44 | }
45 |
46 | location /static {
47 | alias /home/dashboard/frontend/static;
48 | }
49 | }
50 | ```
51 | 6. Nginx neustarten: `sudo systemctl restart nginx`
52 |
--------------------------------------------------------------------------------
/backend/database.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from datetime import datetime, timedelta
3 |
4 | import ezcord
5 |
6 |
7 | class DashboardDB(ezcord.DBHandler):
8 | def __init__(self):
9 | super().__init__("dashboard.db")
10 |
11 | async def setup(self):
12 | await self.exec(
13 | """CREATE TABLE IF NOT EXISTS sessions (
14 | session_id TEXT UNIQUE,
15 | token TEXT,
16 | refresh_token TEXT,
17 | token_expires_at TIMESTAMP,
18 | user_id INTEGER PRIMARY KEY
19 | )"""
20 | )
21 |
22 | async def add_session(self, token, refresh_token, expires_in, user_id):
23 | session_id = str(uuid.uuid4())
24 | expires = datetime.now() + timedelta(seconds=expires_in)
25 |
26 | await self.exec(
27 | """INSERT OR REPLACE INTO sessions (session_id, token, refresh_token, token_expires_at, user_id)
28 | VALUES (?, ?, ?, ?, ?)""",
29 | (session_id, token, refresh_token, expires, user_id),
30 | )
31 | return session_id
32 |
33 | async def get_session(self, session_id):
34 | return await self.one(
35 | "SELECT token, refresh_token, token_expires_at FROM sessions WHERE session_id = ?",
36 | session_id,
37 | detect_types=1,
38 | )
39 |
40 | async def get_user_id(self, session_id):
41 | return await self.one("SELECT user_id FROM sessions WHERE session_id=?", session_id)
42 |
43 | async def update_session(self, session_id, token, refresh_token, token_expires_at):
44 | await self.exec(
45 | "UPDATE sessions SET token = ?, refresh_token = ?, token_expires_at = ? WHERE session_id = ?",
46 | (token, refresh_token, token_expires_at, session_id),
47 | detect_types=1,
48 | )
49 |
50 | async def delete_session(self, session_id):
51 | await self.exec("DELETE FROM sessions WHERE session_id = ?", session_id)
52 |
53 |
54 | class FeatureDB(ezcord.DBHandler):
55 | def __init__(self):
56 | super().__init__("dashboard.db")
57 |
58 | async def setup(self):
59 | await self.exec(
60 | """CREATE TABLE IF NOT EXISTS settings (
61 | guild_id INTEGER PRIMARY KEY,
62 | example_feature INTEGER DEFAULT 0
63 | )"""
64 | )
65 |
66 | async def get_setting(self, guild_id, feature):
67 | return await self.one(f"SELECT {feature} FROM settings WHERE guild_id=?", guild_id)
68 |
69 | async def toggle_setting(self, guild_id, feature):
70 | await self.exec("INSERT OR IGNORE INTO settings (guild_id) VALUES (?)", guild_id)
71 | await self.exec(
72 | f"UPDATE settings SET {feature} = NOT {feature} WHERE guild_id = ?", guild_id
73 | )
74 |
75 |
76 | db = DashboardDB()
77 | feature_db = FeatureDB()
78 |
--------------------------------------------------------------------------------
/backend/api.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 |
3 | import aiohttp
4 | from fastapi import HTTPException
5 |
6 | from .database import db
7 |
8 | API_ENDPOINT = "https://discord.com/api"
9 |
10 |
11 | class DiscordAuth:
12 | client_id: str
13 | client_secret: str
14 | redirect_uri: str
15 | session: aiohttp.ClientSession | None
16 |
17 | def __init__(self, client_id, client_secret, redirect_uri):
18 | self.client_id = client_id
19 | self.client_secret = client_secret
20 | self.redirect_uri = redirect_uri
21 |
22 | self.auth = aiohttp.BasicAuth(str(client_id), client_secret)
23 |
24 | async def setup(self):
25 | self.session = aiohttp.ClientSession()
26 |
27 | async def close(self):
28 | await self.session.close()
29 |
30 | async def get_user(self, token):
31 | headers = {"Authorization": f"Bearer {token}"}
32 | async with self.session.get(API_ENDPOINT + "/users/@me", headers=headers) as response:
33 | return await response.json()
34 |
35 | async def get_guilds(self, token):
36 | headers = {"Authorization": f"Bearer {token}"}
37 | async with self.session.get(
38 | API_ENDPOINT + "/users/@me/guilds", headers=headers
39 | ) as response:
40 | if response.status == 429:
41 | raise HTTPException(status_code=429)
42 | return await response.json()
43 |
44 | async def get_token_response(self, data):
45 | response = await self.session.post(API_ENDPOINT + "/oauth2/token", data=data)
46 | json_response = await response.json()
47 |
48 | access_token = json_response.get("access_token")
49 | refresh_token = json_response.get("refresh_token")
50 | expires_in = json_response.get("expires_in")
51 |
52 | if not access_token or not refresh_token:
53 | return None
54 |
55 | return access_token, refresh_token, expires_in
56 |
57 | async def revoke_token(self, token):
58 | async with self.session.post(
59 | API_ENDPOINT + "/oauth2/token/revoke",
60 | headers={"Content-Type": "application/x-www-form-urlencoded"},
61 | data={"token": token},
62 | auth=self.auth,
63 | ) as response:
64 | response.raise_for_status()
65 |
66 | async def reload(self, session_id, refresh_token):
67 | data = {
68 | "client_id": self.client_id,
69 | "client_secret": self.client_secret,
70 | "grant_type": "refresh_token",
71 | "refresh_token": refresh_token,
72 | }
73 | response = await self.get_token_response(data)
74 | if not response:
75 | return False
76 |
77 | new_token, new_refresh_token, expires_in = response
78 | expire_dt = datetime.now() + timedelta(seconds=expires_in)
79 |
80 | await db.update_session(session_id, new_token, new_refresh_token, expire_dt)
81 | return True
82 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | import discord
4 | import ezcord
5 | import uvicorn
6 | from discord.ext.ipc import Client
7 | from fastapi import Cookie, FastAPI, HTTPException, Request
8 | from fastapi.responses import RedirectResponse
9 | from fastapi.staticfiles import StaticFiles
10 | from starlette.templating import Jinja2Templates
11 | from contextlib import asynccontextmanager
12 | from backend import DiscordAuth, db, feature_db
13 |
14 | # Hier die Daten aus dem Developer-Portal einfügen
15 | CLIENT_ID = 123456789
16 | CLIENT_SECRET = ""
17 | REDIRECT_URI = "http://localhost:8000/callback"
18 | LOGIN_URL = ""
19 | INVITE_LINK = ""
20 |
21 | @asynccontextmanager
22 | async def on_startup(app: FastAPI):
23 | await api.setup()
24 | await db.setup()
25 | await feature_db.setup()
26 |
27 | yield
28 |
29 | await api.close()
30 | # Hier kann noch selbst eine Methode, die je nach Datenbank variiert, hinzugefügt werden, um die Datenbank zu "schließen"
31 |
32 | app = FastAPI(lifespan=on_startup)
33 | app.mount("/static", StaticFiles(directory="frontend/static"), name="static")
34 | templates = Jinja2Templates(directory="frontend")
35 |
36 | ipc = Client(secret_key="keks")
37 | api = DiscordAuth(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI)
38 |
39 |
40 | @app.get("/")
41 | async def home(request: Request):
42 | session_id = request.cookies.get("session_id")
43 | if session_id and await db.get_session(session_id):
44 | return RedirectResponse(url="/guilds")
45 |
46 | guild_count = await ipc.request("guild_count")
47 | return templates.TemplateResponse(
48 | "index.html",
49 | {
50 | "request": request,
51 | "count": guild_count.response,
52 | "login_url": LOGIN_URL
53 | }
54 | )
55 |
56 |
57 | @app.get("/callback")
58 | async def callback(code: str):
59 | data = {
60 | "client_id": CLIENT_ID,
61 | "client_secret": CLIENT_SECRET,
62 | "grant_type": "authorization_code",
63 | "code": code,
64 | "redirect_uri": REDIRECT_URI,
65 | }
66 |
67 | result = await api.get_token_response(data)
68 | if result is None:
69 | raise HTTPException(status_code=401, detail="Invalid Auth Code")
70 |
71 | token, refresh_token, expires_in = result
72 | user = await api.get_user(token)
73 | user_id = user.get("id")
74 |
75 | session_id = await db.add_session(token, refresh_token, expires_in, user_id)
76 |
77 | response = RedirectResponse(url="/guilds")
78 | response.set_cookie(key="session_id", value=session_id, httponly=True)
79 | return response
80 |
81 |
82 | @app.get("/guilds")
83 | async def guilds(request: Request):
84 | session_id = request.cookies.get("session_id")
85 | session = await db.get_session(session_id)
86 | if not session_id or not session:
87 | raise HTTPException(status_code=401, detail="no auth")
88 |
89 | token, refresh_token, token_expires_at = session
90 |
91 | user = await api.get_user(token)
92 | if datetime.now() > token_expires_at or user.get("code") == 0:
93 | if await api.reload(session_id, refresh_token):
94 | RedirectResponse(url="/guilds")
95 | else:
96 | RedirectResponse(url="/logout")
97 |
98 | if "id" not in user:
99 | return RedirectResponse(url="/logout")
100 |
101 | user_guilds = await api.get_guilds(token)
102 | bot_guilds = await ipc.request("bot_guilds")
103 | perms = []
104 |
105 | for guild in user_guilds:
106 | if guild["id"] in bot_guilds.response["data"]:
107 | guild["url"] = "/server/" + str(guild["id"])
108 | else:
109 | guild["url"] = INVITE_LINK + f"&guild_id={guild['id']}"
110 |
111 | if guild["icon"]:
112 | guild["icon"] = "https://cdn.discordapp.com/icons/" + guild["id"] + "/" + guild["icon"]
113 | else:
114 | guild["icon"] = ezcord.random_avatar()
115 |
116 | is_admin = discord.Permissions(int(guild["permissions"])).administrator
117 | if is_admin or guild["owner"]:
118 | perms.append(guild)
119 |
120 | return templates.TemplateResponse(
121 | "guilds.html",
122 | {
123 | "request": request,
124 | "global_name": user["global_name"],
125 | "guilds": perms
126 | }
127 | )
128 |
129 |
130 | @app.get("/server/{guild_id}")
131 | async def server(request: Request, guild_id: int):
132 | session_id = request.cookies.get("session_id")
133 | if not session_id or not await db.get_session(session_id):
134 | raise HTTPException(status_code=401, detail="no auth")
135 |
136 | stats = await ipc.request("guild_stats", guild_id=guild_id)
137 | setting = await feature_db.get_setting(guild_id, "example_feature")
138 | if setting:
139 | feature_txt = "Das Feature ist aktiviert"
140 | else:
141 | feature_txt = "Das Feature ist deaktiviert"
142 |
143 | return templates.TemplateResponse(
144 | "server.html",
145 | {
146 | "request": request,
147 | "name": stats.response["name"],
148 | "count": stats.response["member_count"],
149 | "id": guild_id,
150 | "feature": feature_txt,
151 | },
152 | )
153 |
154 |
155 | @app.get("/server/{guild_id}/settings/{feature}")
156 | async def change_settings(guild_id: int, feature: str, session_id: str = Cookie(None)):
157 | user_id = await db.get_user_id(session_id)
158 | if not session_id or not user_id:
159 | raise HTTPException(status_code=401, detail="no auth")
160 |
161 | perms = await ipc.request("check_perms", guild_id=guild_id, user_id=user_id)
162 |
163 | if perms.response["perms"]:
164 | await feature_db.toggle_setting(guild_id, feature)
165 | return RedirectResponse(url="/server/" + str(guild_id))
166 | else:
167 | return {"error": "Du hast keinen Zugriff auf diesen Server"}
168 |
169 |
170 | @app.get("/logout")
171 | async def logout(session_id: str = Cookie(None)):
172 | session = await db.get_session(session_id)
173 | if not session_id or not session:
174 | raise HTTPException(status_code=401, detail="no auth")
175 |
176 | token, _, _ = session
177 |
178 | response = RedirectResponse("/")
179 | response.delete_cookie(key="session_id", httponly=True)
180 |
181 | await db.delete_session(session_id)
182 | await api.revoke_token(token)
183 |
184 | return response
185 |
186 |
187 | @app.exception_handler(404)
188 | async def error_redirect(_, __):
189 | return RedirectResponse("/")
190 |
191 |
192 | if __name__ == "__main__":
193 | uvicorn.run(app, host="localhost", port=8000)
194 | # uvicorn.run("main:app", host="localhost", port=8000, reload=True)
195 |
--------------------------------------------------------------------------------