complete overhaul
This commit is contained in:
parent
dfe360db45
commit
fd76a7703d
12 changed files with 262 additions and 270 deletions
0
modules/__init__.py
Normal file
0
modules/__init__.py
Normal file
82
modules/central.py
Normal file
82
modules/central.py
Normal file
|
@ -0,0 +1,82 @@
|
|||
import requests
|
||||
import os
|
||||
import modules.globalvars as gv
|
||||
from modules.translations import *
|
||||
from modules.markovmemory import get_file_info
|
||||
|
||||
def ping_server():
|
||||
if gv.ALIVEPING == "false":
|
||||
print(f"{gv.YELLOW}{get_translation(gv.LOCALE, 'pinging_disabled')}{RESET}")
|
||||
os.environ['gooberauthenticated'] = 'No'
|
||||
return
|
||||
goobres = requests.get(f"{gv.VERSION_URL}/alert")
|
||||
print(f"{get_translation(gv.LOCALE, 'goober_server_alert')}{goobres.text}")
|
||||
file_info = get_file_info(gv.MEMORY_FILE)
|
||||
payload = {
|
||||
"name": gv.NAME,
|
||||
"memory_file_info": file_info,
|
||||
"version": gv.local_version,
|
||||
"slash_commands": gv.slash_commands_enabled,
|
||||
"token": gv.gooberTOKEN
|
||||
}
|
||||
try:
|
||||
response = requests.post(gv.VERSION_URL+"/ping", json=payload)
|
||||
if response.status_code == 200:
|
||||
print(f"{gv.GREEN}{get_translation(gv.LOCALE, 'goober_ping_success').format(NAME=gv.NAME)}{RESET}")
|
||||
os.environ['gooberauthenticated'] = 'Yes'
|
||||
else:
|
||||
print(f"{RED}{get_translation(gv.LOCALE, 'goober_ping_fail')} {response.status_code}{RESET}")
|
||||
os.environ['gooberauthenticated'] = 'No'
|
||||
except Exception as e:
|
||||
print(f"{RED}{get_translation(gv.LOCALE, 'goober_ping_fail2')} {str(e)}{RESET}")
|
||||
os.environ['gooberauthenticated'] = 'No'
|
||||
|
||||
def is_name_available(NAME):
|
||||
if os.getenv("gooberTOKEN"):
|
||||
return
|
||||
try:
|
||||
response = requests.post(f"{gv.VERSION_URL}/check-if-available", json={"name": NAME}, headers={"Content-Type": "application/json"})
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("available", False)
|
||||
else:
|
||||
print(f"{get_translation(gv.LOCALE, 'name_check')}", response.json())
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"{get_translation(gv.LOCALE, 'name_check2')}", e)
|
||||
return False
|
||||
|
||||
def register_name(NAME):
|
||||
try:
|
||||
if gv.ALIVEPING == False:
|
||||
return
|
||||
# check if the name is avaliable
|
||||
if not is_name_available(NAME):
|
||||
if os.getenv("gooberTOKEN"):
|
||||
return
|
||||
print(f"{RED}{get_translation(gv.LOCALE, 'name_taken')}{RESET}")
|
||||
quit()
|
||||
|
||||
# if it is register it
|
||||
response = requests.post(f"{gv.VERSION_URL}/register", json={"name": NAME}, headers={"Content-Type": "application/json"})
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
token = data.get("token")
|
||||
|
||||
if not os.getenv("gooberTOKEN"):
|
||||
print(f"{gv.GREEN}{get_translation(gv.LOCALE, 'add_token').format(token=token)} gooberTOKEN=<token>.{gv.RESET}")
|
||||
quit()
|
||||
else:
|
||||
print(f"{gv.GREEN}{gv.RESET}")
|
||||
|
||||
return token
|
||||
else:
|
||||
print(f"{gv.RED}{get_translation(gv.LOCALE, 'token_exists').format()}{RESET}", response.json())
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"{gv.RED}{get_translation(gv.LOCALE, 'registration_error').format()}{RESET}", e)
|
||||
return None
|
||||
|
||||
register_name(gv.NAME)
|
43
modules/globalvars.py
Normal file
43
modules/globalvars.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
import os
|
||||
import platform
|
||||
from dotenv import load_dotenv
|
||||
import pathlib
|
||||
env_path = pathlib.Path(__file__).parent.parent / '.env'
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
|
||||
RED = "\033[31m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
DEBUG = "\033[1;30m"
|
||||
RESET = "\033[0m"
|
||||
VERSION_URL = "https://goober.expect.ovh"
|
||||
UPDATE_URL = VERSION_URL+"/latest_version.json"
|
||||
LOCAL_VERSION_FILE = "current_version.txt"
|
||||
TOKEN = os.getenv("DISCORD_BOT_TOKEN")
|
||||
PREFIX = os.getenv("BOT_PREFIX")
|
||||
hourlyspeak = int(os.getenv("hourlyspeak"))
|
||||
PING_LINE = os.getenv("PING_LINE")
|
||||
random_talk_channel_id1 = int(os.getenv("rnd_talk_channel1"))
|
||||
LOCALE = os.getenv("locale")
|
||||
gooberTOKEN = os.getenv("gooberTOKEN")
|
||||
random_talk_channel_id2 = int(os.getenv("rnd_talk_channel2"))
|
||||
cooldown_time = os.getenv("cooldown")
|
||||
splashtext = os.getenv("splashtext")
|
||||
ownerid = int(os.getenv("ownerid"))
|
||||
showmemenabled = os.getenv("showmemenabled")
|
||||
BLACKLISTED_USERS = os.getenv("BLACKLISTED_USERS", "").split(",")
|
||||
USERTRAIN_ENABLED = os.getenv("USERTRAIN_ENABLED", "true").lower() == "true"
|
||||
NAME = os.getenv("NAME")
|
||||
last_random_talk_time = 0
|
||||
MEMORY_FILE = "memory.json"
|
||||
DEFAULT_DATASET_FILE = "defaultdataset.json"
|
||||
MEMORY_LOADED_FILE = "MEMORY_LOADED"
|
||||
ALIVEPING = os.getenv("ALIVEPING")
|
||||
IGNOREWARNING = False
|
||||
song = os.getenv("song")
|
||||
arch = platform.machine()
|
||||
slash_commands_enabled = False
|
||||
latest_version = "0.0.0"
|
||||
local_version = "0.15.0"
|
||||
os.environ['gooberlocal_version'] = local_version
|
|
@ -1,42 +0,0 @@
|
|||
import discord
|
||||
from discord.ext import commands
|
||||
import os
|
||||
import requests
|
||||
import ast
|
||||
|
||||
def get_version_url(config_file_path):
|
||||
with open(config_file_path, "r") as file:
|
||||
file_content = file.read()
|
||||
tree = ast.parse(file_content)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "VERSION_URL":
|
||||
if isinstance(node.value, ast.Str):
|
||||
return node.value.s
|
||||
return None
|
||||
config_file_path = "config.py"
|
||||
VERSION_URL = get_version_url(config_file_path)
|
||||
|
||||
|
||||
class grabTemplate(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
def download_json():
|
||||
response = requests.get(f"{VERSION_URL}/goob/template.json")
|
||||
if response.status_code == 200:
|
||||
if os.path.exists("memory.json"):
|
||||
return
|
||||
else:
|
||||
userinput = input("Do you want to download the template json instead of starting from scratch?\n(Y/N)\n")
|
||||
if userinput.lower() == "y":
|
||||
with open("memory.json", "w", encoding="utf-8") as file:
|
||||
file.write(response.text)
|
||||
else:
|
||||
print("Starting from scratch...")
|
||||
elif response.status_code == 404:
|
||||
print("File not found on goober central!!")
|
||||
download_json()
|
||||
async def setup(bot):
|
||||
await bot.add_cog(grabTemplate(bot))
|
64
modules/markovmemory.py
Normal file
64
modules/markovmemory.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
import os
|
||||
import json
|
||||
import markovify
|
||||
import pickle
|
||||
from modules.globalvars import *
|
||||
from modules.translations import *
|
||||
def get_file_info(file_path):
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
with open(file_path, "r") as f:
|
||||
lines = f.readlines()
|
||||
return {"file_size_bytes": file_size, "line_count": len(lines)}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def load_memory():
|
||||
data = []
|
||||
|
||||
# load data from MEMORY_FILE
|
||||
try:
|
||||
with open(MEMORY_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
if not os.path.exists(MEMORY_LOADED_FILE):
|
||||
try:
|
||||
with open(DEFAULT_DATASET_FILE, "r") as f:
|
||||
default_data = json.load(f)
|
||||
data.extend(default_data)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
with open(MEMORY_LOADED_FILE, "w") as f:
|
||||
f.write("Data loaded")
|
||||
return data
|
||||
|
||||
def save_memory(memory):
|
||||
with open(MEMORY_FILE, "w") as f:
|
||||
json.dump(memory, f, indent=4)
|
||||
|
||||
def train_markov_model(memory, additional_data=None):
|
||||
if not memory:
|
||||
return None
|
||||
text = "\n".join(memory)
|
||||
if additional_data:
|
||||
text += "\n" + "\n".join(additional_data)
|
||||
model = markovify.NewlineText(text, state_size=2)
|
||||
return model
|
||||
|
||||
def save_markov_model(model, filename='markov_model.pkl'):
|
||||
with open(filename, 'wb') as f:
|
||||
pickle.dump(model, f)
|
||||
print(f"Markov model saved to {filename}.")
|
||||
|
||||
def load_markov_model(filename='markov_model.pkl'):
|
||||
|
||||
try:
|
||||
with open(filename, 'rb') as f:
|
||||
model = pickle.load(f)
|
||||
print(f"{GREEN}{get_translation(LOCALE, 'model_loaded')} {filename}.{RESET}")
|
||||
return model
|
||||
except FileNotFoundError:
|
||||
print(f"{RED}{filename} {get_translation(LOCALE, 'not_found')}{RESET}")
|
||||
return None
|
22
modules/translations.py
Normal file
22
modules/translations.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
import os
|
||||
import json
|
||||
import pathlib
|
||||
from modules.globalvars import RED, RESET
|
||||
def load_translations():
|
||||
translations = {}
|
||||
translations_dir = pathlib.Path(__file__).parent.parent / 'locales'
|
||||
for filename in os.listdir(translations_dir):
|
||||
if filename.endswith(".json"):
|
||||
lang_code = filename.replace(".json", "")
|
||||
with open(os.path.join(translations_dir, filename), "r", encoding="utf-8") as f:
|
||||
translations[lang_code] = json.load(f)
|
||||
|
||||
return translations
|
||||
|
||||
translations = load_translations()
|
||||
|
||||
def get_translation(lang: str, key: str):
|
||||
lang_translations = translations.get(lang, translations["en"])
|
||||
if key not in lang_translations:
|
||||
print(f"{RED}Missing key: {key} in language {lang}{RESET}")
|
||||
return lang_translations.get(key, key)
|
67
modules/version.py
Normal file
67
modules/version.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
import hashlib
|
||||
from modules.translations import *
|
||||
from modules.globalvars import *
|
||||
import traceback
|
||||
import requests
|
||||
|
||||
def generate_sha256_of_current_file():
|
||||
global currenthash
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(__file__, "rb") as f:
|
||||
for byte_block in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
currenthash = sha256_hash.hexdigest()
|
||||
|
||||
def get_latest_version_info():
|
||||
|
||||
try:
|
||||
|
||||
response = requests.get(UPDATE_URL, timeout=5)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
print(f"{RED}{get_translation(LOCALE, 'version_error')} {response.status_code}{RESET}")
|
||||
return None
|
||||
except requests.RequestException as e:
|
||||
print(f"{RED}{get_translation(LOCALE, 'version_error')} {e}{RESET}")
|
||||
return None
|
||||
|
||||
def check_for_update():
|
||||
if ALIVEPING == "false":
|
||||
return
|
||||
global latest_version, local_version
|
||||
|
||||
latest_version_info = get_latest_version_info()
|
||||
if not latest_version_info:
|
||||
print(f"{get_translation(LOCALE, 'fetch_update_fail')}")
|
||||
return None, None
|
||||
|
||||
latest_version = latest_version_info.get("version")
|
||||
os.environ['gooberlatest_version'] = latest_version
|
||||
download_url = latest_version_info.get("download_url")
|
||||
|
||||
if not latest_version or not download_url:
|
||||
print(f"{RED}{get_translation(LOCALE, 'invalid_server')}{RESET}")
|
||||
return None, None
|
||||
|
||||
if local_version == "0.0.0" or None:
|
||||
print(f"{RED}I cant find the local_version variable! Or its been tampered with and its not an interger!{RESET}")
|
||||
return
|
||||
|
||||
generate_sha256_of_current_file()
|
||||
gooberhash = latest_version_info.get("hash")
|
||||
if local_version < latest_version:
|
||||
print(f"{YELLOW}{get_translation(LOCALE, 'new_version').format(latest_version=latest_version, local_version=local_version)}{RESET}")
|
||||
print(f"{YELLOW}{get_translation(LOCALE, 'changelog').format(VERSION_URL=VERSION_URL)}{RESET}")
|
||||
|
||||
elif local_version == latest_version:
|
||||
print(f"{GREEN}{get_translation(LOCALE, 'latest_version')} {local_version}{RESET}")
|
||||
print(f"{get_translation(LOCALE, 'latest_version2').format(VERSION_URL=VERSION_URL)}\n\n")
|
||||
|
||||
# finally fucking fixed this i tell you
|
||||
if gooberhash != currenthash:
|
||||
print(f"{YELLOW}{get_translation(LOCALE, 'modification_warning')}{RESET}")
|
||||
print(f"{YELLOW}{get_translation(LOCALE, 'reported_version')} {local_version}{RESET}")
|
||||
print(f"{DEBUG}{get_translation(LOCALE, 'current_hash')} {currenthash}{RESET}")
|
||||
print(f"{DEBUG}{get_translation(LOCALE, 'current_hash')} {currenthash}{RESET}")
|
||||
|
|
@ -1,880 +0,0 @@
|
|||
import discord
|
||||
from discord.ext import commands, tasks
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
import psutil
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
import time
|
||||
import aiohttp
|
||||
import re
|
||||
from aiohttp import WSMsgType
|
||||
from config import VERSION_URL
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
class GooberWeb(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.app = web.Application()
|
||||
self.runner = None
|
||||
self.site = None
|
||||
self.last_command = "No commands executed yet"
|
||||
self.last_command_time = "Never"
|
||||
self.start_time = time.time()
|
||||
self.websockets = set()
|
||||
|
||||
self.app.add_routes([
|
||||
web.get('/', self.handle_index),
|
||||
web.get('/changesong', self.handle_changesong),
|
||||
web.get('/stats', self.handle_stats),
|
||||
web.get('/data', self.handle_json_data),
|
||||
web.get('/ws', self.handle_websocket),
|
||||
web.get('/styles.css', self.handle_css),
|
||||
web.get('/settings', self.handle_settings),
|
||||
web.post('/update_settings', self.handle_update_settings),
|
||||
web.post('/restart_bot', self.handle_restart_bot),
|
||||
])
|
||||
|
||||
self.bot.loop.create_task(self.start_web_server())
|
||||
self.update_clients.start()
|
||||
|
||||
async def restart_bot(self):
|
||||
await asyncio.sleep(1)
|
||||
python = sys.executable
|
||||
os.execl(python, python, *sys.argv)
|
||||
|
||||
async def handle_restart_bot(self, request):
|
||||
asyncio.create_task(self.restart_bot())
|
||||
return web.Response(text="Bot is restarting...")
|
||||
|
||||
async def get_blacklisted_users(self):
|
||||
blacklisted_ids = os.getenv("BLACKLISTED_USERS", "").split(",")
|
||||
blacklisted_users = []
|
||||
|
||||
for user_id in blacklisted_ids:
|
||||
if not user_id.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
user = await self.bot.fetch_user(int(user_id))
|
||||
blacklisted_users.append({
|
||||
"name": f"{user.name}#{user.discriminator}",
|
||||
"avatar_url": str(user.avatar.url) if user.avatar else str(user.default_avatar.url),
|
||||
"id": user.id
|
||||
})
|
||||
except discord.NotFound:
|
||||
blacklisted_users.append({
|
||||
"name": f"Unknown User ({user_id})",
|
||||
"avatar_url": "",
|
||||
"id": user_id
|
||||
})
|
||||
except discord.HTTPException as e:
|
||||
print(f"Error fetching user {user_id}: {e}")
|
||||
continue
|
||||
|
||||
return blacklisted_users
|
||||
|
||||
async def get_enhanced_guild_info(self):
|
||||
guilds = sorted(self.bot.guilds, key=lambda g: g.member_count, reverse=True)
|
||||
guild_info = []
|
||||
|
||||
for guild in guilds:
|
||||
icon_url = str(guild.icon.url) if guild.icon else ""
|
||||
guild_info.append({
|
||||
"name": guild.name,
|
||||
"member_count": guild.member_count,
|
||||
"icon_url": icon_url,
|
||||
"id": guild.id
|
||||
})
|
||||
|
||||
return guild_info
|
||||
|
||||
async def start_web_server(self):
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
self.site = web.TCPSite(self.runner, '0.0.0.0', 8080)
|
||||
await self.site.start()
|
||||
print("Goober web server started on port 8080")
|
||||
|
||||
async def stop_web_server(self):
|
||||
await self.site.stop()
|
||||
await self.runner.cleanup()
|
||||
print("Web server stopped")
|
||||
|
||||
def cog_unload(self):
|
||||
self.update_clients.cancel()
|
||||
self.bot.loop.create_task(self.stop_web_server())
|
||||
|
||||
@tasks.loop(seconds=5)
|
||||
async def update_clients(self):
|
||||
if not self.websockets:
|
||||
return
|
||||
|
||||
stats = await self.get_bot_stats()
|
||||
message = json.dumps(stats)
|
||||
|
||||
for ws in set(self.websockets):
|
||||
try:
|
||||
await ws.send_str(message)
|
||||
except ConnectionResetError:
|
||||
self.websockets.remove(ws)
|
||||
except Exception as e:
|
||||
print(f"Error sending to websocket: {e}")
|
||||
self.websockets.remove(ws)
|
||||
|
||||
async def handle_websocket(self, request):
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
self.websockets.add(ws)
|
||||
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == WSMsgType.ERROR:
|
||||
print(f"WebSocket error: {ws.exception()}")
|
||||
finally:
|
||||
self.websockets.remove(ws)
|
||||
|
||||
return ws
|
||||
|
||||
async def handle_css(self, request):
|
||||
css_path = os.path.join(os.path.dirname(__file__), 'styles.css')
|
||||
if os.path.exists(css_path):
|
||||
return web.FileResponse(css_path)
|
||||
return web.Response(text="CSS file not found", status=404)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message):
|
||||
if message.author.bot:
|
||||
return
|
||||
|
||||
ctx = await self.bot.get_context(message)
|
||||
if ctx.valid and ctx.command:
|
||||
self._update_command_stats(ctx.command.name, ctx.author)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_app_command_completion(self, interaction, command):
|
||||
self._update_command_stats(command.name, interaction.user)
|
||||
|
||||
def _update_command_stats(self, command_name, user):
|
||||
self.last_command = f"{command_name} (by {user.name}#{user.discriminator})"
|
||||
self.last_command_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if self.websockets:
|
||||
asyncio.create_task(self.update_clients())
|
||||
|
||||
async def get_bot_stats(self):
|
||||
process = psutil.Process(os.getpid())
|
||||
mem_info = process.memory_full_info()
|
||||
cpu_percent = psutil.cpu_percent()
|
||||
process_cpu = process.cpu_percent()
|
||||
|
||||
memory_json_size = "N/A"
|
||||
if os.path.exists("memory.json"):
|
||||
memory_json_size = f"{os.path.getsize('memory.json') / 1024:.2f} KB"
|
||||
|
||||
guild_info = await self.get_enhanced_guild_info()
|
||||
blacklisted_users = await self.get_blacklisted_users()
|
||||
|
||||
uptime_seconds = int(time.time() - self.start_time)
|
||||
uptime_str = f"{uptime_seconds // 86400}d {(uptime_seconds % 86400) // 3600}h {(uptime_seconds % 3600) // 60}m {uptime_seconds % 60}s"
|
||||
|
||||
return {
|
||||
"ram_usage": f"{mem_info.rss / 1024 / 1024:.2f} MB",
|
||||
"cpu_usage": f"{process_cpu}%",
|
||||
"system_cpu": f"{cpu_percent}%",
|
||||
"memory_json_size": memory_json_size,
|
||||
"guild_count": len(guild_info),
|
||||
"bl_count": len(blacklisted_users),
|
||||
"guilds": guild_info,
|
||||
"blacklisted_users": blacklisted_users,
|
||||
"last_command": self.last_command,
|
||||
"last_command_time": self.last_command_time,
|
||||
"bot_uptime": uptime_str,
|
||||
"latency": f"{self.bot.latency * 1000:.2f} ms",
|
||||
"bot_name": self.bot.user.name,
|
||||
"bot_avatar_url": str(self.bot.user.avatar.url) if self.bot.user.avatar else "",
|
||||
"authenticated": os.getenv("gooberauthenticated"),
|
||||
"lastmsg": os.getenv("gooberlatestgen"),
|
||||
"localversion": os.getenv("gooberlocal_version"),
|
||||
"latestversion": os.getenv("gooberlatest_version"),
|
||||
"owner": os.getenv("ownerid")
|
||||
}
|
||||
|
||||
async def handle_update(self, request):
|
||||
if os.path.exists("goob/update.py"):
|
||||
return web.FileResponse("goob/update.py")
|
||||
return web.Response(text="Update file not found", status=404)
|
||||
|
||||
async def handle_changesong(self, request):
|
||||
song = request.query.get('song', '')
|
||||
if song:
|
||||
await self.bot.change_presence(activity=discord.Activity(type=discord.ActivityType.listening, name=song))
|
||||
return web.Response(text=f"Changed song to: {song}")
|
||||
return web.Response(text="Please provide a song parameter", status=400)
|
||||
|
||||
async def handle_changes(self, request):
|
||||
if os.path.exists("goob/changes.txt"):
|
||||
return web.FileResponse("goob/changes.txt")
|
||||
return web.Response(text="Changelog not found", status=404)
|
||||
|
||||
async def read_env_file(self):
|
||||
env_vars = {}
|
||||
try:
|
||||
with open('.env', 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split('=', 1)
|
||||
key = key.strip()
|
||||
if key in ['splashtext', 'DISCORD_BOT_TOKEN']:
|
||||
continue
|
||||
|
||||
env_vars[key] = value.strip('"\'')
|
||||
except FileNotFoundError:
|
||||
print(".env file not found")
|
||||
return env_vars
|
||||
|
||||
|
||||
async def handle_settings(self, request):
|
||||
env_vars = await self.read_env_file()
|
||||
|
||||
# Get config.py variables
|
||||
config_vars = {}
|
||||
try:
|
||||
with open('config.py', 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('VERSION_URL'):
|
||||
config_vars['VERSION_URL'] = line.split('=', 1)[1].strip().strip('"')
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
settings_html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Goober Settings</title>
|
||||
<style>
|
||||
body { background-color: #121212; color: #ffffff; font-family: 'Segoe UI', sans-serif; }
|
||||
h1 { color: #ff5555; text-align: center; }
|
||||
.settings-container { max-width: 800px; margin: auto; background-color: #1e1e1e; padding: 20px; border-radius: 8px; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
label { display: block; margin-bottom: 5px; color: #ff9999; }
|
||||
input { width: 100%; padding: 8px; background-color: #252525; color: white; border: 1px solid #444; border-radius: 4px; }
|
||||
button { background-color: #5f1b1b; color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; }
|
||||
button:hover { background-color: #7a2323; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class='settings-container'>
|
||||
<h1>Goober Settings</h1>
|
||||
<form id='settingsForm' action='/update_settings' method='post'>
|
||||
"""
|
||||
|
||||
for key, value in env_vars.items():
|
||||
settings_html += f"""
|
||||
<div class='form-group'>
|
||||
<label for='{key}'>{key}</label>
|
||||
<input type='text' id='{key}' name='{key}' value='{value}'>
|
||||
</div>
|
||||
"""
|
||||
|
||||
for key, value in config_vars.items():
|
||||
settings_html += f"""
|
||||
<div class='form-group'>
|
||||
<label for='{key}'>{key}</label>
|
||||
<input type='text' id='{key}' name='{key}' value='{value}'>
|
||||
</div>
|
||||
"""
|
||||
|
||||
settings_html += """
|
||||
<button type='submit'>Save Settings</button>
|
||||
</form>
|
||||
<form action="/restart_bot" method="POST">
|
||||
<button type="submit">Restart</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return web.Response(text=settings_html, content_type='text/html')
|
||||
|
||||
async def handle_update_settings(self, request):
|
||||
data = await request.post()
|
||||
env_text = ""
|
||||
|
||||
try:
|
||||
with open('.env', 'r') as f:
|
||||
env_text = f.read()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def replace_match(match):
|
||||
key = match.group(1)
|
||||
value = match.group(2)
|
||||
if key in ['splashtext', 'DISCORD_BOT_TOKEN']:
|
||||
return match.group(0)
|
||||
if key in data:
|
||||
new_value = data[key]
|
||||
if not (new_value.startswith('"') and new_value.endswith('"')):
|
||||
new_value = f'"{new_value}"'
|
||||
return f'{key}={new_value}'
|
||||
return match.group(0)
|
||||
|
||||
env_text = re.sub(r'^(\w+)=([\s\S]+?)(?=\n\w+=|\Z)', replace_match, env_text, flags=re.MULTILINE)
|
||||
|
||||
with open('.env', 'w') as f:
|
||||
f.write(env_text.strip() + '\n')
|
||||
|
||||
if 'VERSION_URL' in data:
|
||||
config_text = ""
|
||||
try:
|
||||
with open('config.py', 'r') as f:
|
||||
config_text = f.read()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
config_text = re.sub(r'^(VERSION_URL\s*=\s*").+?"', f'\\1{data["VERSION_URL"]}"', config_text, flags=re.MULTILINE)
|
||||
|
||||
with open('config.py', 'w') as f:
|
||||
f.write(config_text.strip() + '\n')
|
||||
|
||||
return aiohttp.web.Response(text="Settings updated successfully!")
|
||||
|
||||
async def handle_index(self, request):
|
||||
stats = await self.get_bot_stats()
|
||||
|
||||
guild_list_html = ""
|
||||
for guild in stats['guilds']:
|
||||
icon_html = f'<img src="{guild["icon_url"]}" alt="guild icon" class="guild-icon">' if guild["icon_url"] else '<div class="guild-icon-placeholder"></div>'
|
||||
guild_list_html += f"""
|
||||
<div class="guild-item">
|
||||
{icon_html}
|
||||
<div class="guild-info">
|
||||
<div class="guild-name">{guild["name"]}</div>
|
||||
<div class="guild-members">{guild["member_count"]} members</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
blacklisted_users_html = ""
|
||||
for user in stats['blacklisted_users']:
|
||||
avatar_html = f'<img src="{user["avatar_url"]}" alt="user avatar" class="user-avatar">' if user["avatar_url"] else '<div class="user-avatar-placeholder"></div>'
|
||||
blacklisted_users_html += f"""
|
||||
<div class="blacklisted-user">
|
||||
{avatar_html}
|
||||
<div class="user-info">
|
||||
<div class="user-name">{user["name"]}</div>
|
||||
<div class="user-id">ID: {user["id"]}</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
owner_id = stats.get('owner')
|
||||
owner = None
|
||||
owner_username = "Owner"
|
||||
owner_pfp = ""
|
||||
|
||||
if owner_id:
|
||||
try:
|
||||
owner = await self.bot.fetch_user(int(owner_id))
|
||||
owner_username = f"{owner.name}#{owner.discriminator}"
|
||||
owner_pfp = str(owner.avatar.url) if owner and owner.avatar else ""
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>goobs central</title>
|
||||
<style>
|
||||
#loading-screen {{
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
transition: opacity 1.5s ease-out;
|
||||
}}
|
||||
|
||||
#loading-screen.fade-out {{
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}}
|
||||
|
||||
#welcome-message {{
|
||||
color: #fff;
|
||||
font-size: 2em;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
text-shadow: 0 0 10px #ff5555;
|
||||
}}
|
||||
|
||||
#owner-avatar {{
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #5f1b1b;
|
||||
box-shadow: 0 0 20px #ff5555;
|
||||
}}
|
||||
body {{
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
|
||||
.topnav {{
|
||||
background-color: #2a0a0a;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
gap: 15px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
}}
|
||||
|
||||
.stat-item {{
|
||||
gap: 5px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
background-color: #1a1a1a;
|
||||
padding: 8px 15px;
|
||||
border-radius: 6px;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}}
|
||||
|
||||
.stat-item:hover {{
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
||||
}}
|
||||
|
||||
.stat-item::after {{
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, transparent, #ff5555, transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}}
|
||||
|
||||
.stat-item:hover::after {{
|
||||
opacity: 1;
|
||||
}}
|
||||
|
||||
.stat-title {{
|
||||
font-weight: bold;
|
||||
color: #ff9999;
|
||||
}}
|
||||
|
||||
.stat-item span:not(.stat-title) {{
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}}
|
||||
|
||||
.center {{
|
||||
text-align: center;
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
padding: 0 20px;
|
||||
}}
|
||||
|
||||
.bot-info {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
|
||||
.bot-avatar {{
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #5f1b1b;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
}}
|
||||
|
||||
hr {{
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background-image: linear-gradient(to right, transparent, #5f1b1b, transparent);
|
||||
margin: 20px 0;
|
||||
}}
|
||||
|
||||
.stat-container-row {{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 30px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}}
|
||||
|
||||
.stat-container {{
|
||||
flex: 1;
|
||||
background-color: #1e1e1e;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
min-width: 0;
|
||||
}}
|
||||
|
||||
.stat-title {{
|
||||
color: #ff5555;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 5px;
|
||||
}}
|
||||
|
||||
.guild-item {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 10px;
|
||||
margin: 5px 0;
|
||||
background-color: #252525;
|
||||
border-radius: 5px;
|
||||
transition: background-color 0.2s;
|
||||
}}
|
||||
|
||||
.guild-item:hover {{
|
||||
background-color: #333;
|
||||
}}
|
||||
|
||||
.guild-icon {{
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}}
|
||||
|
||||
.guild-icon-placeholder {{
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background-color: #7289da;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}}
|
||||
|
||||
.guild-info {{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}}
|
||||
|
||||
.guild-name {{
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}}
|
||||
|
||||
.guild-members {{
|
||||
font-size: 0.8em;
|
||||
color: #99aab5;
|
||||
}}
|
||||
|
||||
.blacklisted-user {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 10px;
|
||||
margin: 5px 0;
|
||||
background-color: #2a1a1a;
|
||||
border-radius: 5px;
|
||||
border-left: 3px solid #ff5555;
|
||||
}}
|
||||
|
||||
.user-avatar {{
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}}
|
||||
|
||||
.user-avatar-placeholder {{
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background-color: #7289da;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}}
|
||||
|
||||
.user-info {{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}}
|
||||
|
||||
.user-name {{
|
||||
font-weight: bold;
|
||||
color: #ff5555;
|
||||
}}
|
||||
|
||||
.user-id {{
|
||||
font-size: 0.8em;
|
||||
color: #99aab5;
|
||||
}}
|
||||
|
||||
input[type="text"] {{
|
||||
background-color: #252525;
|
||||
color: white;
|
||||
border: 1px solid #444;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
width: 200px;
|
||||
margin-right: 10px;
|
||||
}}
|
||||
|
||||
button {{
|
||||
background-color: #5f1b1b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}}
|
||||
|
||||
button:hover {{
|
||||
background-color: #7a2323;
|
||||
}}
|
||||
|
||||
#guild-list, #blacklisted-users {{
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding-right: 5px;
|
||||
}}
|
||||
|
||||
#guild-list::-webkit-scrollbar, #blacklisted-users::-webkit-scrollbar {{
|
||||
width: 6px;
|
||||
}}
|
||||
|
||||
#guild-list::-webkit-scrollbar-track, #blacklisted-users::-webkit-scrollbar-track {{
|
||||
background: #1a1a1a;
|
||||
}}
|
||||
|
||||
#guild-list::-webkit-scrollbar-thumb, #blacklisted-users::-webkit-scrollbar-thumb {{
|
||||
background-color: #5f1b1b;
|
||||
border-radius: 3px;
|
||||
}}
|
||||
|
||||
@media (max-width: 768px) {{
|
||||
.stat-container-row {{
|
||||
flex-direction: column;
|
||||
}}
|
||||
|
||||
.topnav {{
|
||||
gap: 10px;
|
||||
padding: 10px 5px;
|
||||
}}
|
||||
|
||||
.stat-item {{
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading-screen">
|
||||
<img id="owner-avatar" src="{owner_pfp}" onerror="this.style.display='none'">
|
||||
<div id="welcome-message"><b>Welcome, {owner_username}</b></div>
|
||||
</div>
|
||||
<div class="topnav">
|
||||
<div class="stat-item" id="ram-usage">
|
||||
<span class="stat-title">RAM:</span>
|
||||
<span>{stats['ram_usage']}</span>
|
||||
</div>
|
||||
<div class="stat-item" id="system-cpu">
|
||||
<span class="stat-title">CPU:</span>
|
||||
<span>{stats['system_cpu']}</span>
|
||||
</div>
|
||||
<div class="stat-item" id="latency">
|
||||
<span class="stat-title">Latency:</span>
|
||||
<span>{stats['latency']}</span>
|
||||
</div>
|
||||
<div class="stat-item" id="json-size">
|
||||
<span class="stat-title">JSON Size:</span>
|
||||
<span>{stats['memory_json_size']}</span>
|
||||
</div>
|
||||
<div class="stat-item" id="uptime">
|
||||
<span class="stat-title">Uptime:</span>
|
||||
<span>{stats['bot_uptime']}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="center">
|
||||
<div class="bot-info">
|
||||
<img src="{stats['bot_avatar_url']}" alt="botvatar" class="bot-avatar" id="bot-avatar">
|
||||
<h1 id="bot-name">{stats['bot_name']}</h1>
|
||||
</div>
|
||||
<hr>
|
||||
<p>your stupid little goober that learns off other people's messages</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-container-row">
|
||||
<div class="stat-container">
|
||||
<div class="stat-title">Last Command</div>
|
||||
<div id="last-command">{stats['last_command']}</div>
|
||||
<div style="font-size: 0.9em; color: #999;" id="last-command-time">at {stats['last_command_time']}</div>
|
||||
<br>
|
||||
<div class="stat-title">Logged into goober central</div>
|
||||
<div id="last-command">{stats['authenticated']}</div>
|
||||
<br>
|
||||
<div class="stat-title">Last generated message</div>
|
||||
<div id="last-command">{stats['lastmsg']}</div>
|
||||
<br>
|
||||
<div class="stat-title">Version</div>
|
||||
<div id="last-command">Installed Version: {stats['localversion']}</div>
|
||||
<div id="last-command">Latest Version: {stats['latestversion']}</div>
|
||||
<br>
|
||||
<div class="stat-title">goober-central URL</div>
|
||||
<div id="last-command">{VERSION_URL}</div>
|
||||
<br>
|
||||
<div class="stat-title">Change song</div>
|
||||
<form action="/changesong" method="get">
|
||||
<input type="text" name="song" placeholder="Enter song name...">
|
||||
<button type="submit">
|
||||
change song
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="stat-container">
|
||||
<div class="stat-title">Servers (<span id="guild-count">{stats['guild_count']}</span>)</div>
|
||||
<div id="guild-list">
|
||||
{guild_list_html}
|
||||
</div>
|
||||
<br>
|
||||
<div class="stat-title">Blacklisted Users (<span id="guild-count">{stats['bl_count']})</div>
|
||||
<div id="blacklisted-users">
|
||||
{blacklisted_users_html if stats['blacklisted_users'] else "<div>No blacklisted users</div>"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.addEventListener('load', function() {{
|
||||
setTimeout(function() {{
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
loadingScreen.classList.add('fade-out');
|
||||
setTimeout(function() {{
|
||||
loadingScreen.remove();
|
||||
}}, 1500);
|
||||
}}, 1500);
|
||||
}});
|
||||
const ws = new WebSocket('ws://' + window.location.host + '/ws');
|
||||
|
||||
ws.onmessage = function(event) {{
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
document.getElementById('ram-usage').innerHTML = `<span class="stat-title">RAM:</span> <span>${{data.ram_usage}}</span>`;
|
||||
document.getElementById('cpu-usage').innerHTML = `<span class="stat-title">CPU:</span> <span>${{data.cpu_usage}}</span>`;
|
||||
document.getElementById('system-cpu').innerHTML = `<span class="stat-title">System CPU:</span> <span>${{data.system_cpu}}</span>`;
|
||||
document.getElementById('latency').innerHTML = `<span class="stat-title">Latency:</span> <span>${{data.latency}}</span>`;
|
||||
document.getElementById('json-size').innerHTML = `<span class="stat-title">JSON Size:</span> <span>${{data.memory_json_size}}</span>`;
|
||||
document.getElementById('uptime').innerHTML = `<span class="stat-title">Uptime:</span> <span>${{data.bot_uptime}}</span>`;
|
||||
|
||||
document.getElementById('bot-name').textContent = data.bot_name;
|
||||
const botAvatar = document.getElementById('bot-avatar');
|
||||
if (botAvatar.src !== data.bot_avatar_url) {{
|
||||
botAvatar.src = data.bot_avatar_url;
|
||||
}}
|
||||
|
||||
|
||||
document.getElementById('last-command').textContent = data.last_command;
|
||||
document.getElementById('last-command-time').textContent = `at ${{data.last_command_time}}`;
|
||||
|
||||
document.getElementById('guild-count').textContent = data.guild_count;
|
||||
|
||||
|
||||
let guildListHtml = '';
|
||||
data.guilds.forEach(guild => {{
|
||||
const iconHtml = guild.icon_url
|
||||
? `<img src="${{guild.icon_url}}" alt="guild icon" class="guild-icon">`
|
||||
: '<div class="guild-icon-placeholder"></div>';
|
||||
guildListHtml += `
|
||||
<div class="guild-item">
|
||||
${{iconHtml}}
|
||||
<div class="guild-info">
|
||||
<div class="guild-name">${{guild.name}}</div>
|
||||
<div class="guild-members">${{guild.member_count}} members</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}});
|
||||
document.getElementById('guild-list').innerHTML = guildListHtml;
|
||||
|
||||
let blacklistedUsersHtml = '';
|
||||
if (data.blacklisted_users && data.blacklisted_users.length > 0) {{
|
||||
data.blacklisted_users.forEach(user => {{
|
||||
const avatarHtml = user.avatar_url
|
||||
? `<img src="${{user.avatar_url}}" alt="user avatar" class="user-avatar">`
|
||||
: '<div class="user-avatar-placeholder"></div>';
|
||||
blacklistedUsersHtml += `
|
||||
<div class="blacklisted-user">
|
||||
${{avatarHtml}}
|
||||
<div class="user-info">
|
||||
<div class="user-name">${{user.name}}</div>
|
||||
<div class="user-id">ID: ${{user.id}}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}});
|
||||
}} else {{
|
||||
blacklistedUsersHtml = '<div>No blacklisted users</div>';
|
||||
}}
|
||||
document.getElementById('blacklisted-users').innerHTML = blacklistedUsersHtml;
|
||||
}};
|
||||
|
||||
ws.onclose = function() {{
|
||||
console.log('WebSocket disconnected');
|
||||
}};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return web.Response(text=html_content, content_type='text/html')
|
||||
|
||||
async def handle_stats(self, request):
|
||||
return await self.handle_index(request)
|
||||
|
||||
async def handle_json_data(self, request):
|
||||
stats = await self.get_bot_stats()
|
||||
return web.json_response(stats)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(GooberWeb(bot))
|
Loading…
Add table
Add a link
Reference in a new issue