Update configuration and README for improved setup and security; disable default API and emoji sync, and enhance Discord permission checks in API routes.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
214
bot/utils/Tools.py
Normal file
214
bot/utils/Tools.py
Normal file
@@ -0,0 +1,214 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ ░█▀▀░█▀█░█▀▄░█▀▀░█░█ ░█▀▄░█▀▀░█░█░█▀▀ ║
|
||||
# ║ ░█░░░█░█░█░█░█▀▀░▄▀▄ ░█░█░█▀▀░▀▄▀░▀▀█ ║
|
||||
# ║ ░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀░▀ ░▀▀░░▀▀▀░░▀░░▀▀▀ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 CodeX Devs — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/codexdev ║
|
||||
# ║ youtube ── https://youtube.com/@CodeXDevs ║
|
||||
# ║ github ── https://github.com/RayExo ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
import json, sys, os
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from core import Context
|
||||
from utils.emoji import DENIED
|
||||
import aiosqlite
|
||||
import asyncio
|
||||
|
||||
async def setup_db():
|
||||
async with aiosqlite.connect('db/prefix.db') as db:
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS prefixes (
|
||||
guild_id INTEGER PRIMARY KEY,
|
||||
prefix TEXT NOT NULL
|
||||
)
|
||||
''')
|
||||
await db.commit()
|
||||
|
||||
|
||||
asyncio.run(setup_db())
|
||||
|
||||
async def is_topcheck_enabled(guild_id: int):
|
||||
async with aiosqlite.connect('db/topcheck.db') as db:
|
||||
async with db.execute("SELECT enabled FROM topcheck WHERE guild_id = ?", (guild_id,)) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return row is not None and row[0] == 1
|
||||
|
||||
|
||||
|
||||
def read_json(file_path):
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
return json.load(file)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {"guilds": {}}
|
||||
|
||||
def write_json(file_path, data):
|
||||
with open(file_path, "w") as file:
|
||||
json.dump(data, file, indent=4, ensure_ascii=False)
|
||||
|
||||
def get_or_create_guild_config(file_path, guild_id, default_config):
|
||||
data = read_json(file_path)
|
||||
if "guilds" not in data:
|
||||
data["guilds"] = {}
|
||||
|
||||
guild_id_str = str(guild_id)
|
||||
if guild_id_str not in data["guilds"]:
|
||||
data["guilds"][guild_id_str] = default_config
|
||||
write_json(file_path, data)
|
||||
return data["guilds"][guild_id_str]
|
||||
|
||||
def update_guild_config(file_path, guild_id, new_data):
|
||||
data = read_json(file_path)
|
||||
if "guilds" not in data:
|
||||
data["guilds"] = {}
|
||||
|
||||
data["guilds"][str(guild_id)] = new_data
|
||||
write_json(file_path, data)
|
||||
|
||||
def getIgnore(guild_id):
|
||||
default_config = {
|
||||
"channel": [],
|
||||
"role": None,
|
||||
"user": [],
|
||||
"bypassrole": None,
|
||||
"bypassuser": [],
|
||||
"commands": []
|
||||
}
|
||||
return get_or_create_guild_config("ignore.json", guild_id, default_config)
|
||||
|
||||
def updateignore(guild_id, data):
|
||||
update_guild_config("ignore.json", guild_id, data)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def getConfig(guildID):
|
||||
async with aiosqlite.connect('db/prefix.db') as db:
|
||||
async with db.execute("SELECT prefix FROM prefixes WHERE guild_id = ?", (guildID,)) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return {"prefix": row[0]}
|
||||
else:
|
||||
defaultConfig = {"prefix": ">"}
|
||||
await updateConfig(guildID, defaultConfig)
|
||||
return defaultConfig
|
||||
|
||||
async def updateConfig(guildID, data):
|
||||
async with aiosqlite.connect('db/prefix.db') as db:
|
||||
await db.execute(
|
||||
"INSERT OR REPLACE INTO prefixes (guild_id, prefix) VALUES (?, ?)",
|
||||
(guildID, data["prefix"])
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
|
||||
def restart_program():
|
||||
python = sys.executable
|
||||
os.execl(python, python, *sys.argv)
|
||||
|
||||
|
||||
def blacklist_check():
|
||||
|
||||
async def predicate(ctx):
|
||||
async with aiosqlite.connect('db/block.db') as db:
|
||||
cursor = await db.execute("SELECT 1 FROM user_blacklist WHERE user_id = ?", (str(ctx.author.id),))
|
||||
user_blacklisted = await cursor.fetchone()
|
||||
if user_blacklisted:
|
||||
return False
|
||||
|
||||
cursor = await db.execute("SELECT 1 FROM guild_blacklist WHERE guild_id = ?", (str(ctx.guild.id),))
|
||||
guild_blacklisted = await cursor.fetchone()
|
||||
if guild_blacklisted:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
return commands.check(predicate)
|
||||
|
||||
|
||||
async def get_ignore_data(guild_id: int) -> dict:
|
||||
async with aiosqlite.connect("db/ignore.db") as db:
|
||||
data = {
|
||||
"channel": set(),
|
||||
"user": set(),
|
||||
"command": set(),
|
||||
"bypassuser": set(),
|
||||
}
|
||||
|
||||
async with db.execute("SELECT channel_id FROM ignored_channels WHERE guild_id = ?", (guild_id,)) as cursor:
|
||||
channels = await cursor.fetchall()
|
||||
data["channel"] = {str(channel_id) for (channel_id,) in channels}
|
||||
|
||||
async with db.execute("SELECT user_id FROM ignored_users WHERE guild_id = ?", (guild_id,)) as cursor:
|
||||
users = await cursor.fetchall()
|
||||
data["user"] = {str(user_id) for (user_id,) in users}
|
||||
|
||||
async with db.execute("SELECT command_name FROM ignored_commands WHERE guild_id = ?", (guild_id,)) as cursor:
|
||||
commands = await cursor.fetchall()
|
||||
data["command"] = {command_name.strip().lower() for (command_name,) in commands}
|
||||
|
||||
async with db.execute("SELECT user_id FROM bypassed_users WHERE guild_id = ?", (guild_id,)) as cursor:
|
||||
bypass_users = await cursor.fetchall()
|
||||
data["bypassuser"] = {str(user_id) for (user_id,) in bypass_users}
|
||||
|
||||
return data
|
||||
|
||||
def ignore_check():
|
||||
async def predicate(ctx):
|
||||
data = await get_ignore_data(ctx.guild.id)
|
||||
ch = data["channel"]
|
||||
iuser = data["user"]
|
||||
cmd = data["command"]
|
||||
buser = data["bypassuser"]
|
||||
|
||||
if str(ctx.author.id) in buser:
|
||||
return True
|
||||
if str(ctx.channel.id) in ch or str(ctx.author.id) in iuser:
|
||||
return False
|
||||
|
||||
command_name = ctx.command.name.strip().lower()
|
||||
aliases = [alias.strip().lower() for alias in ctx.command.aliases]
|
||||
if command_name in cmd or any(alias in cmd for alias in aliases):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
return commands.check(predicate)
|
||||
|
||||
def top_check():
|
||||
async def predicate(ctx):
|
||||
if not ctx.guild:
|
||||
return True
|
||||
|
||||
if getattr(ctx, "invoked_with", None) in ["help", "h"]:
|
||||
return True
|
||||
|
||||
topcheck_enabled = await is_topcheck_enabled(ctx.guild.id)
|
||||
|
||||
if not topcheck_enabled:
|
||||
return True
|
||||
|
||||
if ctx.author != ctx.guild.owner and ctx.author.top_role.position <= ctx.guild.me.top_role.position:
|
||||
embed = discord.Embed(
|
||||
title=f"{DENIED} Access Denied",
|
||||
description="Your top role must be at a **higher** position than my top role.",
|
||||
color=0x000000
|
||||
)
|
||||
embed.set_footer(
|
||||
text=f"“{ctx.command.qualified_name}” command executed by {ctx.author}",
|
||||
icon_url=ctx.author.avatar.url if ctx.author.avatar else ctx.author.default_avatar.url
|
||||
)
|
||||
await ctx.send(embed=embed)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
return commands.check(predicate)
|
||||
20
bot/utils/__init__.py
Normal file
20
bot/utils/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ ░█▀▀░█▀█░█▀▄░█▀▀░█░█ ░█▀▄░█▀▀░█░█░█▀▀ ║
|
||||
# ║ ░█░░░█░█░█░█░█▀▀░▄▀▄ ░█░█░█▀▀░▀▄▀░▀▀█ ║
|
||||
# ║ ░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀░▀ ░▀▀░░▀▀▀░░▀░░▀▀▀ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 CodeX Devs — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/codexdev ║
|
||||
# ║ youtube ── https://youtube.com/@CodeXDevs ║
|
||||
# ║ github ── https://github.com/RayExo ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
from .config import *
|
||||
from .Tools import *
|
||||
from .paginators import *
|
||||
from .paginator import *
|
||||
167
bot/utils/ai_utils.py
Normal file
167
bot/utils/ai_utils.py
Normal file
@@ -0,0 +1,167 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ ░█▀▀░█▀█░█▀▄░█▀▀░█░█ ░█▀▄░█▀▀░█░█░█▀▀ ║
|
||||
# ║ ░█░░░█░█░█░█░█▀▀░▄▀▄ ░█░█░█▀▀░▀▄▀░▀▀█ ║
|
||||
# ║ ░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀░▀ ░▀▀░░▀▀▀░░▀░░▀▀▀ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 CodeX Devs — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/codexdev ║
|
||||
# ║ youtube ── https://youtube.com/@CodeXDevs ║
|
||||
# ║ github ── https://github.com/RayExo ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
import aiohttp
|
||||
import io
|
||||
import time
|
||||
import os
|
||||
import random
|
||||
import json
|
||||
from langdetect import detect
|
||||
from gtts import gTTS
|
||||
from urllib.parse import quote
|
||||
from utils.config_loader import load_current_language, config
|
||||
from openai import AsyncOpenAI
|
||||
from duckduckgo_search import AsyncDDGS
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
current_language = load_current_language()
|
||||
internet_access = config['INTERNET_ACCESS']
|
||||
|
||||
client = AsyncOpenAI(
|
||||
base_url=config['API_BASE_URL'],
|
||||
api_key="nah-ha",
|
||||
)
|
||||
|
||||
async def generate_response(instructions, history):
|
||||
messages = [
|
||||
{"role": "system", "name": "instructions", "content": instructions},
|
||||
*history,
|
||||
]
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "searchtool",
|
||||
"description": "Searches the internet.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The query for search engine",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
response = await client.chat.completions.create(
|
||||
model=config['MODEL_ID'],
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice="auto",
|
||||
)
|
||||
response_message = response.choices[0].message
|
||||
tool_calls = response_message.tool_calls
|
||||
|
||||
if tool_calls:
|
||||
available_functions = {
|
||||
"searchtool": duckduckgotool,
|
||||
}
|
||||
messages.append(response_message)
|
||||
|
||||
for tool_call in tool_calls:
|
||||
function_name = tool_call.function.name
|
||||
function_to_call = available_functions[function_name]
|
||||
function_args = json.loads(tool_call.function.arguments)
|
||||
function_response = await function_to_call(
|
||||
query=function_args.get("query")
|
||||
)
|
||||
messages.append(
|
||||
{
|
||||
"tool_call_id": tool_call.id,
|
||||
"role": "tool",
|
||||
"name": function_name,
|
||||
"content": function_response,
|
||||
}
|
||||
)
|
||||
second_response = await client.chat.completions.create(
|
||||
model=config['MODEL_ID'],
|
||||
messages=messages
|
||||
)
|
||||
return second_response.choices[0].message.content
|
||||
return response_message.content
|
||||
|
||||
async def duckduckgotool(query) -> str:
|
||||
if config['INTERNET_ACCESS']:
|
||||
return "internet access has been disabled by user"
|
||||
blob = ''
|
||||
results = await AsyncDDGS(proxy=None).text(query, max_results=6)
|
||||
try:
|
||||
for index, result in enumerate(results[:6]): # Limiting to 6 results
|
||||
blob += f'[{index}] Title : {result["title"]}\nSnippet : {result["body"]}\n\n\n Provide a cohesive response base on provided Search results'
|
||||
except Exception as e:
|
||||
blob += f"Search error: {e}\n"
|
||||
return blob
|
||||
|
||||
|
||||
async def poly_image_gen(session, prompt):
|
||||
seed = random.randint(1, 100000)
|
||||
image_url = f"https://image.pollinations.ai/prompt/{prompt}?seed={seed}"
|
||||
async with session.get(image_url) as response:
|
||||
image_data = await response.read()
|
||||
return io.BytesIO(image_data)
|
||||
|
||||
async def generate_image_prodia(prompt, model, sampler, seed, neg):
|
||||
print("\033[1;32m(Prodia) Creating image for :\033[0m", prompt)
|
||||
start_time = time.time()
|
||||
async def create_job(prompt, model, sampler, seed, neg):
|
||||
url = 'https://api.prodia.com/generate'
|
||||
params = {
|
||||
'new': 'true',
|
||||
'prompt': f'{quote(prompt)}',
|
||||
'model': model,
|
||||
'steps': '100',
|
||||
'cfg': '9.5',
|
||||
'seed': f'{seed}',
|
||||
'sampler': sampler,
|
||||
'upscale': 'True',
|
||||
'aspect_ratio': 'square'
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, params=params) as response:
|
||||
data = await response.json()
|
||||
return data['job']
|
||||
|
||||
job_id = await create_job(prompt, model, sampler, seed, neg)
|
||||
url = f'https://api.prodia.com/job/{job_id}'
|
||||
headers = {
|
||||
'authority': 'api.prodia.com',
|
||||
'accept': '*/*',
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while True:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
json = await response.json()
|
||||
if json['status'] == 'succeeded':
|
||||
async with session.get(f'https://images.prodia.xyz/{job_id}.png?download=1', headers=headers) as response:
|
||||
content = await response.content.read()
|
||||
img_file_obj = io.BytesIO(content)
|
||||
duration = time.time() - start_time
|
||||
print(f"\033[1;34m(Prodia) Finished image creation\n\033[0mJob id : {job_id} Prompt : ", prompt, "in", duration, "seconds.")
|
||||
return img_file_obj
|
||||
|
||||
async def text_to_speech(text):
|
||||
bytes_obj = io.BytesIO()
|
||||
detected_language = detect(text)
|
||||
tts = gTTS(text=text, lang=detected_language)
|
||||
tts.write_to_fp(bytes_obj)
|
||||
bytes_obj.seek(0)
|
||||
return bytes_obj
|
||||
BIN
bot/utils/arial.ttf
Normal file
BIN
bot/utils/arial.ttf
Normal file
Binary file not shown.
86
bot/utils/config.py
Normal file
86
bot/utils/config.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ ░█▀▀░█▀█░█▀▄░█▀▀░█░█ ░█▀▄░█▀▀░█░█░█▀▀ ║
|
||||
# ║ ░█░░░█░█░█░█░█▀▀░▄▀▄ ░█░█░█▀▀░▀▄▀░▀▀█ ║
|
||||
# ║ ░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀░▀ ░▀▀░░▀▀▀░░▀░░▀▀▀ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 CodeX Devs — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/codexdev ║
|
||||
# ║ youtube ── https://youtube.com/@CodeXDevs ║
|
||||
# ║ github ── https://github.com/RayExo ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
TOKEN = os.environ.get("TOKEN")
|
||||
BRAND_NAME = os.environ.get("brand_name", "Zyrox X")
|
||||
NAME = BRAND_NAME
|
||||
BotName = BRAND_NAME
|
||||
|
||||
server = "https://discord.gg/codexdev"
|
||||
serverLink = "https://discord.gg/codexdev"
|
||||
ch = "https://discord.com/channels/699587669059174461/1271825678710476911"
|
||||
|
||||
# ── Security / feature toggles ────────────────────────────────────────────────
|
||||
|
||||
def _env_bool(key: str, default: bool = False) -> bool:
|
||||
raw = os.getenv(key, str(default)).strip().lower()
|
||||
return raw in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
JISHAKU_ENABLED = _env_bool("JISHAKU_ENABLED", False)
|
||||
API_ENABLED = _env_bool("API_ENABLED", False)
|
||||
TUNNEL_ENABLED = _env_bool("TUNNEL_ENABLED", False)
|
||||
TUNNEL_ALLOW_BINARY_DOWNLOAD = _env_bool("TUNNEL_ALLOW_BINARY_DOWNLOAD", False)
|
||||
|
||||
API_HOST = os.getenv("API_HOST", "127.0.0.1").strip() or "127.0.0.1"
|
||||
API_PORT = int(os.getenv("API_PORT", "8000"))
|
||||
|
||||
# ── Webhooks (only active when explicitly configured) ─────────────────────────
|
||||
|
||||
def _valid_webhook_url(url: str | None) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
url = url.strip()
|
||||
if not url or url == "https://discord.com/api/webhooks/":
|
||||
return None
|
||||
if "discord.com/api/webhooks/" not in url and "discordapp.com/api/webhooks/" not in url:
|
||||
return None
|
||||
return url
|
||||
|
||||
CMD_WEBHOOK_URL = _valid_webhook_url(os.getenv("CMD_WEBHOOK_URL"))
|
||||
|
||||
# ── Optional Discord log / stats channels (unset = disabled) ──────────────────
|
||||
|
||||
def _parse_optional_id(env_key: str) -> int | None:
|
||||
raw = os.getenv(env_key, "").strip()
|
||||
if not raw or not raw.isdigit():
|
||||
return None
|
||||
return int(raw)
|
||||
|
||||
LOG_CHANNEL_ID = _parse_optional_id("LOG_CHANNEL_ID")
|
||||
SERVER_COUNT_CHANNEL_ID = _parse_optional_id("SERVER_COUNT_CHANNEL_ID")
|
||||
USER_COUNT_CHANNEL_ID = _parse_optional_id("USER_COUNT_CHANNEL_ID")
|
||||
|
||||
# ── Owner / Staff IDs ─────────────────────────────────────────────────────────
|
||||
# REQUIRED: set OWNER_IDS in .env — no hardcoded fallback IDs.
|
||||
|
||||
def _parse_ids(env_key: str) -> list[int]:
|
||||
raw = os.getenv(env_key, "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
return [int(p.strip()) for p in raw.split(",") if p.strip().isdigit()]
|
||||
|
||||
OWNER_IDS: list[int] = _parse_ids("OWNER_IDS")
|
||||
OWNER_IDS_STR: list[str] = [str(i) for i in OWNER_IDS]
|
||||
|
||||
# Aliases kept for backwards compatibility with files that import these names
|
||||
BOT_OWNER_IDS = OWNER_IDS
|
||||
BOT_OWNER_IDS_STR = OWNER_IDS_STR
|
||||
STAFF_IDS = OWNER_IDS
|
||||
STAFF_IDS_STR = OWNER_IDS_STR
|
||||
59
bot/utils/config_loader.py
Normal file
59
bot/utils/config_loader.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ ░█▀▀░█▀█░█▀▄░█▀▀░█░█ ░█▀▄░█▀▀░█░█░█▀▀ ║
|
||||
# ║ ░█░░░█░█░█░█░█▀▀░▄▀▄ ░█░█░█▀▀░▀▄▀░▀▀█ ║
|
||||
# ║ ░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀░▀ ░▀▀░░▀▀▀░░▀░░▀▀▀ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 CodeX Devs — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/codexdev ║
|
||||
# ║ youtube ── https://youtube.com/@CodeXDevs ║
|
||||
# ║ github ── https://github.com/RayExo ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
import yaml
|
||||
import json
|
||||
import os
|
||||
|
||||
# Config load
|
||||
with open('config.yml', 'r', encoding='utf-8') as config_file:
|
||||
config = yaml.safe_load(config_file)
|
||||
|
||||
## Language settings ##
|
||||
valid_language_codes = []
|
||||
lang_directory = "lang"
|
||||
|
||||
current_language_code = config['LANGUAGE']
|
||||
|
||||
for filename in os.listdir(lang_directory):
|
||||
if filename.startswith("lang.") and filename.endswith(".json") and os.path.isfile(
|
||||
os.path.join(lang_directory, filename)):
|
||||
language_code = filename.split(".")[1]
|
||||
valid_language_codes.append(language_code)
|
||||
|
||||
def load_current_language() -> dict:
|
||||
lang_file_path = os.path.join(
|
||||
lang_directory, f"lang.{current_language_code}.json")
|
||||
with open(lang_file_path, encoding="utf-8") as lang_file:
|
||||
current_language = json.load(lang_file)
|
||||
return current_language
|
||||
|
||||
# Instructions loader
|
||||
def load_instructions() -> dict:
|
||||
instructions = {}
|
||||
for file_name in os.listdir("instructions"):
|
||||
if file_name.endswith('.txt'):
|
||||
file_path = os.path.join("instructions", file_name)
|
||||
with open(file_path, 'r', encoding='utf-8') as file:
|
||||
file_content = file.read()
|
||||
# Use the file name without extension as the variable name
|
||||
variable_name = file_name.split('.')[0]
|
||||
instructions[variable_name] = file_content
|
||||
return instructions
|
||||
|
||||
def load_active_channels() -> dict:
|
||||
if os.path.exists("channels.json"):
|
||||
with open("channels.json", "r", encoding='utf-8') as f:
|
||||
active_channels = json.load(f)
|
||||
return active_channels
|
||||
108
bot/utils/cv2.py
Normal file
108
bot/utils/cv2.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ ░█▀▀░█▀█░█▀▄░█▀▀░█░█ ░█▀▄░█▀▀░█░█░█▀▀ ║
|
||||
# ║ ░█░░░█░█░█░█░█▀▀░▄▀▄ ░█░█░█▀▀░▀▄▀░▀▀█ ║
|
||||
# ║ ░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀░▀ ░▀▀░░▀▀▀░░▀░░▀▀▀ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 CodeX Devs — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/codexdev ║
|
||||
# ║ youtube ── https://youtube.com/@CodeXDevs ║
|
||||
# ║ github ── https://github.com/RayExo ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
"""
|
||||
Shared CV2 (Component V2) utilities for the bot.
|
||||
Provides a helper to build Container objects since Container()
|
||||
does NOT accept positional children arguments — items must be
|
||||
added via .add_item().
|
||||
"""
|
||||
|
||||
import discord
|
||||
from discord.ui import LayoutView, TextDisplay, Separator, Container
|
||||
|
||||
|
||||
def build_container(*items, accent_color=None):
|
||||
"""Build a Container and add items to it via .add_item()."""
|
||||
container = Container(accent_color=accent_color)
|
||||
for item in items:
|
||||
container.add_item(item)
|
||||
return container
|
||||
|
||||
|
||||
class CV2(LayoutView):
|
||||
"""Quick helper: CV2("Title", "section1", "section2", ...)"""
|
||||
def __init__(self, title, *sections):
|
||||
super().__init__(timeout=None)
|
||||
container = build_container(
|
||||
TextDisplay(f"**{title}**"),
|
||||
*[item for s in sections for item in (Separator(visible=True), TextDisplay(str(s)))]
|
||||
)
|
||||
self.add_item(container)
|
||||
|
||||
def add_action_rows(container, components):
|
||||
"""Safely adds components to a CV2 container across multiple ActionRows"""
|
||||
from discord.ui import ActionRow
|
||||
current_row = []
|
||||
for item in components:
|
||||
if getattr(item, 'type', None) and getattr(item.type, 'value', 0) != 2:
|
||||
if current_row:
|
||||
container.add_item(ActionRow(*current_row))
|
||||
current_row = []
|
||||
container.add_item(ActionRow(item))
|
||||
else:
|
||||
current_row.append(item)
|
||||
if len(current_row) == 5:
|
||||
container.add_item(ActionRow(*current_row))
|
||||
current_row = []
|
||||
if current_row:
|
||||
container.add_item(ActionRow(*current_row))
|
||||
|
||||
class CV2Embed(CV2):
|
||||
"""A CV2 container that behaves like a discord.Embed."""
|
||||
def __init__(self, title="", description="", **kwargs):
|
||||
self._title = title
|
||||
self._description = description or ""
|
||||
self._fields = []
|
||||
self._footer = None
|
||||
super().__init__(title, self._description)
|
||||
self.color = kwargs.get("color", 0xFF0000)
|
||||
|
||||
def _rebuild(self):
|
||||
self.clear_items()
|
||||
sections = [self._description] if self._description else []
|
||||
for name, value in self._fields:
|
||||
sections.append(f"**{name}**\n{value}")
|
||||
|
||||
if self._footer:
|
||||
sections.append(f"*{self._footer}*")
|
||||
|
||||
container = build_container(
|
||||
TextDisplay(f"**{self._title}**"),
|
||||
*[item for s in sections for item in (Separator(visible=True), TextDisplay(str(s)))]
|
||||
)
|
||||
self.add_item(container)
|
||||
|
||||
def add_field(self, name, value, inline=False, **kwargs):
|
||||
self._fields.append((name, value))
|
||||
self._rebuild()
|
||||
return self
|
||||
|
||||
def set_footer(self, text=None, icon_url=None, **kwargs):
|
||||
if text:
|
||||
self._footer = text
|
||||
self._rebuild()
|
||||
return self
|
||||
|
||||
def set_thumbnail(self, url=None, **kwargs):
|
||||
return self
|
||||
|
||||
def set_author(self, name=None, url=None, icon_url=None, **kwargs):
|
||||
return self
|
||||
|
||||
def set_image(self, url=None, **kwargs):
|
||||
return self
|
||||
|
||||
def to_dict(self):
|
||||
return {"title": self._title, "description": self._description}
|
||||
379
bot/utils/emoji.py
Normal file
379
bot/utils/emoji.py
Normal file
@@ -0,0 +1,379 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ ░█▀▀░█▀█░█▀▄░█▀▀░█░█ ░█▀▄░█▀▀░█░█░█▀▀ ║
|
||||
# ║ ░█░░░█░█░█░█░█▀▀░▄▀▄ ░█░█░█▀▀░▀▄▀░▀▀█ ║
|
||||
# ║ ░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀░▀ ░▀▀░░▀▀▀░░▀░░▀▀▀ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 CodeX Devs — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/codexdev ║
|
||||
# ║ youtube ── https://youtube.com/@CodeXDevs ║
|
||||
# ║ github ── https://github.com/RayExo ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
"""
|
||||
Centralized emoji module for the Zyrox bot.
|
||||
All emoji definitions are stored here for easy management and consistency.
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# DISCORD CUSTOM EMOJIS (Static)
|
||||
# ============================================================================
|
||||
BOOST = "<:boost:1448966463586041906>"
|
||||
BUG_HUNTER = "<:BugHunterLevel1:1448949674898620518>"
|
||||
BUG_HUNTER_LVL2 = "<:BugHunterLvl2:1122549925237375086>"
|
||||
CAST = "<:zcast:1448951414301655175>"
|
||||
CERTIFIED_MODERATOR = "<:CertifiedDiscordModerator:1448949742792085516>"
|
||||
CHANNEL = "<:channel:1448951734096625727>"
|
||||
CODEBASE = "<:codebase:1448951697853386826>"
|
||||
CODED = "<:coded:1448966435622752389>"
|
||||
CROSS = "<:zcross:1448951756372443296>"
|
||||
CROSS_ALT = "<:CrossIcon:1327829124894429235>"
|
||||
CUTE_CUTE_CUTE = "<:Cute_Cute_Cute:1384578375221248010>"
|
||||
DELETE = "<:delete:1327842168693461022>"
|
||||
DELETE_ALT1 = "<:delete:1448966413242073088>"
|
||||
DENIED = "<:Denied:1294218790082711553>"
|
||||
DISABLE = "<:Disable:1448949515787964417>"
|
||||
DND = "<:dnd:1448951614172954664>"
|
||||
EARLY_SUPPORTER = "<:EarlySupporter:1448949719752773703>"
|
||||
ENABLE = "<:Enable:1448949527846322296>"
|
||||
ERROR = "<:error:1397218903389044776>"
|
||||
FORWARD = "<:forward:1329361532999569439>"
|
||||
GAMES = "<:games:1448951285498777641>"
|
||||
HANDSHAKE = "<:handshake:1448949571811282984>"
|
||||
HAPPY_PANDA = "<:happy_panda:1384576519904559206>"
|
||||
HEADMOD = "<:headmod:1274781954482376857>"
|
||||
HEART3 = "<:heart3:1448966390353756200>"
|
||||
HEART_EM = "<:heart_em:1274781856406962250>"
|
||||
HEERIYE = "<:Heeriye:1274769360560328846>"
|
||||
HOME = "<:icons_home:1337295807430393958>"
|
||||
HYPESQUAD_BRILLIANCE = "<:Hypesquad_Brilliance:1448949708381749370>"
|
||||
ICONLOAD = "<:iconLoad:1327829324518391824>"
|
||||
ICONS_CHANNEL = "<:icons_channel:1327829380935843941>"
|
||||
ICONS_MUSIC = "<:icons_music:1327829459729911900>"
|
||||
ICONS_PAUSE = "<:icons_pause:1327829480835780609>"
|
||||
ICONS_PLUS = "<:icons_plus:1328966531140288524>"
|
||||
ICONS_WARNING = "<:icons_warning:1448949538944716922>"
|
||||
ICONS_WARNING_ALT1 = "<:icons_warning:1327829522573430864>"
|
||||
ICON_BROWSER = "<:icon_browser:1448951659521773598>"
|
||||
IDLE = "<:idle:1448951603028693042>"
|
||||
INDEX = "<:index:1448951296370544790>"
|
||||
INFO = "<:info:1374723970376405113>"
|
||||
KING = "<:king:1448951721479901334>"
|
||||
LEVEL_UP = "<:zlevelup:1448964376504696943>"
|
||||
LOCK = "<:lock:1448949549455511685>"
|
||||
MANAGER = "<:manager:1394348646803439709>"
|
||||
MENTION = "<:zyrox_mention:1448949481776222218>"
|
||||
MESSAGE = "<:zmsg:1448964399166394483>"
|
||||
MINECRAFT = "<:zmc:1448964387426537474>"
|
||||
ML_CROSS = "<:ml_cross:1204106928675102770>"
|
||||
MUSIC = "<:zmusic:1448951372707008533>"
|
||||
MUSICSTOP_ICONS = "<:musicstop_icons:1327829536053923934>"
|
||||
MUTE = "<:zmute:1448951435478700072>"
|
||||
NEW = "<:New:1448949337395695616>"
|
||||
NEXT = "<:icons_next:1327829470027055184>"
|
||||
NEXT_ALT1 = "<:next:1448949316109733920>"
|
||||
OFFLINE = "<:offline:1448951625506099261>"
|
||||
PARTNER_BADGE = "<:PartneredServerOwner:1122549945532297246>"
|
||||
PC = "<:pc:1448951637266665542>"
|
||||
PIN = "<:zpin:1448949810462855249>"
|
||||
PREVIOUS = "<:next:1327829548426854522>"
|
||||
RED_BUTTON = "<:red_button:1401444612874305671>"
|
||||
RED_PIN = "<:red_pin:1448949326846889994>"
|
||||
REWIND = "<:rewind1:1329360839874056225>"
|
||||
REWIND_ALT1 = "<:rewind:1500793294865563658>"
|
||||
SEED = "<:zseed:1448951477640101929>"
|
||||
SHUFFLE = "<:shuffle:1329360518367936564>"
|
||||
SKIP = "<:skip:1329359900563996754>"
|
||||
STAR = "<:starr:1448951307707748395>"
|
||||
SWORD = "<:zsowrd:1448951362238021682>"
|
||||
SYSTEM = "<:zyrox_system:1448949359159939143>"
|
||||
THUNDER = "<:zyroxthunder:1448949415200034907>"
|
||||
TICK = "<:ztick:1448951767990796298>"
|
||||
TICKET = "<:zticket:1448951318713470987>"
|
||||
TICK_ALT = "<:tick:1327829594954530896>"
|
||||
TIME = "<:zyrox_time:1448949493012889610>"
|
||||
TIMER = "<:ztimer:1448949799528173621>"
|
||||
UNLOCK = "<:unlock:1448949560457171070>"
|
||||
UPTIME = "<:uptime:1398280366501920829>"
|
||||
U_ADMIN = "<:U_admin:1327829252120510567>"
|
||||
WARNING = "<:warning:1448951779353038949>"
|
||||
WARNING_ALT = "<:warning:1396796622347833378>"
|
||||
WIFI = "<:zwifi:1448951466931912715>"
|
||||
ZAI = "<:zai:1448949821611446302>"
|
||||
ZARROW = "<:zArrow:1448951532837015643>"
|
||||
ZBACK = "<:zback:1448949305229443124>"
|
||||
ZBAN = "<:zban:1448951424665784373>"
|
||||
ZBOT = "<:zbot:1448951393216888905>"
|
||||
ZCIRCLE = "<:zcircle:1448964410155470848>"
|
||||
ZCIRCLE_ALT1 = "<:zcircle:1448951351601270814>"
|
||||
ZCLOUD = "<:zCloud:1448951498213032036>"
|
||||
ZCOUNTING = "<:zcounting:1448949348103749713>"
|
||||
ZCROSS = "<:zcross:1448951767990796298>"
|
||||
ZDIL = "<:zdil:1448949605676093540>"
|
||||
ZHUMAN = "<:zHuman:1448951509235531869>"
|
||||
ZMODULE = "<:zmodule:1448951340716785744>"
|
||||
ZMUSICPAUSE = "<:zmusicpause:1448951801931108413>"
|
||||
ZPAUSE = "<:zpause:1448949283423522928>"
|
||||
ZPEOPLE = "<:zpeople:1448951456861519962>"
|
||||
ZPLAY = "<:zplay:1448949294412337222>"
|
||||
ZPLUS = "<:zplus:1448951790463615038>"
|
||||
ZROCKET = "<:zrocket:1448951445989888010>"
|
||||
ZSAFE = "<:zSafe:1448951403434479626>"
|
||||
ZSETTINGS = "<:zsettings:1448951745706459206>"
|
||||
ZTADA = "<:ztada:1448951329664925717>"
|
||||
ZTICK = "<:Ztick:1222750301233090600>"
|
||||
ZUNMUTE = "<:zunmute:1448951487970414694>"
|
||||
ZWARNING = "<:zwarning:1448949627712966717>"
|
||||
ZWRENCH = "<:zwrench:1448951382597177495>"
|
||||
ZYROXCONNECTION = "<:zyroxconnection:1448949425828528230>"
|
||||
ZYROXHAMMER = "<:zyroxhammer:1448949447617806458>"
|
||||
ZYROXLINKS = "<:zyroxlinks:1448949436939239495>"
|
||||
ZYROXSYS = "<:zyroxsys:1448949469650620426>"
|
||||
ZYROX_CODE = "<:zyrox_code:1448949381436014662>"
|
||||
ZYROX_COMMAND = "<:zyrox_command:1448949381436014662>"
|
||||
ZYROX_GLOBAL = "<:zyrox_global:1448949370539217026>"
|
||||
ZYROX_OWNER = "<:zyrox_owner:1448949381436014662>"
|
||||
ZYROX_SEARCH = "<:zyrox_search:1448949381436014662>"
|
||||
|
||||
# ============================================================================
|
||||
# DISCORD CUSTOM EMOJIS (Animated)
|
||||
# ============================================================================
|
||||
ACTIVE_DEVELOPER = "<a:Active_Developer:1448949755181793280>"
|
||||
ARROWRED = "<a:ArrowRed:1448951520077811806>"
|
||||
BLACKCROWN = "<a:BlackCrown:1448949787842973697>"
|
||||
BLOBPART = "<a:blobpart:1435923345748004969>"
|
||||
BOOSTS = "<a:boosts:1448949652547436654>"
|
||||
EARLY_VERIFIED_BOT_DEV = "<a:EarlyVerifiedBotDeveloper:1448949731777839184>"
|
||||
EMOTE = "<a:emote:1448966401887961149>"
|
||||
GIFD = "<a:GIFD:1275850452323401789>"
|
||||
GIFN = "<a:GIFN:1275850451212042391>"
|
||||
HYPESQUAD_BALANCE = "<a:a_Hypesquad_Balance:1448949777067806720>"
|
||||
HYPESQUAD_BRAVERY = "<a:a_Hypesquad_Bravery:1448949697577353307>"
|
||||
HYPESQUAD_EVENTS = "<a:HypesquadEvents:1448949663821598812>"
|
||||
KING_ALT1 = "<a:king:1234399917792034846>"
|
||||
LOADING = "<a:loading:1448949404185657354>"
|
||||
LOADINGRED = "<a:loadingred:1448966488865247232>"
|
||||
LOADING_ALT1 = "<a:loading:1448951579603505154>"
|
||||
MAX__A = "<a:max__A:1295014945641201685>"
|
||||
MENTION_ALT1 = "<a:mention:1448966424495390801>"
|
||||
MINGLE = "<a:mingle:1367773396745846895>"
|
||||
MOBILE = "<a:mobile:1448951648490885132>"
|
||||
MUSIC_ALT1 = "<a:music:1448966355935301643>"
|
||||
NITRO_BOOST = "<a:nitroboost:1448949639540899921>"
|
||||
ONLINE = "<a:online:1448951591305744427>"
|
||||
PREMIUM = "<a:premium:1204110058124873889>"
|
||||
RACECAR64 = "<a:racecar64:1448966449535127623>"
|
||||
REDDOT = "<a:reddot:1398280090198081578>"
|
||||
REDHEART = "<a:RedHeart:1272229548280512547>"
|
||||
REDRULESBOOK = "<a:RedRulesBook:1448966523258404955>"
|
||||
SG_RD = "<a:sg_rd:1273974278433280122>"
|
||||
STAFF = "<a:staff:1448949765931925504>"
|
||||
STAR_ALT1 = "<a:Star:1273588820373147803>"
|
||||
STAR_ALT2 = "<a:star:1251876754516349059>"
|
||||
TADAA = "<a:TADAA:1448966368044126249>"
|
||||
TIMER_ALT1 = "<a:timer:1329404677820911697>"
|
||||
_37496ALERT = "<a:37496alert:1273959128490049556>"
|
||||
|
||||
# ============================================================================
|
||||
# DISCORD BADGE EMOJIS MAPPING (Dictionary)
|
||||
# ============================================================================
|
||||
DISCORD_BADGE_EMOJIS = {
|
||||
"staff": STAFF,
|
||||
"partner": PARTNER_BADGE,
|
||||
"hypesquad": HYPESQUAD_BRILLIANCE,
|
||||
"hypesquad_bravery": HYPESQUAD_BRAVERY,
|
||||
"hypesquad_brilliance": HYPESQUAD_BRILLIANCE,
|
||||
"hypesquad_balance": HYPESQUAD_BALANCE,
|
||||
"bug_hunter": BUG_HUNTER,
|
||||
"bug_hunter_level_2": BUG_HUNTER_LVL2,
|
||||
"early_supporter": EARLY_SUPPORTER,
|
||||
"early_verified_bot_developer": EARLY_VERIFIED_BOT_DEV,
|
||||
"certified_moderator": CERTIFIED_MODERATOR,
|
||||
"active_developer": ACTIVE_DEVELOPER,
|
||||
"discord_mod": CERTIFIED_MODERATOR,
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# UNICODE EMOJIS
|
||||
# ============================================================================
|
||||
ARROW_DOWN = "⬇️"
|
||||
ARROW_LEFT = "⬅️"
|
||||
ARROW_RIGHT = "➡️"
|
||||
ARROW_UP = "⬆️"
|
||||
BLOCK = "🚫"
|
||||
BUBBLE_TEA = "🧋"
|
||||
CELEBRATE = "🎉"
|
||||
CHERRIES = "🍒"
|
||||
CLOCK = "⏱️"
|
||||
COOKIE = "🍪"
|
||||
CURSOR = "🖱️"
|
||||
DIZZY = "🥴"
|
||||
ERROR_UNICODE = "❌"
|
||||
GAME_CONTROLLER = "🎮"
|
||||
HEARTS = "💕"
|
||||
JAVA_COFFEE = "☕"
|
||||
LAUGH1 = "😂"
|
||||
LAUGH2 = "🤣"
|
||||
LAUGH3 = "😆"
|
||||
LOCK_UNICODE = "🔒"
|
||||
MONEY = "💸"
|
||||
MOON = "🌙"
|
||||
NOTE = "📝"
|
||||
PAPER = "\U0001f4f0"
|
||||
PEACH = "🍑"
|
||||
REFRESH = "🔄"
|
||||
ROCK = "\U0001faa8"
|
||||
SCISSORS = "\U00002702"
|
||||
SHOCKED = "😳"
|
||||
SPARKLE = "✨"
|
||||
STAR_UNICODE = "⭐"
|
||||
STOP_BUTTON = "⏹️"
|
||||
SUCCESS = "✅"
|
||||
TARGET = "🎯"
|
||||
TONGUE_OUT = "😜"
|
||||
UPSIDE_DOWN = "🙃"
|
||||
WARNING_UNICODE = "⚠️"
|
||||
|
||||
# ============================================================================
|
||||
# EMOJI COLLECTIONS BY CATEGORY (Dictionaries)
|
||||
# ============================================================================
|
||||
GAME_BUTTONS = {
|
||||
"up": ARROW_UP,
|
||||
"down": ARROW_DOWN,
|
||||
"left": ARROW_LEFT,
|
||||
"right": ARROW_RIGHT,
|
||||
"stop": STOP_BUTTON,
|
||||
"target": "🎯",
|
||||
}
|
||||
|
||||
ACTION_EMOJIS = {
|
||||
"success": SUCCESS,
|
||||
"error": ERROR_UNICODE,
|
||||
"warning": WARNING_UNICODE,
|
||||
"clock": CLOCK,
|
||||
"refresh": REFRESH,
|
||||
}
|
||||
|
||||
RPS_CHOICES = {
|
||||
"rock": ROCK,
|
||||
"scissors": SCISSORS,
|
||||
"paper": PAPER,
|
||||
}
|
||||
|
||||
BUTTON_EMOJIS = {
|
||||
"note": NOTE,
|
||||
"privacy": LOCK_UNICODE,
|
||||
"claim": STAR_UNICODE,
|
||||
"untrust": ERROR_UNICODE,
|
||||
"block": BLOCK,
|
||||
"target": "🎯",
|
||||
"edit": "✏️",
|
||||
}
|
||||
|
||||
REACTION_TEST_EMOJIS = [
|
||||
COOKIE, CELEBRATE, BUBBLE_TEA, CHERRIES, PEACH, MONEY, MOON, HEARTS
|
||||
]
|
||||
|
||||
FUN_EMOJIS = [
|
||||
LAUGH1, LAUGH2, LAUGH3, SHOCKED, DIZZY, UPSIDE_DOWN, TONGUE_OUT
|
||||
]
|
||||
|
||||
MINECRAFT_EMOJIS = {
|
||||
"success": SUCCESS,
|
||||
"error": ERROR_UNICODE,
|
||||
"warning": WARNING_UNICODE,
|
||||
"clock": CLOCK,
|
||||
"refresh": REFRESH,
|
||||
"java": JAVA_COFFEE,
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# FEATURE EMOJIS (Dictionaries)
|
||||
# ============================================================================
|
||||
MODERATION_EMOJIS = {
|
||||
"warn": WARNING,
|
||||
"mute": MUTE,
|
||||
"ban": SWORD,
|
||||
"kick": SWORD,
|
||||
"lock": LOCK,
|
||||
}
|
||||
|
||||
TICKET_EMOJIS = {
|
||||
"ticket": TICKET,
|
||||
"close": ERROR,
|
||||
"open": SUCCESS,
|
||||
"pin": PIN,
|
||||
}
|
||||
|
||||
LEVEL_EMOJIS = {
|
||||
"level_up": LEVEL_UP,
|
||||
"sparkle": SPARKLE,
|
||||
"achievement": STAR,
|
||||
}
|
||||
|
||||
UTILITY_EMOJIS = {
|
||||
"music": MUSIC,
|
||||
"system": SYSTEM,
|
||||
"new": NEW,
|
||||
"message": MESSAGE,
|
||||
"wifi": WIFI,
|
||||
"cast": CAST,
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
def get_badge_emoji(badge_name: str) -> str:
|
||||
"""
|
||||
Get Discord badge emoji by name.
|
||||
|
||||
Args:
|
||||
badge_name: The name of the badge (e.g., 'staff', 'partner', 'bug_hunter')
|
||||
|
||||
Returns:
|
||||
The emoji string for the badge, or None if not found
|
||||
"""
|
||||
return DISCORD_BADGE_EMOJIS.get(badge_name.lower())
|
||||
|
||||
|
||||
def get_action_emoji(action: str) -> str:
|
||||
"""
|
||||
Get emoji for a common action.
|
||||
|
||||
Args:
|
||||
action: The action name (e.g., 'success', 'error', 'warning')
|
||||
|
||||
Returns:
|
||||
The emoji string for the action
|
||||
"""
|
||||
return ACTION_EMOJIS.get(action.lower())
|
||||
|
||||
|
||||
def get_button_emoji(button_type: str) -> str:
|
||||
"""
|
||||
Get emoji for a button type.
|
||||
|
||||
Args:
|
||||
button_type: The button type (e.g., 'note', 'privacy', 'claim')
|
||||
|
||||
Returns:
|
||||
The emoji string for the button
|
||||
"""
|
||||
return BUTTON_EMOJIS.get(button_type.lower())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COMPATIBILITY ALIASES
|
||||
# ============================================================================
|
||||
|
||||
# Common aliases for frequently used emojis
|
||||
CHECKMARK = TICK
|
||||
CROSS_MARK = CROSS
|
||||
CHECK = SUCCESS
|
||||
FAIL = ERROR
|
||||
OK = SUCCESS
|
||||
NOT_OK = ERROR
|
||||
|
||||
227
bot/utils/help.py
Normal file
227
bot/utils/help.py
Normal file
@@ -0,0 +1,227 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ ░█▀▀░█▀█░█▀▄░█▀▀░█░█ ░█▀▄░█▀▀░█░█░█▀▀ ║
|
||||
# ║ ░█░░░█░█░█░█░█▀▀░▄▀▄ ░█░█░█▀▀░▀▄▀░▀▀█ ║
|
||||
# ║ ░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀░▀ ░▀▀░░▀▀▀░░▀░░▀▀▀ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 CodeX Devs — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/codexdev ║
|
||||
# ║ youtube ── https://youtube.com/@CodeXDevs ║
|
||||
# ║ github ── https://github.com/RayExo ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
import discord
|
||||
from utils.Tools import *
|
||||
from utils.cv2 import build_container
|
||||
from utils.emoji import REWIND, PREVIOUS, NEXT, FORWARD, DELETE, HOME
|
||||
from discord.ui import LayoutView, TextDisplay, Separator, ActionRow
|
||||
|
||||
|
||||
class Dropdown(discord.ui.Select):
|
||||
|
||||
def __init__(self, ctx, options, placeholder="Choose a Category for Help"):
|
||||
super().__init__(placeholder=placeholder,
|
||||
min_values=1,
|
||||
max_values=1,
|
||||
options=options)
|
||||
self.invoker = ctx.author
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
if self.invoker == interaction.user:
|
||||
index = self.view.find_index_from_select(self.values[0])
|
||||
if not index:
|
||||
index = 0
|
||||
await self.view.set_page(index, interaction)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
"You must run this command to interact with it.", ephemeral=True)
|
||||
|
||||
|
||||
class View(LayoutView):
|
||||
|
||||
def __init__(self, mapping: dict, ctx, homeembed, ui: int):
|
||||
super().__init__(timeout=None)
|
||||
self.mapping = mapping
|
||||
self.ctx = ctx
|
||||
self.index = 0
|
||||
self.current_page = 0
|
||||
self.ui = ui
|
||||
|
||||
self.options, self.pages, self.total_pages = self.gen_pages(homeembed)
|
||||
self.pages[0]['footer'] = f"• Help page 1/{self.total_pages} | Requested by: {self.ctx.author.display_name}"
|
||||
self._rebuild()
|
||||
|
||||
def _rebuild(self):
|
||||
self.clear_items()
|
||||
page = self.pages[self.index]
|
||||
page['footer'] = f"• Help page {self.index + 1}/{self.total_pages} | Requested by: {self.ctx.author.display_name}"
|
||||
|
||||
# Build container items (text content)
|
||||
items = []
|
||||
if page.get('title'):
|
||||
items.append(TextDisplay(f"**{page['title']}**"))
|
||||
if page.get('description'):
|
||||
if items:
|
||||
items.append(Separator(visible=True))
|
||||
items.append(TextDisplay(page['description']))
|
||||
for name, value in page.get('fields', []):
|
||||
items.append(Separator(visible=True))
|
||||
items.append(TextDisplay(f"**{name}**\n{value}"))
|
||||
|
||||
# Build buttons
|
||||
is_first = self.index == 0
|
||||
is_last = self.index >= len(self.pages) - 1
|
||||
|
||||
homeB = discord.ui.Button(label="", emoji=REWIND, style=discord.ButtonStyle.secondary, disabled=is_first)
|
||||
backB = discord.ui.Button(label="", emoji=PREVIOUS, style=discord.ButtonStyle.secondary, disabled=is_first)
|
||||
quitB = discord.ui.Button(label="", emoji=DELETE, style=discord.ButtonStyle.danger)
|
||||
nextB = discord.ui.Button(label="", emoji=NEXT, style=discord.ButtonStyle.secondary, disabled=is_last)
|
||||
lastB = discord.ui.Button(label="", emoji=FORWARD, style=discord.ButtonStyle.secondary, disabled=is_last)
|
||||
|
||||
homeB.callback = self._home_cb
|
||||
backB.callback = self._back_cb
|
||||
quitB.callback = self._quit_cb
|
||||
nextB.callback = self._next_cb
|
||||
lastB.callback = self._last_cb
|
||||
|
||||
# Add buttons ActionRow inside the container
|
||||
items.append(ActionRow(homeB, backB, quitB, nextB, lastB))
|
||||
|
||||
# Add dropdowns inside the container
|
||||
if self.ui == 0:
|
||||
items.append(ActionRow(Dropdown(ctx=self.ctx, options=self.options)))
|
||||
elif self.ui == 2:
|
||||
mid = len(self.options) // 2
|
||||
o1, o2 = self.options[:mid], self.options[mid:]
|
||||
if o1:
|
||||
items.append(ActionRow(Dropdown(ctx=self.ctx, options=o1, placeholder="Main Commands")))
|
||||
if o2:
|
||||
items.append(ActionRow(Dropdown(ctx=self.ctx, options=o2, placeholder="Extra Commands")))
|
||||
elif self.ui == 3:
|
||||
items.append(ActionRow(Dropdown(ctx=self.ctx, options=self.options)))
|
||||
|
||||
# Add footer after controls
|
||||
if page.get('footer'):
|
||||
items.append(Separator(visible=True))
|
||||
items.append(TextDisplay(f"*{page['footer']}*"))
|
||||
|
||||
# Build the single container with everything inside
|
||||
self.add_item(build_container(*items))
|
||||
|
||||
async def _check(self, interaction):
|
||||
if interaction.user != self.ctx.author:
|
||||
await interaction.response.send_message("You must run this command to interact with it.", ephemeral=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _home_cb(self, interaction):
|
||||
if await self._check(interaction):
|
||||
await self.set_page(0, interaction)
|
||||
|
||||
async def _back_cb(self, interaction):
|
||||
if await self._check(interaction):
|
||||
await self.set_page(self.index - 1 if self.index > 0 else len(self.pages) - 1, interaction)
|
||||
|
||||
async def _quit_cb(self, interaction):
|
||||
if await self._check(interaction):
|
||||
await interaction.response.defer()
|
||||
await interaction.delete_original_response()
|
||||
|
||||
async def _next_cb(self, interaction):
|
||||
if await self._check(interaction):
|
||||
await self.set_page(self.index + 1 if self.index < len(self.pages) - 1 else 0, interaction)
|
||||
|
||||
async def _last_cb(self, interaction):
|
||||
if await self._check(interaction):
|
||||
await self.set_page(len(self.pages) - 1, interaction)
|
||||
|
||||
def find_index_from_select(self, value):
|
||||
i = 0
|
||||
used_labels = set()
|
||||
for cog in self.get_cogs():
|
||||
if cog.__class__.__name__ == "Roleplay":
|
||||
continue
|
||||
if "help_custom" in dir(cog):
|
||||
_, label, _ = cog.help_custom()
|
||||
original_label = label
|
||||
counter = 1
|
||||
while label in used_labels:
|
||||
label = f"{original_label} {counter}"
|
||||
counter += 1
|
||||
used_labels.add(label)
|
||||
if label == value or value.startswith(original_label + " "):
|
||||
return i + 1
|
||||
i += 1
|
||||
return 0
|
||||
|
||||
def get_cogs(self):
|
||||
return list(self.mapping.keys())
|
||||
|
||||
def gen_pages(self, homeembed):
|
||||
options, pages = [], []
|
||||
total_pages = 0
|
||||
used_labels = set()
|
||||
|
||||
options.append(discord.SelectOption(label="Home", emoji=HOME, description=""))
|
||||
|
||||
# Convert homeembed (CV2Embed) to page data
|
||||
if hasattr(homeembed, '_title'):
|
||||
home_page = {
|
||||
'title': homeembed._title or '',
|
||||
'description': homeembed._description or '',
|
||||
'fields': list(homeembed._fields) if hasattr(homeembed, '_fields') else [],
|
||||
'footer': None
|
||||
}
|
||||
else:
|
||||
home_page = {
|
||||
'title': getattr(homeembed, 'title', '') or '',
|
||||
'description': getattr(homeembed, 'description', '') or '',
|
||||
'fields': [(f.name, f.value) for f in homeembed.fields] if hasattr(homeembed, 'fields') and homeembed.fields else [],
|
||||
'footer': homeembed.footer.text if hasattr(homeembed, 'footer') and homeembed.footer else None
|
||||
}
|
||||
|
||||
pages.append(home_page)
|
||||
total_pages += 1
|
||||
used_labels.add("Home")
|
||||
|
||||
for cog in self.get_cogs():
|
||||
if cog.__class__.__name__ == "Roleplay":
|
||||
continue
|
||||
if "help_custom" in dir(cog):
|
||||
emoji, label, description = cog.help_custom()
|
||||
original_label = label
|
||||
counter = 1
|
||||
while label in used_labels:
|
||||
label = f"{original_label} {counter}"
|
||||
counter += 1
|
||||
used_labels.add(label)
|
||||
options.append(discord.SelectOption(label=label, emoji=emoji, description=description))
|
||||
|
||||
fields = []
|
||||
for command in cog.get_commands():
|
||||
params = ""
|
||||
for param in command.clean_params:
|
||||
if param not in ["self", "ctx"]:
|
||||
params += f" <{param}>"
|
||||
help_text = command.help or "No description available"
|
||||
if len(help_text) > 1020:
|
||||
help_text = help_text[:1017] + "..."
|
||||
fields.append((f"{command.name}{params}", f"{help_text}\n•"))
|
||||
|
||||
pages.append({
|
||||
'title': f"{emoji} {original_label}",
|
||||
'description': '',
|
||||
'fields': fields,
|
||||
'footer': None
|
||||
})
|
||||
total_pages += 1
|
||||
|
||||
return options, pages, total_pages
|
||||
|
||||
async def set_page(self, page, interaction):
|
||||
self.index = page
|
||||
self.current_page = page
|
||||
self._rebuild()
|
||||
await interaction.response.edit_message(view=self)
|
||||
226
bot/utils/paginator.py
Normal file
226
bot/utils/paginator.py
Normal file
@@ -0,0 +1,226 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ ░█▀▀░█▀█░█▀▄░█▀▀░█░█ ░█▀▄░█▀▀░█░█░█▀▀ ║
|
||||
# ║ ░█░░░█░█░█░█░█▀▀░▄▀▄ ░█░█░█▀▀░▀▄▀░▀▀█ ║
|
||||
# ║ ░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀░▀ ░▀▀░░▀▀▀░░▀░░▀▀▀ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 CodeX Devs — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/codexdev ║
|
||||
# ║ youtube ── https://youtube.com/@CodeXDevs ║
|
||||
# ║ github ── https://github.com/RayExo ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord.ext import menus
|
||||
from discord.ext.commands import Context
|
||||
from discord import Interaction, ButtonStyle
|
||||
from utils.emoji import REWIND, PREVIOUS, NEXT, FORWARD, DELETE
|
||||
|
||||
|
||||
class Paginator(discord.ui.View):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: menus.PageSource,
|
||||
*,
|
||||
ctx: Context | Interaction,
|
||||
check_embeds: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
self.source: menus.PageSource = source
|
||||
self.check_embeds: bool = check_embeds
|
||||
self.ctx = ctx
|
||||
self.message: Optional[discord.Message] = None
|
||||
self.current_page: int = 0
|
||||
self.clear_items()
|
||||
self.fill_items()
|
||||
|
||||
def fill_items(self) -> None:
|
||||
|
||||
if self.source.is_paginating():
|
||||
max_pages = self.source.get_max_pages()
|
||||
use_last_and_first = max_pages is not None and max_pages >= 2
|
||||
if use_last_and_first:
|
||||
self.add_item(self.first_page_button)
|
||||
self.add_item(self.previous_page_button)
|
||||
self.add_item(self.stop_button)
|
||||
self.add_item(self.next_page_button)
|
||||
if use_last_and_first:
|
||||
self.add_item(self.last_page_button)
|
||||
#self.add_item(self.stop_button)
|
||||
|
||||
async def _get_kwargs_from_page(self, page: int) -> Dict[str, Any]:
|
||||
value = await discord.utils.maybe_coroutine(self.source.format_page,
|
||||
self, page)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
elif isinstance(value, str):
|
||||
return {'content': value, 'embed': None}
|
||||
elif isinstance(value, discord.Embed):
|
||||
return {'embed': value, 'content': None}
|
||||
else:
|
||||
return {}
|
||||
|
||||
async def show_page(self, interaction: discord.Interaction,
|
||||
page_number: int) -> None:
|
||||
page = await self.source.get_page(page_number)
|
||||
self.current_page = page_number
|
||||
kwargs = await self._get_kwargs_from_page(page)
|
||||
self._update_labels(page_number)
|
||||
if kwargs:
|
||||
if interaction.response.is_done():
|
||||
if self.message:
|
||||
await self.message.edit(**kwargs, view=self)
|
||||
else:
|
||||
await interaction.response.edit_message(**kwargs, view=self)
|
||||
|
||||
def _update_labels(self, page_number: int) -> None:
|
||||
self.first_page_button.disabled = page_number == 0
|
||||
self.next_page_button.disabled = False
|
||||
self.previous_page_button.disabled = False
|
||||
|
||||
max_pages = self.source.get_max_pages()
|
||||
if max_pages is not None:
|
||||
self.last_page_button.disabled = (page_number + 1) >= max_pages
|
||||
if (page_number + 1) >= max_pages:
|
||||
self.next_page_button.disabled = True
|
||||
if page_number == 0:
|
||||
self.previous_page_button.disabled = True
|
||||
|
||||
async def show_checked_page(self, interaction: discord.Interaction,
|
||||
page_number: int) -> None:
|
||||
max_pages = self.source.get_max_pages()
|
||||
try:
|
||||
if max_pages is None:
|
||||
await self.show_page(interaction, page_number)
|
||||
elif max_pages > page_number >= 0:
|
||||
await self.show_page(interaction, page_number)
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
async def interaction_check(self,
|
||||
interaction: discord.Interaction) -> bool:
|
||||
if isinstance(self.ctx, Interaction):
|
||||
if interaction.user and interaction.user.id in (
|
||||
self.ctx.client.owner_id, self.ctx.user.id):
|
||||
return True
|
||||
await interaction.response.send_message("Uh oh! That message doesn't belong to you. You must run this command to interact with it.",
|
||||
ephemeral=True)
|
||||
return False
|
||||
if interaction.user and interaction.user.id in (self.ctx.bot.owner_id,
|
||||
self.ctx.author.id):
|
||||
return True
|
||||
await interaction.response.send_message("Uh oh! That message doesn't belong to you. You must run this command to interact with it.",
|
||||
ephemeral=True)
|
||||
return False
|
||||
|
||||
async def on_timeout(self) -> None:
|
||||
if self.message:
|
||||
for child in self.children:
|
||||
if isinstance(child, discord.ui.Button):
|
||||
child.disabled = True
|
||||
try:
|
||||
await self.message.edit(view=self)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
|
||||
async def on_error(self, interaction: discord.Interaction,
|
||||
error: Exception, item: discord.ui.Item) -> None:
|
||||
if interaction.response.is_done():
|
||||
await interaction.followup.send('An unknown error occurred, sorry',
|
||||
ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
'An unknown error occurred, sorry', ephemeral=True)
|
||||
|
||||
def update_styles(self, **kwargs):
|
||||
"""
|
||||
Update the button styles and emojis
|
||||
"""
|
||||
|
||||
# Update the button emojis
|
||||
self.first_page_button.emoji = kwargs.get('first_button_emoji') or REWIND
|
||||
self.previous_page_button.emoji = kwargs.get(
|
||||
'previous_button_emoji') or PREVIOUS
|
||||
self.next_page_button.emoji = kwargs.get('next_button_emoji') or NEXT
|
||||
self.last_page_button.emoji = kwargs.get('last_button_emoji') or FORWARD
|
||||
self.stop_button.emoji = kwargs.get(
|
||||
'stop_button_emoji') or DELETE
|
||||
|
||||
# Update the Button Styles
|
||||
self.first_page_button.style = kwargs.get(
|
||||
'first_button_style') or ButtonStyle.secondary
|
||||
self.previous_page_button.style = kwargs.get(
|
||||
'previous_button_style') or ButtonStyle.secondary
|
||||
self.stop_button.style = kwargs.get(
|
||||
'stop_button_style') or ButtonStyle.red
|
||||
self.next_page_button.style = kwargs.get(
|
||||
'next_button_style') or ButtonStyle.secondary
|
||||
self.last_page_button.style = kwargs.get(
|
||||
'last_button_style') or ButtonStyle.secondary
|
||||
|
||||
async def paginate(self,
|
||||
*,
|
||||
content: Optional[str] = None,
|
||||
ephemeral: bool = False,
|
||||
**kwargs) -> None:
|
||||
self.update_styles(**kwargs)
|
||||
if isinstance(self.ctx, Interaction):
|
||||
await self.source._prepare_once()
|
||||
page = await self.source.get_page(0)
|
||||
kwargs = await self._get_kwargs_from_page(page)
|
||||
if content:
|
||||
kwargs.setdefault('content', content)
|
||||
self._update_labels(0)
|
||||
self.message = await self.ctx.response.send_message(
|
||||
**kwargs, view=self, ephemeral=ephemeral)
|
||||
return
|
||||
|
||||
await self.source._prepare_once()
|
||||
page = await self.source.get_page(0)
|
||||
kwargs = await self._get_kwargs_from_page(page)
|
||||
if content:
|
||||
kwargs.setdefault('content', content)
|
||||
self._update_labels(0)
|
||||
self.message = await self.ctx.send(**kwargs,
|
||||
view=self,
|
||||
ephemeral=ephemeral)
|
||||
|
||||
@discord.ui.button()
|
||||
async def first_page_button(self, interaction: discord.Interaction,
|
||||
button: discord.ui.Button):
|
||||
"""Go to the first page"""
|
||||
await self.show_page(interaction, 0)
|
||||
|
||||
@discord.ui.button()
|
||||
async def previous_page_button(self, interaction: discord.Interaction,
|
||||
button: discord.ui.Button):
|
||||
"""Go to the previous page"""
|
||||
await self.show_checked_page(interaction, self.current_page - 1)
|
||||
|
||||
@discord.ui.button()
|
||||
async def stop_button(self, interaction: discord.Interaction,
|
||||
button: discord.ui.Button):
|
||||
"""Stops the pagination session."""
|
||||
await interaction.response.defer()
|
||||
await interaction.delete_original_response()
|
||||
self.stop()
|
||||
|
||||
@discord.ui.button()
|
||||
async def next_page_button(self, interaction: discord.Interaction,
|
||||
button: discord.ui.Button):
|
||||
"""Go to the next page"""
|
||||
await self.show_checked_page(interaction, self.current_page + 1)
|
||||
|
||||
@discord.ui.button()
|
||||
async def last_page_button(self, interaction: discord.Interaction,
|
||||
button: discord.ui.Button):
|
||||
"""Go to the last page"""
|
||||
await self.show_page(interaction, self.source.get_max_pages() - 1)
|
||||
111
bot/utils/paginators.py
Normal file
111
bot/utils/paginators.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ ░█▀▀░█▀█░█▀▄░█▀▀░█░█ ░█▀▄░█▀▀░█░█░█▀▀ ║
|
||||
# ║ ░█░░░█░█░█░█░█▀▀░▄▀▄ ░█░█░█▀▀░▀▄▀░▀▀█ ║
|
||||
# ║ ░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀░▀ ░▀▀░░▀▀▀░░▀░░▀▀▀ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 CodeX Devs — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/codexdev ║
|
||||
# ║ youtube ── https://youtube.com/@CodeXDevs ║
|
||||
# ║ github ── https://github.com/RayExo ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
from __future__ import annotations
|
||||
import discord
|
||||
from utils.config import BotName
|
||||
try:
|
||||
from discord.ext import menus
|
||||
from discord.ext import commands
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ImportError(
|
||||
"discord-ext-menus is required. Install it with: pip install git+https://github.com/Rapptz/discord-ext-menus"
|
||||
) from exc
|
||||
|
||||
from .paginator import Paginator as EmbedPaginator
|
||||
from discord.ext.commands import Context, Paginator as CmdPaginator
|
||||
from typing import Any, List
|
||||
|
||||
|
||||
class FieldPagePaginator(menus.ListPageSource):
|
||||
|
||||
def __init__(self,
|
||||
entries: list[tuple[Any, Any]],
|
||||
*,
|
||||
per_page: int = 10,
|
||||
inline: bool = False,
|
||||
**kwargs) -> None:
|
||||
super().__init__(entries, per_page=per_page)
|
||||
self.embed: discord.Embed = discord.Embed(
|
||||
title=kwargs.get('title'),
|
||||
description=kwargs.get('description'),
|
||||
color=0xFF0000)
|
||||
self.inline: bool = inline
|
||||
|
||||
async def format_page(self, menu: EmbedPaginator,
|
||||
entries: list[tuple[Any, Any]]) -> discord.Embed:
|
||||
self.embed.clear_fields()
|
||||
|
||||
for key, value in entries:
|
||||
self.embed.add_field(name=key, value=value, inline=self.inline)
|
||||
|
||||
maximum = self.get_max_pages()
|
||||
if maximum > 1:
|
||||
text = f'• Page {menu.current_page + 1}/{maximum} | Requested by {menu.ctx.author.display_name}'
|
||||
self.embed.set_footer(
|
||||
text=text,
|
||||
icon_url=
|
||||
"https://cdn.discordapp.com/icons/699587669059174461/f689b4366447d5a23eda8d0ec749c1ba.png?width=115&height=115"
|
||||
)
|
||||
#self.embed.timestamp = discord.utils.utcnow()
|
||||
return self.embed
|
||||
|
||||
|
||||
class TextPaginator(menus.ListPageSource):
|
||||
|
||||
def __init__(self, text, *, prefix='```', suffix='```', max_size=2000):
|
||||
pages = CmdPaginator(prefix=prefix,
|
||||
suffix=suffix,
|
||||
max_size=max_size - 200)
|
||||
for line in text.split('\n'):
|
||||
pages.add_line(line)
|
||||
|
||||
super().__init__(entries=pages.pages, per_page=1)
|
||||
|
||||
async def format_page(self, menu, content):
|
||||
maximum = self.get_max_pages()
|
||||
if maximum > 1:
|
||||
return f'{content} {BotName} • Page {menu.current_page + 1}/{maximum}'
|
||||
return content
|
||||
|
||||
|
||||
class DescriptionEmbedPaginator(menus.ListPageSource):
|
||||
|
||||
def __init__(self,
|
||||
entries: list[Any],
|
||||
*,
|
||||
per_page: int = 10,
|
||||
**kwargs) -> None:
|
||||
super().__init__(entries, per_page=per_page)
|
||||
self.embed: discord.Embed = discord.Embed(
|
||||
title=kwargs.get('title'),
|
||||
color=0xFF0000,
|
||||
)
|
||||
|
||||
async def format_page(self, menu: EmbedPaginator,
|
||||
entries: list[tuple[Any, Any]]) -> discord.Embed:
|
||||
self.embed.clear_fields()
|
||||
|
||||
self.embed.description = '\n'.join(entries)
|
||||
#self.embed.timestamp = discord.utils.utcnow()
|
||||
maximum = self.get_max_pages()
|
||||
if maximum > 1:
|
||||
text = f'• Page {menu.current_page + 1}/{maximum} | Requested by {menu.ctx.author.display_name}'
|
||||
self.embed.set_footer(
|
||||
text=text,
|
||||
icon_url=
|
||||
"https://cdn.discordapp.com/icons/699587669059174461/f689b4366447d5a23eda8d0ec749c1ba.png?width=115&height=115"
|
||||
)
|
||||
|
||||
return self.embed
|
||||
66
bot/utils/safe_parse.py
Normal file
66
bot/utils/safe_parse.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ Safe parsing helpers — no eval() on untrusted data ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import operator
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_role_id_list(raw: str | None) -> list[int]:
|
||||
"""Parse a stored role-ID list (JSON or legacy Python repr)."""
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
parsed = ast.literal_eval(raw)
|
||||
except (ValueError, SyntaxError):
|
||||
return []
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
return [int(x) for x in parsed if str(x).isdigit()]
|
||||
|
||||
|
||||
def dump_role_id_list(ids: list[int]) -> str:
|
||||
return json.dumps(ids)
|
||||
|
||||
|
||||
_MATH_ALLOWED = re.compile(r"^[\d+\-*/().\s]+$")
|
||||
|
||||
|
||||
def _eval_math_node(node: ast.AST) -> float:
|
||||
if isinstance(node, ast.Expression):
|
||||
return _eval_math_node(node.body)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return float(node.value)
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
|
||||
value = _eval_math_node(node.operand)
|
||||
return value if isinstance(node.op, ast.UAdd) else -value
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)):
|
||||
left = _eval_math_node(node.left)
|
||||
right = _eval_math_node(node.right)
|
||||
ops = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
}
|
||||
if isinstance(node.op, ast.Div) and right == 0:
|
||||
raise ZeroDivisionError("division by zero")
|
||||
return ops[type(node.op)](left, right)
|
||||
raise ValueError("unsupported expression")
|
||||
|
||||
|
||||
def safe_math_eval(expression: str) -> str:
|
||||
"""Evaluate basic arithmetic safely (digits, +, -, *, /, parentheses)."""
|
||||
expr = expression.strip()
|
||||
if not expr or not _MATH_ALLOWED.match(expr):
|
||||
raise ValueError("invalid characters in expression")
|
||||
tree = ast.parse(expr, mode="eval")
|
||||
return str(_eval_math_node(tree))
|
||||
215
bot/utils/sync_emojis.py
Normal file
215
bot/utils/sync_emojis.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ ░█▀▀░█▀█░█▀▄░█▀▀░█░█ ░█▀▄░█▀▀░█░█░█▀▀ ║
|
||||
# ║ ░█░░░█░█░█░█░█▀▀░▄▀▄ ░█░█░█▀▀░▀▄▀░▀▀█ ║
|
||||
# ║ ░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀░▀ ░▀▀░░▀▀▀░░▀░░▀▀▀ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 CodeX Devs — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/codexdev ║
|
||||
# ║ youtube ── https://youtube.com/@CodeXDevs ║
|
||||
# ║ github ── https://github.com/RayExo ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
"""
|
||||
Application Emoji Sync Utility
|
||||
Reads all custom Discord emojis from utils/emoji.py, checks them against
|
||||
the bot's application emojis, uploads any that are missing, and patches
|
||||
emoji.py in-place with corrected IDs.
|
||||
|
||||
Controlled by EMOJI_SYNC in .env:
|
||||
EMOJI_SYNC="true" → runs on every startup
|
||||
EMOJI_SYNC="false" → skipped entirely
|
||||
|
||||
If emoji.py is patched (new uploads or ID fixes), the bot automatically
|
||||
restarts so the fresh IDs are loaded into memory.
|
||||
|
||||
Call `run_sync(token)` once inside on_ready.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import base64
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from colorama import Fore, Style, init
|
||||
|
||||
init(autoreset=True)
|
||||
|
||||
EMOJI_PY_PATH = os.path.join(os.path.dirname(__file__), "emoji.py")
|
||||
|
||||
|
||||
def _log(level: str, color: str, symbol: str, msg: str) -> None:
|
||||
print(f"{color}{symbol} {level}:{Style.RESET_ALL} {msg}")
|
||||
|
||||
def info(msg): _log("EmojiSync", Fore.CYAN, "◈", msg)
|
||||
def success(msg): _log("EmojiSync", Fore.GREEN, "✔", msg)
|
||||
def warning(msg): _log("EmojiSync", Fore.YELLOW, "↻", msg)
|
||||
def error(msg): _log("EmojiSync", Fore.RED, "✖", msg)
|
||||
def system(msg): _log("EmojiSync", Fore.MAGENTA, "★", msg)
|
||||
|
||||
|
||||
def _restart() -> None:
|
||||
"""Replace the current process with a fresh copy of itself."""
|
||||
system(f"Restarting bot to load updated emoji IDs...")
|
||||
# Flush stdout so the message is visible before the process is replaced
|
||||
sys.stdout.flush()
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
|
||||
|
||||
async def _fetch_emoji_image(session: aiohttp.ClientSession, emoji_id: str, animated: bool):
|
||||
ext = "gif" if animated else "webp"
|
||||
url = f"https://cdn.discordapp.com/emojis/{emoji_id}.{ext}"
|
||||
try:
|
||||
async with session.get(url, allow_redirects=True) as r:
|
||||
if r.status == 200:
|
||||
return await r.read()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def run_sync(token: str) -> None:
|
||||
"""
|
||||
Async emoji sync. Pass the bot token directly.
|
||||
Respects the EMOJI_SYNC env var — set to "false" to disable.
|
||||
Triggers an automatic restart when emoji.py is patched.
|
||||
"""
|
||||
# ── Toggle check ──────────────────────────────────────────────────────────
|
||||
enabled = os.getenv("EMOJI_SYNC", "true").strip().lower()
|
||||
if enabled != "true":
|
||||
info(f"Disabled via EMOJI_SYNC={enabled!r} — skipping.")
|
||||
return
|
||||
|
||||
if not token:
|
||||
warning("No token provided — skipping EmojiSync.")
|
||||
return
|
||||
|
||||
# ── Read emoji.py ─────────────────────────────────────────────────────────
|
||||
try:
|
||||
with open(EMOJI_PY_PATH, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as err:
|
||||
error(f"Could not read emoji.py ({err})")
|
||||
return
|
||||
|
||||
matches = set(re.findall(r"<(a?):(\w+):(\d+)>", content))
|
||||
if not matches:
|
||||
info("No custom emojis found in emoji.py — nothing to sync.")
|
||||
return
|
||||
|
||||
system(f"Starting Application Emoji Sync — {len(matches)} unique emojis found in emoji.py")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bot {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession(headers=headers) as session:
|
||||
# Fetch bot application ID
|
||||
async with session.get("https://discord.com/api/v10/users/@me") as r:
|
||||
if r.status != 200:
|
||||
error(f"Failed to fetch bot info [HTTP {r.status}]")
|
||||
return
|
||||
app_id = (await r.json()).get("id")
|
||||
|
||||
# Fetch existing application emojis
|
||||
async with session.get(f"https://discord.com/api/v10/applications/{app_id}/emojis") as r:
|
||||
if r.status != 200:
|
||||
error(f"Failed to fetch application emojis [HTTP {r.status}]")
|
||||
return
|
||||
data = await r.json()
|
||||
app_emojis: list = data.get("items", []) if isinstance(data, dict) else data
|
||||
|
||||
info(
|
||||
f"Found {Fore.YELLOW}{len(matches)}{Style.RESET_ALL} templates "
|
||||
f"{Fore.LIGHTBLACK_EX}|{Style.RESET_ALL} "
|
||||
f"Application hosts {Fore.GREEN}{len(app_emojis)}{Style.RESET_ALL} emojis"
|
||||
)
|
||||
|
||||
updated = False
|
||||
skipped = uploaded = fixed = failed = 0
|
||||
|
||||
for animated_str, name, old_id in matches:
|
||||
animated = animated_str == "a"
|
||||
|
||||
existing = (
|
||||
next((e for e in app_emojis if e["id"] == old_id), None)
|
||||
or next((e for e in app_emojis if e["name"] == name), None)
|
||||
)
|
||||
|
||||
if existing:
|
||||
new_id = existing["id"]
|
||||
if old_id != new_id:
|
||||
old_str = f"<{animated_str}:{name}:{old_id}>"
|
||||
new_str = f"<{animated_str}:{existing['name']}:{new_id}>"
|
||||
content = content.replace(old_str, new_str)
|
||||
updated = True
|
||||
fixed += 1
|
||||
warning(f"Auto-fixing ID: {name} {Fore.LIGHTBLACK_EX}-> {new_id}")
|
||||
else:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Not found — upload it
|
||||
info(f"Uploading: {name} {Fore.LIGHTBLACK_EX}(not in application emojis)")
|
||||
|
||||
image_data = await _fetch_emoji_image(session, old_id, animated)
|
||||
if not image_data:
|
||||
error(f"Could not download image for {name} [ID: {old_id}]")
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
mime = "image/gif" if animated else "image/webp"
|
||||
b64 = base64.b64encode(image_data).decode("utf-8")
|
||||
image_uri = f"data:{mime};base64,{b64}"
|
||||
|
||||
async with session.post(
|
||||
f"https://discord.com/api/v10/applications/{app_id}/emojis",
|
||||
json={"name": name, "image": image_uri},
|
||||
) as r2:
|
||||
if r2.status in (200, 201):
|
||||
new_emoji = await r2.json()
|
||||
new_id = new_emoji["id"]
|
||||
old_str = f"<{animated_str}:{name}:{old_id}>"
|
||||
new_str = f"<{animated_str}:{new_emoji['name']}:{new_id}>"
|
||||
content = content.replace(old_str, new_str)
|
||||
app_emojis.append(new_emoji)
|
||||
updated = True
|
||||
uploaded += 1
|
||||
success(f"Uploaded: {name} {Fore.LIGHTBLACK_EX}[saved as ID: {new_id}]")
|
||||
else:
|
||||
resp_text = await r2.text()
|
||||
error(f"Discord rejected {name} -> {resp_text}")
|
||||
failed += 1
|
||||
|
||||
# Small delay to respect Discord rate limits
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# ── Write patched emoji.py ────────────────────────────────────────────────
|
||||
if updated:
|
||||
try:
|
||||
with open(EMOJI_PY_PATH, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
success("emoji.py patched in-place to reflect current API state.")
|
||||
except Exception as err:
|
||||
error(f"Could not write patched emoji.py ({err})")
|
||||
updated = False # don't restart if we couldn't save
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
parts = []
|
||||
if skipped: parts.append(f"{Fore.GREEN}{skipped} already matching{Style.RESET_ALL}")
|
||||
if fixed: parts.append(f"{Fore.YELLOW}{fixed} ID mismatches fixed{Style.RESET_ALL}")
|
||||
if uploaded: parts.append(f"{Fore.CYAN}{uploaded} newly uploaded{Style.RESET_ALL}")
|
||||
if failed: parts.append(f"{Fore.RED}{failed} failures{Style.RESET_ALL}")
|
||||
|
||||
if parts:
|
||||
system("Sync complete: " + f" {Fore.LIGHTBLACK_EX}|{Style.RESET_ALL} ".join(parts))
|
||||
else:
|
||||
system("Sync complete: nothing to do.")
|
||||
|
||||
# ── Auto-restart if emoji.py was changed ─────────────────────────────────
|
||||
if updated:
|
||||
_restart()
|
||||
331
bot/utils/tunnel.py
Normal file
331
bot/utils/tunnel.py
Normal file
@@ -0,0 +1,331 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ ░█▀▀░█▀█░█▀▄░█▀▀░█░█ ░█▀▄░█▀▀░█░█░█▀▀ ║
|
||||
# ║ ░█░░░█░█░█░█░█▀▀░▄▀▄ ░█░█░█▀▀░▀▄▀░▀▀█ ║
|
||||
# ║ ░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀░▀ ░▀▀░░▀▀▀░░▀░░▀▀▀ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 CodeX Devs — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/codexdev ║
|
||||
# ║ youtube ── https://youtube.com/@CodeXDevs ║
|
||||
# ║ github ── https://github.com/RayExo ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
"""
|
||||
HTTPS Tunnel for the ZyroX API — Cloudflare Tunnel via pycloudflared.
|
||||
|
||||
Zero manual installs. Just:
|
||||
1. pip install pycloudflared (already in requirements.txt)
|
||||
2. Get your tunnel token from the Cloudflare dashboard (browser only, no CLI)
|
||||
3. Set CF_TUNNEL_TOKEN in .env
|
||||
|
||||
That's it. The cloudflared binary is downloaded automatically on first run.
|
||||
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
How to get your CF_TUNNEL_TOKEN (browser only, no CLI needed)
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
1. Go to https://one.dash.cloudflare.com
|
||||
2. Networks → Tunnels → Create a tunnel
|
||||
3. Choose "Cloudflared" as connector type
|
||||
4. Give it a name (e.g. zyrox-api) and click Save
|
||||
5. On the "Install connector" step, find the token in the command shown:
|
||||
cloudflared tunnel run --token <YOUR_TOKEN_HERE>
|
||||
Copy just the token string.
|
||||
6. Go to "Published Application Routes" tab → Add a Published Application Routes:
|
||||
Subdomain: zyrox-api Domain: yourdomain.com Service: http://localhost:8000
|
||||
(Or use any domain you have on Cloudflare)
|
||||
7. Paste the token into your .env:
|
||||
CF_TUNNEL_TOKEN = "eyJhIjoiX..."
|
||||
CF_TUNNEL_URL = "https://zyrox-api.yourdomain.com"
|
||||
|
||||
That's the permanent URL — never changes between restarts.
|
||||
Unlimited bandwidth, unlimited requests, free.
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import subprocess
|
||||
|
||||
# ── env vars ──────────────────────────────────────────────────────────────────
|
||||
from utils.config import TUNNEL_ENABLED, TUNNEL_ALLOW_BINARY_DOWNLOAD, API_PORT
|
||||
|
||||
CF_TUNNEL_TOKEN = os.getenv("CF_TUNNEL_TOKEN", "").strip() # token from Cloudflare dashboard
|
||||
CF_TUNNEL_URL = os.getenv("CF_TUNNEL_URL", "").strip() # e.g. https://api.yourdomain.com
|
||||
|
||||
# ── colours ───────────────────────────────────────────────────────────────────
|
||||
_CYAN = "\033[36m"
|
||||
_GREEN = "\033[32m"
|
||||
_YELLOW = "\033[33m"
|
||||
_RED = "\033[31m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
def _ensure_pycloudflared() -> bool:
|
||||
"""Return True if pycloudflared is installed (no automatic pip install)."""
|
||||
try:
|
||||
import pycloudflared # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
print(
|
||||
f"{_RED}◈ Tunnel: pycloudflared is not installed.\n"
|
||||
f" Install manually: pip install pycloudflared{_RESET}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _download_cloudflared_direct() -> str | None:
|
||||
"""
|
||||
Last-resort: download the cloudflared binary directly from GitHub
|
||||
into a local ./bin/ directory. Works even if pycloudflared fails.
|
||||
"""
|
||||
import platform
|
||||
import urllib.request
|
||||
import stat
|
||||
|
||||
system = platform.system().lower() # linux / darwin / windows
|
||||
machine = platform.machine().lower() # x86_64 / aarch64 / arm64
|
||||
|
||||
# Map to Cloudflare's release naming
|
||||
if system == "linux":
|
||||
if machine in ("aarch64", "arm64"):
|
||||
asset = "cloudflared-linux-arm64"
|
||||
elif machine == "arm":
|
||||
asset = "cloudflared-linux-arm"
|
||||
else:
|
||||
asset = "cloudflared-linux-amd64"
|
||||
elif system == "darwin":
|
||||
asset = "cloudflared-darwin-amd64"
|
||||
elif system == "windows":
|
||||
asset = "cloudflared-windows-amd64.exe"
|
||||
else:
|
||||
print(f"{_RED}◈ Tunnel: unsupported OS '{system}' for direct download.{_RESET}")
|
||||
return None
|
||||
|
||||
url = f"https://github.com/cloudflare/cloudflared/releases/latest/download/{asset}"
|
||||
bin_dir = os.path.join(os.path.dirname(__file__), "..", "bin")
|
||||
os.makedirs(bin_dir, exist_ok=True)
|
||||
dest = os.path.join(bin_dir, asset)
|
||||
|
||||
if os.path.isfile(dest):
|
||||
# Already downloaded — just ensure it's executable
|
||||
pass
|
||||
else:
|
||||
print(f"{_YELLOW}◈ Tunnel: downloading cloudflared binary from GitHub…{_RESET}")
|
||||
try:
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
print(f"{_GREEN}◈ Tunnel: downloaded to {dest}{_RESET}")
|
||||
except Exception as exc:
|
||||
print(f"{_RED}◈ Tunnel: direct download failed — {exc}{_RESET}")
|
||||
return None
|
||||
|
||||
# Fix execute bit
|
||||
try:
|
||||
current = os.stat(dest).st_mode
|
||||
os.chmod(dest, current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return dest
|
||||
|
||||
|
||||
def _get_binary() -> str | None:
|
||||
"""
|
||||
Return a working path to the cloudflared binary.
|
||||
Strategy (in order):
|
||||
1. pycloudflared package (auto-installs package if missing, triggers binary download)
|
||||
2. System PATH
|
||||
3. Direct GitHub download into ./bin/
|
||||
"""
|
||||
import shutil
|
||||
import stat
|
||||
|
||||
# ── Step 0: check pycloudflared is installed ─────────────────────────────
|
||||
if not _ensure_pycloudflared():
|
||||
return None
|
||||
|
||||
candidates: list[str] = []
|
||||
|
||||
# ── Step 1: ask pycloudflared for the binary ───────────────────────────────
|
||||
try:
|
||||
import pycloudflared as _pcf
|
||||
|
||||
# cloudflared_path attribute (set after first download)
|
||||
path = getattr(_pcf, "cloudflared_path", None)
|
||||
if path:
|
||||
candidates.append(str(path))
|
||||
|
||||
# Walk the package directory for any cloudflared file
|
||||
pkg_dir = os.path.dirname(_pcf.__file__)
|
||||
for fname in os.listdir(pkg_dir):
|
||||
full = os.path.join(pkg_dir, fname)
|
||||
if "cloudflared" in fname.lower() and os.path.isfile(full):
|
||||
candidates.append(full)
|
||||
|
||||
# pycloudflared ≥ 0.2 exposes a download() helper
|
||||
if not candidates or not any(os.path.isfile(c) for c in candidates):
|
||||
download_fn = getattr(_pcf, "download", None)
|
||||
if callable(download_fn):
|
||||
print(f"{_YELLOW}◈ Tunnel: triggering pycloudflared binary download…{_RESET}")
|
||||
try:
|
||||
downloaded = download_fn()
|
||||
if downloaded:
|
||||
candidates.append(str(downloaded))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ── Step 2: system PATH ────────────────────────────────────────────────────
|
||||
sys_path = shutil.which("cloudflared")
|
||||
if sys_path:
|
||||
candidates.append(sys_path)
|
||||
|
||||
# ── Step 3: validate each candidate ───────────────────────────────────────
|
||||
for candidate in candidates:
|
||||
if not os.path.isfile(candidate):
|
||||
continue
|
||||
# Fix execute bit (common issue in Pterodactyl containers)
|
||||
try:
|
||||
current = os.stat(candidate).st_mode
|
||||
if not (current & stat.S_IXUSR):
|
||||
os.chmod(candidate, current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
except OSError:
|
||||
pass
|
||||
# Smoke-test
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[candidate, "--version"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return candidate
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
|
||||
# ── Step 4: direct GitHub download (opt-in only) ─────────────────────────
|
||||
if not TUNNEL_ALLOW_BINARY_DOWNLOAD:
|
||||
print(
|
||||
f"{_YELLOW}◈ Tunnel: no working binary found.\n"
|
||||
f" Install pycloudflared manually, or set TUNNEL_ALLOW_BINARY_DOWNLOAD=true "
|
||||
f"to allow downloading cloudflared from GitHub.{_RESET}"
|
||||
)
|
||||
return None
|
||||
|
||||
print(f"{_YELLOW}◈ Tunnel: no working binary found — attempting direct download…{_RESET}")
|
||||
direct = _download_cloudflared_direct()
|
||||
if direct and os.path.isfile(direct):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[direct, "--version"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return direct
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _run_tunnel(binary: str, token: str, port: int, public_url: str) -> None:
|
||||
"""
|
||||
Blocking loop — runs:
|
||||
cloudflared tunnel --no-autoupdate run --token <token>
|
||||
Restarts automatically if the process exits.
|
||||
"""
|
||||
cmd = [
|
||||
binary,
|
||||
"tunnel",
|
||||
"--no-autoupdate",
|
||||
"--url", f"http://localhost:{port}",
|
||||
"run",
|
||||
"--token", token,
|
||||
]
|
||||
|
||||
first_run = True
|
||||
while True:
|
||||
if first_run:
|
||||
print(f"{_CYAN}◈ Tunnel: connecting to Cloudflare…{_RESET}")
|
||||
first_run = False
|
||||
else:
|
||||
print(f"{_YELLOW}◈ Tunnel: reconnecting…{_RESET}")
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
announced = False
|
||||
for line in proc.stdout:
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Only surface important lines — suppress the noisy info spam
|
||||
low = line.lower()
|
||||
if any(k in low for k in ("error", "warn", "registered", "connected", "failed", "unable")):
|
||||
print(f"{_CYAN} [cloudflared] {line}{_RESET}")
|
||||
|
||||
# Announce the live URL once the tunnel is up
|
||||
if not announced and ("registered tunnel connection" in low or "connection registered" in low):
|
||||
announced = True
|
||||
if public_url:
|
||||
print(f"{_GREEN}◈ Tunnel: API is live at {public_url}{_RESET}")
|
||||
print(f"{_CYAN} ↳ DASHBOARD_API_URL = {public_url}/api/v1{_RESET}")
|
||||
else:
|
||||
print(f"{_GREEN}◈ Tunnel: connected — check CF_TUNNEL_URL in .env for your public URL{_RESET}")
|
||||
|
||||
proc.wait()
|
||||
code = proc.returncode
|
||||
|
||||
except (FileNotFoundError, PermissionError) as exc:
|
||||
print(f"{_RED}◈ Tunnel: failed to execute binary at '{binary}' — {exc}{_RESET}")
|
||||
print(f"{_RED} Check: file exists, has execute permission, and matches the server OS/arch.{_RESET}")
|
||||
return
|
||||
except Exception as exc:
|
||||
print(f"{_RED}◈ Tunnel: unexpected error — {exc}{_RESET}")
|
||||
code = -1
|
||||
|
||||
print(f"{_YELLOW}◈ Tunnel: exited (code {code}), restarting in 5 s…{_RESET}")
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
def start_tunnel() -> None:
|
||||
"""
|
||||
Start the Cloudflare Tunnel in a background daemon thread.
|
||||
Called from CodeX.py after keep_alive().
|
||||
"""
|
||||
if not TUNNEL_ENABLED:
|
||||
print(f"{_YELLOW}◈ Tunnel: disabled via TUNNEL_ENABLED=false{_RESET}")
|
||||
return
|
||||
|
||||
if not CF_TUNNEL_TOKEN:
|
||||
print(
|
||||
f"{_YELLOW}◈ Tunnel: CF_TUNNEL_TOKEN is not set — tunnel skipped.\n"
|
||||
f" Get your token from https://one.dash.cloudflare.com → Networks → Tunnels{_RESET}"
|
||||
)
|
||||
return
|
||||
|
||||
binary = _get_binary()
|
||||
if not binary:
|
||||
print(
|
||||
f"{_RED}◈ Tunnel: could not obtain a working cloudflared binary after all attempts.\n"
|
||||
f" Tunnel will not start. Check your network or install manually:\n"
|
||||
f" pip install pycloudflared{_RESET}"
|
||||
)
|
||||
return
|
||||
|
||||
print(f"{_CYAN}◈ Tunnel: cloudflared binary ready — starting tunnel on port {API_PORT}…{_RESET}")
|
||||
t = threading.Thread(target=_run_tunnel, args=(binary, CF_TUNNEL_TOKEN, API_PORT, CF_TUNNEL_URL), daemon=True)
|
||||
t.start()
|
||||
Reference in New Issue
Block a user