├── .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 |
17 | {% for guild in guilds %} 18 | 19 |
20 | 21 |

{{ guild.name }}

22 |
23 |
24 | {% endfor %} 25 |
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 | --------------------------------------------------------------------------------