Upload 91 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -0
- bot/__pycache__/__init__.cpython-311.pyc +0 -0
- bot/__pycache__/config.cpython-311.pyc +0 -0
- bot/__pycache__/database.cpython-311.pyc +0 -0
- bot/__pycache__/emojis.cpython-311.pyc +0 -0
- bot/__pycache__/i18n.cpython-311.pyc +0 -0
- bot/__pycache__/main.cpython-311.pyc +0 -0
- bot/__pycache__/server.cpython-311.pyc +0 -0
- bot/__pycache__/theme.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/__init__.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/admin.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/ai_admin.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/ai_suite.cpython-311.pyc +2 -2
- bot/cogs/__pycache__/banner_manager.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/board_games.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/community.cpython-311.pyc +2 -2
- bot/cogs/__pycache__/configuration.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/developer.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/engagement.cpython-311.pyc +2 -2
- bot/cogs/__pycache__/events.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/fun.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/gambling.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/language.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/media.cpython-311.pyc +2 -2
- bot/cogs/__pycache__/media_helpers.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/menu.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/observability.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/server_manager.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/utility.cpython-311.pyc +0 -0
- bot/cogs/__pycache__/verification.cpython-311.pyc +0 -0
- bot/cogs/admin.py +0 -0
- bot/cogs/ai_admin.py +847 -395
- bot/cogs/ai_suite.py +5 -6
- bot/cogs/banner_manager.py +38 -4
- bot/cogs/board_games.py +1 -1
- bot/cogs/community.py +131 -5
- bot/cogs/configuration.py +5 -5
- bot/cogs/engagement.py +149 -27
- bot/cogs/events.py +494 -18
- bot/cogs/fun.py +9 -4
- bot/cogs/gambling.py +688 -770
- bot/cogs/language.py +22 -3
- bot/cogs/media.py +261 -85
- bot/cogs/media_helpers.py +12 -3
- bot/cogs/menu.py +805 -754
- bot/cogs/observability.py +74 -10
- bot/database.py +25 -10
- bot/emojis.py +26 -10
- bot/i18n.py +15 -2
- bot/locales/ar.json +178 -3
.gitattributes
CHANGED
|
@@ -38,3 +38,5 @@ bot/cogs/__pycache__/community.cpython-311.pyc filter=lfs diff=lfs merge=lfs -te
|
|
| 38 |
bot/cogs/__pycache__/engagement.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
| 39 |
bot/cogs/__pycache__/media.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
| 40 |
database.db filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 38 |
bot/cogs/__pycache__/engagement.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
| 39 |
bot/cogs/__pycache__/media.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
| 40 |
database.db filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
bot/cogs/__pycache__/admin.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
bot/cogs/__pycache__/events.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
bot/__pycache__/__init__.cpython-311.pyc
CHANGED
|
Binary files a/bot/__pycache__/__init__.cpython-311.pyc and b/bot/__pycache__/__init__.cpython-311.pyc differ
|
|
|
bot/__pycache__/config.cpython-311.pyc
CHANGED
|
Binary files a/bot/__pycache__/config.cpython-311.pyc and b/bot/__pycache__/config.cpython-311.pyc differ
|
|
|
bot/__pycache__/database.cpython-311.pyc
CHANGED
|
Binary files a/bot/__pycache__/database.cpython-311.pyc and b/bot/__pycache__/database.cpython-311.pyc differ
|
|
|
bot/__pycache__/emojis.cpython-311.pyc
CHANGED
|
Binary files a/bot/__pycache__/emojis.cpython-311.pyc and b/bot/__pycache__/emojis.cpython-311.pyc differ
|
|
|
bot/__pycache__/i18n.cpython-311.pyc
CHANGED
|
Binary files a/bot/__pycache__/i18n.cpython-311.pyc and b/bot/__pycache__/i18n.cpython-311.pyc differ
|
|
|
bot/__pycache__/main.cpython-311.pyc
CHANGED
|
Binary files a/bot/__pycache__/main.cpython-311.pyc and b/bot/__pycache__/main.cpython-311.pyc differ
|
|
|
bot/__pycache__/server.cpython-311.pyc
ADDED
|
Binary file (968 Bytes). View file
|
|
|
bot/__pycache__/theme.cpython-311.pyc
CHANGED
|
Binary files a/bot/__pycache__/theme.cpython-311.pyc and b/bot/__pycache__/theme.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/__init__.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/__init__.cpython-311.pyc and b/bot/cogs/__pycache__/__init__.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/admin.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/admin.cpython-311.pyc and b/bot/cogs/__pycache__/admin.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/ai_admin.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/ai_admin.cpython-311.pyc and b/bot/cogs/__pycache__/ai_admin.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/ai_suite.cpython-311.pyc
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e3d87a3a72d573124f17d8e63596d2f74a90fe1a464d7c3e483a0a7a1b97fae8
|
| 3 |
+
size 142795
|
bot/cogs/__pycache__/banner_manager.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/banner_manager.cpython-311.pyc and b/bot/cogs/__pycache__/banner_manager.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/board_games.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/board_games.cpython-311.pyc and b/bot/cogs/__pycache__/board_games.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/community.cpython-311.pyc
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:87a05d115d6c14cd3607f3ce9d415d52e370582ddf9817c707a8002eb0f39d66
|
| 3 |
+
size 131193
|
bot/cogs/__pycache__/configuration.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/configuration.cpython-311.pyc and b/bot/cogs/__pycache__/configuration.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/developer.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/developer.cpython-311.pyc and b/bot/cogs/__pycache__/developer.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/engagement.cpython-311.pyc
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2dab296a18802c5fb56ad3d9d1e26cfcd915adc3b82be3c4dd50c7b294a781a2
|
| 3 |
+
size 120855
|
bot/cogs/__pycache__/events.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/events.cpython-311.pyc and b/bot/cogs/__pycache__/events.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/fun.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/fun.cpython-311.pyc and b/bot/cogs/__pycache__/fun.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/gambling.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/gambling.cpython-311.pyc and b/bot/cogs/__pycache__/gambling.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/language.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/language.cpython-311.pyc and b/bot/cogs/__pycache__/language.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/media.cpython-311.pyc
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ca5f86939d327aa5b764e849e35198a8f0881667d5202772e915f0a92899da33
|
| 3 |
+
size 220295
|
bot/cogs/__pycache__/media_helpers.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/media_helpers.cpython-311.pyc and b/bot/cogs/__pycache__/media_helpers.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/menu.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/menu.cpython-311.pyc and b/bot/cogs/__pycache__/menu.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/observability.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/observability.cpython-311.pyc and b/bot/cogs/__pycache__/observability.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/server_manager.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/server_manager.cpython-311.pyc and b/bot/cogs/__pycache__/server_manager.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/utility.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/utility.cpython-311.pyc and b/bot/cogs/__pycache__/utility.cpython-311.pyc differ
|
|
|
bot/cogs/__pycache__/verification.cpython-311.pyc
CHANGED
|
Binary files a/bot/cogs/__pycache__/verification.cpython-311.pyc and b/bot/cogs/__pycache__/verification.cpython-311.pyc differ
|
|
|
bot/cogs/admin.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
bot/cogs/ai_admin.py
CHANGED
|
@@ -1,479 +1,931 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Autonomous AI Administrator - Senior Backend Engineer Implementation
|
| 3 |
-
Permission Guard + Intelligence Layer + Execution Engine + Global Language Support
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
|
|
| 8 |
import json
|
| 9 |
import re
|
| 10 |
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
class PermissionGuard:
|
| 22 |
-
"""Validates hierarchy before any AI admin action."""
|
| 23 |
-
|
| 24 |
-
@staticmethod
|
| 25 |
-
def check(ctx: commands.Context) -> tuple[bool, str]:
|
| 26 |
-
if not ctx.guild:
|
| 27 |
-
return False, "Server only."
|
| 28 |
-
|
| 29 |
-
if ctx.author.id == ctx.guild.owner_id:
|
| 30 |
-
return True, ""
|
| 31 |
-
|
| 32 |
-
if not ctx.author.guild_permissions.manage_guild:
|
| 33 |
-
return False, "Manage Server permission required."
|
| 34 |
-
|
| 35 |
-
member = ctx.guild.get_member(ctx.author.id)
|
| 36 |
-
bot_member = ctx.guild.me
|
| 37 |
-
|
| 38 |
-
if member and member.top_role.position >= bot_member.top_role.position:
|
| 39 |
-
return False, "Insufficient Hierarchy: My role must be higher than yours."
|
| 40 |
-
|
| 41 |
-
return True, ""
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
class IntelligenceLayer:
|
| 45 |
-
"""OpenRouter integration for AI decision making."""
|
| 46 |
-
|
| 47 |
-
def __init__(self, bot: commands.Bot) -> None:
|
| 48 |
-
self.bot = bot
|
| 49 |
-
self._session: aiohttp.ClientSession | None = None
|
| 50 |
-
|
| 51 |
-
async def _get_session(self) -> aiohttp.ClientSession:
|
| 52 |
-
if self._session is None or self._session.closed:
|
| 53 |
-
self._session = aiohttp.ClientSession()
|
| 54 |
-
return self._session
|
| 55 |
-
|
| 56 |
-
async def ask_ai(self, prompt: str) -> list[dict[str, Any]] | None:
|
| 57 |
-
settings = self.bot.settings
|
| 58 |
-
api_key = getattr(settings, "openrouter_api_key", None)
|
| 59 |
-
if not api_key:
|
| 60 |
-
return None
|
| 61 |
-
|
| 62 |
-
model = getattr(settings, "openrouter_model", "openai/gpt-4o-mini") or "openai/gpt-4o-mini"
|
| 63 |
-
|
| 64 |
-
system_message = """You are an Autonomous AI Administrator for a Discord server.
|
| 65 |
-
Output ONLY a valid JSON array of actions. Do not include any other text.
|
| 66 |
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
-
|
| 70 |
{
|
| 71 |
-
"action": "
|
| 72 |
-
"
|
| 73 |
-
"
|
| 74 |
-
"
|
| 75 |
-
"reason": "Why this role is needed"
|
| 76 |
}
|
| 77 |
|
| 78 |
-
|
| 79 |
{
|
| 80 |
-
"action": "
|
| 81 |
-
"
|
| 82 |
-
"
|
| 83 |
-
"
|
| 84 |
-
"locked_to_roles": ["Role1", "Role2"],
|
| 85 |
-
"reason": "Why this channel is needed"
|
| 86 |
}
|
| 87 |
|
| 88 |
-
|
| 89 |
{
|
| 90 |
-
"action": "
|
| 91 |
"channel": "channel-name",
|
| 92 |
-
"
|
| 93 |
-
"description": "Announcement content",
|
| 94 |
-
"color": "#00FFFF"
|
| 95 |
}
|
| 96 |
|
| 97 |
-
|
| 98 |
{
|
| 99 |
-
"action": "
|
| 100 |
-
"
|
| 101 |
-
"
|
| 102 |
-
"winners": 1,
|
| 103 |
-
"channel": "channel-name"
|
| 104 |
}
|
| 105 |
|
| 106 |
-
|
| 107 |
{
|
| 108 |
-
"action": "
|
| 109 |
-
"
|
| 110 |
-
"
|
| 111 |
-
"
|
| 112 |
-
"channel": "channel-name"
|
| 113 |
}
|
| 114 |
|
| 115 |
-
|
| 116 |
{
|
| 117 |
-
"action": "
|
| 118 |
-
"
|
| 119 |
-
"
|
| 120 |
-
"
|
| 121 |
}
|
| 122 |
|
| 123 |
-
|
| 124 |
{
|
| 125 |
-
"action": "
|
| 126 |
-
"
|
| 127 |
-
"
|
| 128 |
}
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
"temperature": 0.3,
|
| 142 |
-
"max_tokens": 2000
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
headers = {
|
| 146 |
-
"Authorization": f"Bearer {api_key}",
|
| 147 |
-
"Content-Type": "application/json",
|
| 148 |
-
"HTTP-Referer": "https://github.com/mega-bot",
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
try:
|
| 152 |
-
session = await self._get_session()
|
| 153 |
-
async with session.post(
|
| 154 |
-
"https://openrouter.ai/api/v1/chat/completions",
|
| 155 |
-
json=payload,
|
| 156 |
-
headers=headers
|
| 157 |
-
) as resp:
|
| 158 |
-
if resp.status != 200:
|
| 159 |
-
return None
|
| 160 |
-
data = await resp.json()
|
| 161 |
-
|
| 162 |
-
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
| 163 |
-
return self._parse_json_response(content)
|
| 164 |
-
except Exception:
|
| 165 |
-
return None
|
| 166 |
-
|
| 167 |
-
def _parse_json_response(self, content: str) -> list[dict[str, Any]] | None:
|
| 168 |
-
try:
|
| 169 |
-
content = content.strip()
|
| 170 |
-
json_match = re.search(r'\[.*\]', content, re.DOTALL)
|
| 171 |
-
if json_match:
|
| 172 |
-
content = json_match.group(0)
|
| 173 |
-
return json.loads(content)
|
| 174 |
-
except Exception:
|
| 175 |
-
return None
|
| 176 |
-
|
| 177 |
-
async def close(self) -> None:
|
| 178 |
-
if self._session and not self._session.closed:
|
| 179 |
-
await self._session.close()
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
elif action_type == "run_command":
|
| 206 |
result = await self._run_command(action, ctx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
else:
|
| 208 |
result = f"Unknown action: {action_type}"
|
| 209 |
results.append(result)
|
| 210 |
except Exception as e:
|
| 211 |
results.append(f"Error executing {action_type}: {str(e)}")
|
| 212 |
return results
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
async def _create_channel(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
|
|
|
|
|
|
| 229 |
name = action.get("name", "new-channel")
|
| 230 |
channel_type = action.get("type", "text")
|
| 231 |
-
category_name = action.get("category")
|
| 232 |
-
locked_roles = action.get("locked_to_roles", [])
|
| 233 |
-
reason = action.get("reason", "AI Admin")
|
| 234 |
-
|
| 235 |
-
overwrites = {
|
| 236 |
-
ctx.guild.default_role: discord.PermissionOverwrite(view_channel=False),
|
| 237 |
-
ctx.guild.me: discord.PermissionOverwrite(view_channel=True, send_messages=True, manage_channels=True),
|
| 238 |
-
}
|
| 239 |
-
|
| 240 |
-
for role_name in locked_roles:
|
| 241 |
-
role = discord.utils.get(ctx.guild.roles, name=role_name)
|
| 242 |
-
if role:
|
| 243 |
-
overwrites[role] = discord.PermissionOverwrite(view_channel=True, send_messages=True)
|
| 244 |
-
|
| 245 |
-
category = None
|
| 246 |
-
if category_name:
|
| 247 |
-
category = discord.utils.get(ctx.guild.categories, name=category_name)
|
| 248 |
-
|
| 249 |
-
if channel_type == "text":
|
| 250 |
-
channel = await ctx.guild.create_text_channel(
|
| 251 |
-
name=name, category=category, overwrites=overwrites, reason=reason
|
| 252 |
-
)
|
| 253 |
-
elif channel_type == "voice":
|
| 254 |
-
channel = await ctx.guild.create_voice_channel(
|
| 255 |
-
name=name, category=category, overwrites=overwrites, reason=reason
|
| 256 |
-
)
|
| 257 |
-
else:
|
| 258 |
-
return f"Unknown channel type: {channel_type}"
|
| 259 |
-
|
| 260 |
-
return f"Created channel: {channel.mention}"
|
| 261 |
-
|
| 262 |
-
async def _announce(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 263 |
-
channel_name = action.get("channel")
|
| 264 |
-
title = action.get("title", "Announcement")
|
| 265 |
-
description = action.get("description", "")
|
| 266 |
-
color_str = action.get("color", "#00FFFF")
|
| 267 |
-
|
| 268 |
-
try:
|
| 269 |
-
color = discord.Color(int(color_str.lstrip("#"), 16))
|
| 270 |
-
except ValueError:
|
| 271 |
-
color = discord.Color.blue()
|
| 272 |
-
|
| 273 |
-
channel = None
|
| 274 |
-
if channel_name:
|
| 275 |
-
channel = discord.utils.get(ctx.guild.text_channels, name=channel_name)
|
| 276 |
-
|
| 277 |
-
if not channel:
|
| 278 |
-
channel = ctx.channel
|
| 279 |
-
|
| 280 |
-
embed = discord.Embed(title=title, description=description, color=color)
|
| 281 |
-
embed.timestamp = discord.utils.utcnow()
|
| 282 |
-
await channel.send(embed=embed)
|
| 283 |
-
return f"Announcement sent to {channel.mention}"
|
| 284 |
-
|
| 285 |
-
async def _create_giveaway(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 286 |
-
prize = action.get("prize", "Giveaway Prize")
|
| 287 |
-
duration = action.get("duration_minutes", 60)
|
| 288 |
-
winners = action.get("winners", 1)
|
| 289 |
-
channel_name = action.get("channel")
|
| 290 |
-
|
| 291 |
-
channel = ctx.channel
|
| 292 |
-
if channel_name:
|
| 293 |
-
ch = discord.utils.get(ctx.guild.text_channels, name=channel_name)
|
| 294 |
-
if ch:
|
| 295 |
-
channel = ch
|
| 296 |
-
|
| 297 |
-
cog = self.bot.get_cog("Community")
|
| 298 |
-
if not cog:
|
| 299 |
-
return "Community cog not found."
|
| 300 |
-
|
| 301 |
-
await
|
| 302 |
-
return f"Giveaway created: {prize}"
|
| 303 |
-
|
| 304 |
-
async def _create_tournament(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 305 |
-
name = action.get("name", "Tournament")
|
| 306 |
-
game = action.get("game", "Game")
|
| 307 |
-
max_participants = action.get("max_participants", 16)
|
| 308 |
-
channel_name = action.get("channel")
|
| 309 |
-
|
| 310 |
-
channel = ctx.channel
|
| 311 |
-
if channel_name:
|
| 312 |
-
ch = discord.utils.get(ctx.guild.text_channels, name=channel_name)
|
| 313 |
-
if ch:
|
| 314 |
-
channel = ch
|
| 315 |
-
|
| 316 |
-
cog = self.bot.get_cog("Engagement")
|
| 317 |
-
if not cog:
|
| 318 |
-
return "Engagement cog not found."
|
| 319 |
-
|
| 320 |
-
await ctx.invoke(cog.tournament_create, name=name, game=game, max=max_participants, channel=channel)
|
| 321 |
-
return f"Tournament created: {name}"
|
| 322 |
-
|
| 323 |
-
async def _create_poll(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 324 |
-
question = action.get("question", "Poll")
|
| 325 |
-
options = action.get("options", ["Yes", "No"])
|
| 326 |
-
duration = action.get("duration_minutes", 30)
|
| 327 |
-
|
| 328 |
-
cog = self.bot.get_cog("Community")
|
| 329 |
-
if not cog:
|
| 330 |
-
return "Community cog not found."
|
| 331 |
-
|
| 332 |
-
options_str = ",".join(options)
|
| 333 |
-
await ctx.invoke(cog.poll, question=question, options=options_str, duration=duration)
|
| 334 |
-
return f"Poll created: {question}"
|
| 335 |
-
|
| 336 |
-
async def _run_command(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 337 |
-
command_name = action.get("command", "")
|
| 338 |
-
args = action.get("args", {})
|
| 339 |
-
|
| 340 |
-
command = self.bot.get_command(command_name)
|
| 341 |
-
if not command:
|
| 342 |
-
return f"Command not found: {command_name}"
|
| 343 |
-
|
| 344 |
-
try:
|
| 345 |
-
await ctx.invoke(command, **args)
|
| 346 |
-
return f"Command executed: {command_name}"
|
| 347 |
-
except Exception as e:
|
| 348 |
-
return f"Error running command: {str(e)}"
|
| 349 |
-
|
| 350 |
-
async def _create_giveaway(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 351 |
-
prize = action.get("prize", "Giveaway Prize")
|
| 352 |
-
duration = action.get("duration_minutes", 60)
|
| 353 |
-
winners = action.get("winners", 1)
|
| 354 |
-
channel_name = action.get("channel")
|
| 355 |
-
|
| 356 |
-
channel = ctx.channel
|
| 357 |
-
if channel_name:
|
| 358 |
-
ch = discord.utils.get(ctx.guild.text_channels, name=channel_name)
|
| 359 |
-
if ch:
|
| 360 |
-
channel = ch
|
| 361 |
-
|
| 362 |
-
cog = self.bot.get_cog("Community")
|
| 363 |
-
if not cog:
|
| 364 |
-
return "Community cog not found."
|
| 365 |
-
|
| 366 |
-
await ctx.invoke(cog.giveaway, prize=prize, minutes=duration, winners=winners, channel=channel)
|
| 367 |
-
return f"Giveaway created: {prize}"
|
| 368 |
-
|
| 369 |
async def _create_tournament(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 370 |
name = action.get("name", "Tournament")
|
| 371 |
game = action.get("game", "Game")
|
| 372 |
max_participants = action.get("max_participants", 16)
|
| 373 |
-
channel_name = action.get("channel")
|
| 374 |
-
|
| 375 |
-
channel = ctx.channel
|
| 376 |
-
if channel_name:
|
| 377 |
-
ch = discord.utils.get(ctx.guild.text_channels, name=channel_name)
|
| 378 |
-
if ch:
|
| 379 |
-
channel = ch
|
| 380 |
|
| 381 |
cog = self.bot.get_cog("Engagement")
|
| 382 |
if not cog:
|
| 383 |
return "Engagement cog not found."
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
| 386 |
return f"Tournament created: {name}"
|
| 387 |
|
| 388 |
async def _create_poll(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 389 |
question = action.get("question", "Poll")
|
| 390 |
options = action.get("options", ["Yes", "No"])
|
| 391 |
-
duration = action.get("duration_minutes", 30)
|
| 392 |
|
| 393 |
-
cog = self.bot.get_cog("
|
| 394 |
if not cog:
|
| 395 |
-
return "
|
| 396 |
|
| 397 |
-
|
| 398 |
-
|
|
|
|
| 399 |
return f"Poll created: {question}"
|
| 400 |
-
|
| 401 |
async def _run_command(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 402 |
command_name = action.get("command", "")
|
| 403 |
args = action.get("args", {})
|
| 404 |
-
|
| 405 |
-
command = self.bot.get_command(command_name)
|
| 406 |
-
if not command:
|
| 407 |
-
return f"Command not found: {command_name}"
|
| 408 |
-
|
| 409 |
try:
|
| 410 |
await ctx.invoke(command, **args)
|
| 411 |
return f"Command executed: {command_name}"
|
| 412 |
except Exception as e:
|
| 413 |
return f"Error running command: {str(e)}"
|
| 414 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
class AIAdmin(commands.Cog):
|
| 417 |
-
"""Autonomous AI Administrator Cog."""
|
| 418 |
-
|
| 419 |
def __init__(self, bot: commands.Bot) -> None:
|
| 420 |
self.bot = bot
|
| 421 |
self.permission_guard = PermissionGuard()
|
| 422 |
self.intelligence = IntelligenceLayer(bot)
|
| 423 |
self.execution = ExecutionEngine(bot)
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
@commands.hybrid_command(name="ai_admin", description="Let AI administrate the server")
|
| 429 |
async def ai_admin(self, ctx: commands.Context, *, request: str) -> None:
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
await ctx.defer()
|
| 435 |
-
|
| 436 |
-
actions = await self.intelligence.ask_ai(request)
|
| 437 |
-
if not actions:
|
| 438 |
-
await ctx.send("AI failed to generate actions. Try again.", ephemeral=True)
|
| 439 |
return
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
"**Announce:**\n"
|
| 471 |
-
"`/ai_admin announce server maintenance in 1 hour`"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
),
|
| 473 |
color=discord.Color.blue()
|
| 474 |
)
|
| 475 |
-
await ctx.send(embed=embed, ephemeral=True)
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
async def setup(bot: commands.Bot) -> None:
|
| 479 |
-
await bot.add_cog(AIAdmin(bot))
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Autonomous AI Administrator - Senior Backend Engineer Implementation
|
| 3 |
+
Permission Guard + Intelligence Layer + Execution Engine + Global Language Support
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
+
import datetime as dt
|
| 9 |
import json
|
| 10 |
import re
|
| 11 |
from typing import Any
|
| 12 |
+
|
| 13 |
+
import discord
|
| 14 |
+
from discord.ext import commands
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
import aiohttp
|
| 18 |
+
except Exception:
|
| 19 |
+
aiohttp = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class PermissionGuard:
|
| 23 |
+
"""Validates hierarchy before any AI admin action."""
|
| 24 |
+
|
| 25 |
+
@staticmethod
|
| 26 |
+
def check(ctx: commands.Context) -> tuple[bool, str]:
|
| 27 |
+
if not ctx.guild:
|
| 28 |
+
return False, "Server only."
|
| 29 |
+
|
| 30 |
+
if ctx.author.id == ctx.guild.owner_id:
|
| 31 |
+
return True, ""
|
| 32 |
+
|
| 33 |
+
if not ctx.author.guild_permissions.manage_guild:
|
| 34 |
+
return False, "Manage Server permission required."
|
| 35 |
+
|
| 36 |
+
member = ctx.guild.get_member(ctx.author.id)
|
| 37 |
+
bot_member = ctx.guild.me
|
| 38 |
+
|
| 39 |
+
if member and member.top_role.position >= bot_member.top_role.position:
|
| 40 |
+
return False, "Insufficient Hierarchy: My role must be higher than yours."
|
| 41 |
+
|
| 42 |
+
return True, ""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class IntelligenceLayer:
|
| 46 |
+
"""OpenRouter integration for AI decision making."""
|
| 47 |
+
|
| 48 |
+
def __init__(self, bot: commands.Bot) -> None:
|
| 49 |
+
self.bot = bot
|
| 50 |
+
self._session: aiohttp.ClientSession | None = None
|
| 51 |
+
|
| 52 |
+
async def _get_session(self) -> aiohttp.ClientSession:
|
| 53 |
+
if self._session is None or self._session.closed:
|
| 54 |
+
self._session = aiohttp.ClientSession()
|
| 55 |
+
return self._session
|
| 56 |
+
|
| 57 |
+
async def ask_ai(self, prompt: str) -> list[dict[str, Any]] | None:
|
| 58 |
+
settings = self.bot.settings
|
| 59 |
+
api_key = getattr(settings, "openrouter_api_key", None)
|
| 60 |
+
if not api_key:
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
model = getattr(settings, "openrouter_model", "openai/gpt-4o-mini") or "openai/gpt-4o-mini"
|
| 64 |
+
|
| 65 |
+
system_message = """You are an Autonomous AI Administrator for a Discord server.
|
| 66 |
+
Output ONLY a valid JSON array of actions. Do not include any other text.
|
| 67 |
+
|
| 68 |
+
Supported actions:
|
| 69 |
+
|
| 70 |
+
1. CREATE ROLE:
|
| 71 |
+
{
|
| 72 |
+
"action": "create_role",
|
| 73 |
+
"name": "Role Name",
|
| 74 |
+
"color": "#FF0000",
|
| 75 |
+
"hoist": true,
|
| 76 |
+
"reason": "Why this role is needed"
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
2. CREATE CHANNEL:
|
| 80 |
+
{
|
| 81 |
+
"action": "create_channel",
|
| 82 |
+
"name": "channel-name",
|
| 83 |
+
"type": "text",
|
| 84 |
+
"category": "Category Name",
|
| 85 |
+
"locked_to_roles": ["Role1", "Role2"],
|
| 86 |
+
"reason": "Why this channel is needed"
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
3. ANNOUNCE:
|
| 90 |
+
{
|
| 91 |
+
"action": "announce",
|
| 92 |
+
"channel": "channel-name",
|
| 93 |
+
"title": "Announcement Title",
|
| 94 |
+
"description": "Announcement content",
|
| 95 |
+
"color": "#00FFFF"
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
4. CREATE GIVEAWAY:
|
| 99 |
+
{
|
| 100 |
+
"action": "create_giveaway",
|
| 101 |
+
"prize": "Prize description",
|
| 102 |
+
"duration_minutes": 60,
|
| 103 |
+
"winners": 1,
|
| 104 |
+
"channel": "channel-name"
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
5. CREATE TOURNAMENT:
|
| 108 |
+
{
|
| 109 |
+
"action": "create_tournament",
|
| 110 |
+
"name": "Tournament Name",
|
| 111 |
+
"game": "Game name",
|
| 112 |
+
"max_participants": 16,
|
| 113 |
+
"channel": "channel-name"
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
6. CREATE POLL:
|
| 117 |
+
{
|
| 118 |
+
"action": "create_poll",
|
| 119 |
+
"question": "Poll question",
|
| 120 |
+
"options": ["Option 1", "Option 2", "Option 3"],
|
| 121 |
+
"duration_minutes": 30
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
7. RUN COMMAND (for any other bot command):
|
| 125 |
+
{
|
| 126 |
+
"action": "run_command",
|
| 127 |
+
"command": "command_name",
|
| 128 |
+
"args": {"arg1": "value1", "arg2": "value2"}
|
| 129 |
+
}
|
| 130 |
|
| 131 |
+
8. TIMEOUT MEMBER:
|
| 132 |
+
{
|
| 133 |
+
"action": "timeout_member",
|
| 134 |
+
"member": "@user or user_id",
|
| 135 |
+
"minutes": 30,
|
| 136 |
+
"reason": "Why timeout is needed"
|
| 137 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
+
9. UNTIMEOUT MEMBER:
|
| 140 |
+
{
|
| 141 |
+
"action": "untimeout_member",
|
| 142 |
+
"member": "@user or user_id",
|
| 143 |
+
"reason": "Why timeout is removed"
|
| 144 |
+
}
|
| 145 |
|
| 146 |
+
10. ADD ROLE:
|
| 147 |
{
|
| 148 |
+
"action": "add_role",
|
| 149 |
+
"member": "@user or user_id",
|
| 150 |
+
"role": "Role Name",
|
| 151 |
+
"reason": "Why role is added"
|
|
|
|
| 152 |
}
|
| 153 |
|
| 154 |
+
11. REMOVE ROLE:
|
| 155 |
{
|
| 156 |
+
"action": "remove_role",
|
| 157 |
+
"member": "@user or user_id",
|
| 158 |
+
"role": "Role Name",
|
| 159 |
+
"reason": "Why role is removed"
|
|
|
|
|
|
|
| 160 |
}
|
| 161 |
|
| 162 |
+
12. LOCK CHANNEL:
|
| 163 |
{
|
| 164 |
+
"action": "lock_channel",
|
| 165 |
"channel": "channel-name",
|
| 166 |
+
"reason": "Why channel is locked"
|
|
|
|
|
|
|
| 167 |
}
|
| 168 |
|
| 169 |
+
13. UNLOCK CHANNEL:
|
| 170 |
{
|
| 171 |
+
"action": "unlock_channel",
|
| 172 |
+
"channel": "channel-name",
|
| 173 |
+
"reason": "Why channel is unlocked"
|
|
|
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
+
14. SLOWMODE:
|
| 177 |
{
|
| 178 |
+
"action": "set_slowmode",
|
| 179 |
+
"channel": "channel-name",
|
| 180 |
+
"seconds": 10,
|
| 181 |
+
"reason": "Why slowmode is set"
|
|
|
|
| 182 |
}
|
| 183 |
|
| 184 |
+
15. PURGE MESSAGES:
|
| 185 |
{
|
| 186 |
+
"action": "purge_messages",
|
| 187 |
+
"channel": "channel-name",
|
| 188 |
+
"amount": 25,
|
| 189 |
+
"reason": "Why messages are purged"
|
| 190 |
}
|
| 191 |
|
| 192 |
+
16. DELETE CHANNEL:
|
| 193 |
{
|
| 194 |
+
"action": "delete_channel",
|
| 195 |
+
"channel": "channel-name",
|
| 196 |
+
"reason": "Why channel is deleted"
|
| 197 |
}
|
| 198 |
|
| 199 |
+
17. RENAME CHANNEL:
|
| 200 |
+
{
|
| 201 |
+
"action": "rename_channel",
|
| 202 |
+
"channel": "old-channel-name",
|
| 203 |
+
"new_name": "new-channel-name",
|
| 204 |
+
"reason": "Why channel is renamed"
|
| 205 |
+
}
|
| 206 |
|
| 207 |
+
18. CREATE CATEGORY:
|
| 208 |
+
{
|
| 209 |
+
"action": "create_category",
|
| 210 |
+
"name": "Category Name",
|
| 211 |
+
"reason": "Why this category is needed"
|
| 212 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
+
19. RENAME CATEGORY:
|
| 215 |
+
{
|
| 216 |
+
"action": "rename_category",
|
| 217 |
+
"category": "Old Category Name",
|
| 218 |
+
"new_name": "New Category Name",
|
| 219 |
+
"reason": "Why category is renamed"
|
| 220 |
+
}
|
| 221 |
|
| 222 |
+
20. DELETE CATEGORY:
|
| 223 |
+
{
|
| 224 |
+
"action": "delete_category",
|
| 225 |
+
"category": "Category Name",
|
| 226 |
+
"reason": "Why category is deleted"
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
ALWAYS include a "response_to_user" field in the SAME language as the input.
|
| 230 |
+
Example: If input is Arabic, response_to_user must be in Arabic.
|
| 231 |
+
|
| 232 |
+
IMPORTANT: For any request that involves creating events, giveaways, tournaments, polls, or other bot features, use the appropriate action above. If unsure, use run_command."""
|
| 233 |
+
|
| 234 |
+
payload = {
|
| 235 |
+
"model": model,
|
| 236 |
+
"messages": [
|
| 237 |
+
{"role": "system", "content": system_message},
|
| 238 |
+
{"role": "user", "content": prompt}
|
| 239 |
+
],
|
| 240 |
+
"temperature": 0.3,
|
| 241 |
+
"max_tokens": 2000
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
headers = {
|
| 245 |
+
"Authorization": f"Bearer {api_key}",
|
| 246 |
+
"Content-Type": "application/json",
|
| 247 |
+
"HTTP-Referer": "https://github.com/mega-bot",
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
try:
|
| 251 |
+
session = await self._get_session()
|
| 252 |
+
async with session.post(
|
| 253 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 254 |
+
json=payload,
|
| 255 |
+
headers=headers
|
| 256 |
+
) as resp:
|
| 257 |
+
if resp.status != 200:
|
| 258 |
+
return None
|
| 259 |
+
data = await resp.json()
|
| 260 |
+
|
| 261 |
+
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
| 262 |
+
return self._parse_json_response(content)
|
| 263 |
+
except Exception:
|
| 264 |
+
return None
|
| 265 |
+
|
| 266 |
+
def _parse_json_response(self, content: str) -> list[dict[str, Any]] | None:
|
| 267 |
+
try:
|
| 268 |
+
content = content.strip()
|
| 269 |
+
json_match = re.search(r'\[.*\]', content, re.DOTALL)
|
| 270 |
+
if json_match:
|
| 271 |
+
content = json_match.group(0)
|
| 272 |
+
return json.loads(content)
|
| 273 |
+
except Exception:
|
| 274 |
+
return None
|
| 275 |
+
|
| 276 |
+
async def close(self) -> None:
|
| 277 |
+
if self._session and not self._session.closed:
|
| 278 |
+
await self._session.close()
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
class ExecutionEngine:
|
| 282 |
+
"""Executes AI decisions with proper error handling."""
|
| 283 |
+
|
| 284 |
+
def __init__(self, bot: commands.Bot) -> None:
|
| 285 |
+
self.bot = bot
|
| 286 |
+
|
| 287 |
+
async def execute(self, actions: list[dict[str, Any]], ctx: commands.Context) -> list[str]:
|
| 288 |
+
results = []
|
| 289 |
+
for action in actions:
|
| 290 |
+
action_type = action.get("action", "")
|
| 291 |
+
try:
|
| 292 |
+
if action_type == "create_role":
|
| 293 |
+
result = await self._create_role(action, ctx)
|
| 294 |
+
elif action_type == "create_channel":
|
| 295 |
+
result = await self._create_channel(action, ctx)
|
| 296 |
+
elif action_type == "announce":
|
| 297 |
+
result = await self._announce(action, ctx)
|
| 298 |
+
elif action_type == "create_giveaway":
|
| 299 |
+
result = await self._create_giveaway(action, ctx)
|
| 300 |
+
elif action_type == "create_tournament":
|
| 301 |
+
result = await self._create_tournament(action, ctx)
|
| 302 |
+
elif action_type == "create_poll":
|
| 303 |
+
result = await self._create_poll(action, ctx)
|
| 304 |
elif action_type == "run_command":
|
| 305 |
result = await self._run_command(action, ctx)
|
| 306 |
+
elif action_type == "timeout_member":
|
| 307 |
+
result = await self._timeout_member(action, ctx)
|
| 308 |
+
elif action_type == "untimeout_member":
|
| 309 |
+
result = await self._untimeout_member(action, ctx)
|
| 310 |
+
elif action_type == "add_role":
|
| 311 |
+
result = await self._add_role(action, ctx)
|
| 312 |
+
elif action_type == "remove_role":
|
| 313 |
+
result = await self._remove_role(action, ctx)
|
| 314 |
+
elif action_type == "lock_channel":
|
| 315 |
+
result = await self._lock_channel(action, ctx)
|
| 316 |
+
elif action_type == "unlock_channel":
|
| 317 |
+
result = await self._unlock_channel(action, ctx)
|
| 318 |
+
elif action_type == "set_slowmode":
|
| 319 |
+
result = await self._set_slowmode(action, ctx)
|
| 320 |
+
elif action_type == "purge_messages":
|
| 321 |
+
result = await self._purge_messages(action, ctx)
|
| 322 |
+
elif action_type == "delete_channel":
|
| 323 |
+
result = await self._delete_channel(action, ctx)
|
| 324 |
+
elif action_type == "rename_channel":
|
| 325 |
+
result = await self._rename_channel(action, ctx)
|
| 326 |
+
elif action_type == "create_category":
|
| 327 |
+
result = await self._create_category(action, ctx)
|
| 328 |
+
elif action_type == "rename_category":
|
| 329 |
+
result = await self._rename_category(action, ctx)
|
| 330 |
+
elif action_type == "delete_category":
|
| 331 |
+
result = await self._delete_category(action, ctx)
|
| 332 |
else:
|
| 333 |
result = f"Unknown action: {action_type}"
|
| 334 |
results.append(result)
|
| 335 |
except Exception as e:
|
| 336 |
results.append(f"Error executing {action_type}: {str(e)}")
|
| 337 |
return results
|
| 338 |
+
|
| 339 |
+
@staticmethod
|
| 340 |
+
def _resolve_member(guild: discord.Guild, ref: str | int | None) -> discord.Member | None:
|
| 341 |
+
if ref is None:
|
| 342 |
+
return None
|
| 343 |
+
text = str(ref).strip()
|
| 344 |
+
mention = re.search(r"<@!?(\d+)>", text)
|
| 345 |
+
if mention:
|
| 346 |
+
return guild.get_member(int(mention.group(1)))
|
| 347 |
+
if text.isdigit():
|
| 348 |
+
return guild.get_member(int(text))
|
| 349 |
+
return discord.utils.find(lambda m: m.name.lower() == text.lower() or m.display_name.lower() == text.lower(), guild.members)
|
| 350 |
+
|
| 351 |
+
@staticmethod
|
| 352 |
+
def _resolve_channel(guild: discord.Guild, ref: str | None, fallback: discord.abc.GuildChannel | None = None) -> discord.TextChannel | None:
|
| 353 |
+
if ref:
|
| 354 |
+
text = str(ref).strip()
|
| 355 |
+
mention = re.search(r"<#(\d+)>", text)
|
| 356 |
+
if mention:
|
| 357 |
+
ch = guild.get_channel(int(mention.group(1)))
|
| 358 |
+
return ch if isinstance(ch, discord.TextChannel) else None
|
| 359 |
+
by_name = discord.utils.get(guild.text_channels, name=text)
|
| 360 |
+
if by_name:
|
| 361 |
+
return by_name
|
| 362 |
+
if isinstance(fallback, discord.TextChannel):
|
| 363 |
+
return fallback
|
| 364 |
+
return None
|
| 365 |
+
|
| 366 |
+
@staticmethod
|
| 367 |
+
def _resolve_guild_channel(
|
| 368 |
+
guild: discord.Guild,
|
| 369 |
+
ref: str | int | None,
|
| 370 |
+
fallback: discord.abc.GuildChannel | None = None,
|
| 371 |
+
) -> discord.abc.GuildChannel | None:
|
| 372 |
+
if ref is not None:
|
| 373 |
+
text = str(ref).strip()
|
| 374 |
+
mention = re.search(r"<#(\d+)>", text)
|
| 375 |
+
if mention:
|
| 376 |
+
return guild.get_channel(int(mention.group(1)))
|
| 377 |
+
if text.isdigit():
|
| 378 |
+
return guild.get_channel(int(text))
|
| 379 |
+
by_name = discord.utils.find(lambda c: c.name.lower() == text.lower(), guild.channels)
|
| 380 |
+
if by_name:
|
| 381 |
+
return by_name
|
| 382 |
+
if isinstance(fallback, discord.abc.GuildChannel):
|
| 383 |
+
return fallback
|
| 384 |
+
return None
|
| 385 |
+
|
| 386 |
+
@staticmethod
|
| 387 |
+
def _resolve_category(guild: discord.Guild, ref: str | int | None) -> discord.CategoryChannel | None:
|
| 388 |
+
if ref is None:
|
| 389 |
+
return None
|
| 390 |
+
text = str(ref).strip()
|
| 391 |
+
mention = re.search(r"<#(\d+)>", text)
|
| 392 |
+
if mention:
|
| 393 |
+
ch = guild.get_channel(int(mention.group(1)))
|
| 394 |
+
return ch if isinstance(ch, discord.CategoryChannel) else None
|
| 395 |
+
if text.isdigit():
|
| 396 |
+
ch = guild.get_channel(int(text))
|
| 397 |
+
return ch if isinstance(ch, discord.CategoryChannel) else None
|
| 398 |
+
return discord.utils.find(lambda c: c.name.lower() == text.lower(), guild.categories)
|
| 399 |
+
|
| 400 |
+
async def _create_role(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 401 |
+
name = action.get("name", "New Role")
|
| 402 |
+
color_str = action.get("color", "#99AAB5")
|
| 403 |
+
hoist = action.get("hoist", False)
|
| 404 |
+
reason = action.get("reason", "AI Admin")
|
| 405 |
+
|
| 406 |
+
try:
|
| 407 |
+
color = discord.Color(int(color_str.lstrip("#"), 16))
|
| 408 |
+
except ValueError:
|
| 409 |
+
color = discord.Color.default()
|
| 410 |
+
|
| 411 |
+
role = await ctx.guild.create_role(name=name, color=color, hoist=hoist, reason=reason)
|
| 412 |
+
return f"Created role: {role.mention}"
|
| 413 |
+
|
| 414 |
async def _create_channel(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 415 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 416 |
+
return "Manage Channels permission required."
|
| 417 |
name = action.get("name", "new-channel")
|
| 418 |
channel_type = action.get("type", "text")
|
| 419 |
+
category_name = action.get("category")
|
| 420 |
+
locked_roles = action.get("locked_to_roles", [])
|
| 421 |
+
reason = action.get("reason", "AI Admin")
|
| 422 |
+
|
| 423 |
+
overwrites = {
|
| 424 |
+
ctx.guild.default_role: discord.PermissionOverwrite(view_channel=False),
|
| 425 |
+
ctx.guild.me: discord.PermissionOverwrite(view_channel=True, send_messages=True, manage_channels=True),
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
for role_name in locked_roles:
|
| 429 |
+
role = discord.utils.get(ctx.guild.roles, name=role_name)
|
| 430 |
+
if role:
|
| 431 |
+
overwrites[role] = discord.PermissionOverwrite(view_channel=True, send_messages=True)
|
| 432 |
+
|
| 433 |
+
category = None
|
| 434 |
+
if category_name:
|
| 435 |
+
category = discord.utils.get(ctx.guild.categories, name=category_name)
|
| 436 |
+
|
| 437 |
+
if channel_type == "text":
|
| 438 |
+
channel = await ctx.guild.create_text_channel(
|
| 439 |
+
name=name, category=category, overwrites=overwrites, reason=reason
|
| 440 |
+
)
|
| 441 |
+
elif channel_type == "voice":
|
| 442 |
+
channel = await ctx.guild.create_voice_channel(
|
| 443 |
+
name=name, category=category, overwrites=overwrites, reason=reason
|
| 444 |
+
)
|
| 445 |
+
else:
|
| 446 |
+
return f"Unknown channel type: {channel_type}"
|
| 447 |
+
|
| 448 |
+
return f"Created channel: {channel.mention}"
|
| 449 |
+
|
| 450 |
+
async def _announce(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 451 |
+
channel_name = action.get("channel")
|
| 452 |
+
title = action.get("title", "Announcement")
|
| 453 |
+
description = action.get("description", "")
|
| 454 |
+
color_str = action.get("color", "#00FFFF")
|
| 455 |
+
|
| 456 |
+
try:
|
| 457 |
+
color = discord.Color(int(color_str.lstrip("#"), 16))
|
| 458 |
+
except ValueError:
|
| 459 |
+
color = discord.Color.blue()
|
| 460 |
+
|
| 461 |
+
channel = None
|
| 462 |
+
if channel_name:
|
| 463 |
+
channel = discord.utils.get(ctx.guild.text_channels, name=channel_name)
|
| 464 |
+
|
| 465 |
+
if not channel:
|
| 466 |
+
channel = ctx.channel
|
| 467 |
+
|
| 468 |
+
embed = discord.Embed(title=title, description=description, color=color)
|
| 469 |
+
embed.timestamp = discord.utils.utcnow()
|
| 470 |
+
await channel.send(embed=embed)
|
| 471 |
+
return f"Announcement sent to {channel.mention}"
|
| 472 |
+
|
| 473 |
+
async def _create_giveaway(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 474 |
+
prize = action.get("prize", "Giveaway Prize")
|
| 475 |
+
duration = action.get("duration_minutes", 60)
|
| 476 |
+
winners = action.get("winners", 1)
|
| 477 |
+
channel_name = action.get("channel")
|
| 478 |
+
|
| 479 |
+
channel = ctx.channel
|
| 480 |
+
if channel_name:
|
| 481 |
+
ch = discord.utils.get(ctx.guild.text_channels, name=channel_name)
|
| 482 |
+
if ch:
|
| 483 |
+
channel = ch
|
| 484 |
+
|
| 485 |
+
cog = self.bot.get_cog("Community")
|
| 486 |
+
if not cog:
|
| 487 |
+
return "Community cog not found."
|
| 488 |
+
|
| 489 |
+
await cog.giveaway_create(ctx, int(duration), int(winners), prize=prize)
|
| 490 |
+
return f"Giveaway created: {prize}"
|
| 491 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
async def _create_tournament(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 493 |
name = action.get("name", "Tournament")
|
| 494 |
game = action.get("game", "Game")
|
| 495 |
max_participants = action.get("max_participants", 16)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
|
| 497 |
cog = self.bot.get_cog("Engagement")
|
| 498 |
if not cog:
|
| 499 |
return "Engagement cog not found."
|
| 500 |
+
|
| 501 |
+
# Engagement exposes tournament creation via the base `/tournament` command.
|
| 502 |
+
games = f"{game}" if game else "chess, checkers, connect4, othello"
|
| 503 |
+
await ctx.invoke(cog.tournament, name=name, games=games)
|
| 504 |
return f"Tournament created: {name}"
|
| 505 |
|
| 506 |
async def _create_poll(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 507 |
question = action.get("question", "Poll")
|
| 508 |
options = action.get("options", ["Yes", "No"])
|
|
|
|
| 509 |
|
| 510 |
+
cog = self.bot.get_cog("Utility")
|
| 511 |
if not cog:
|
| 512 |
+
return "Utility cog not found."
|
| 513 |
|
| 514 |
+
# Utility poll command accepts options as a "|" separated string.
|
| 515 |
+
options_str = "|".join(str(o) for o in options)
|
| 516 |
+
await ctx.invoke(cog.poll, question=question, options=options_str)
|
| 517 |
return f"Poll created: {question}"
|
| 518 |
+
|
| 519 |
async def _run_command(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 520 |
command_name = action.get("command", "")
|
| 521 |
args = action.get("args", {})
|
| 522 |
+
|
| 523 |
+
command = self.bot.get_command(command_name)
|
| 524 |
+
if not command:
|
| 525 |
+
return f"Command not found: {command_name}"
|
| 526 |
+
|
| 527 |
try:
|
| 528 |
await ctx.invoke(command, **args)
|
| 529 |
return f"Command executed: {command_name}"
|
| 530 |
except Exception as e:
|
| 531 |
return f"Error running command: {str(e)}"
|
| 532 |
|
| 533 |
+
async def _timeout_member(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 534 |
+
member = self._resolve_member(ctx.guild, action.get("member"))
|
| 535 |
+
if not member:
|
| 536 |
+
return "Member not found."
|
| 537 |
+
minutes = max(1, min(int(action.get("minutes", 10)), 40320))
|
| 538 |
+
reason = action.get("reason", "AI Admin timeout")
|
| 539 |
+
until = discord.utils.utcnow() + dt.timedelta(minutes=minutes)
|
| 540 |
+
await member.timeout(until, reason=reason)
|
| 541 |
+
return f"Timed out {member.mention} for {minutes} minute(s)."
|
| 542 |
+
|
| 543 |
+
async def _untimeout_member(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 544 |
+
member = self._resolve_member(ctx.guild, action.get("member"))
|
| 545 |
+
if not member:
|
| 546 |
+
return "Member not found."
|
| 547 |
+
reason = action.get("reason", "AI Admin untimeout")
|
| 548 |
+
await member.timeout(None, reason=reason)
|
| 549 |
+
return f"Removed timeout from {member.mention}."
|
| 550 |
+
|
| 551 |
+
async def _add_role(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 552 |
+
member = self._resolve_member(ctx.guild, action.get("member"))
|
| 553 |
+
if not member:
|
| 554 |
+
return "Member not found."
|
| 555 |
+
role_name = str(action.get("role", "")).strip()
|
| 556 |
+
role = discord.utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles)
|
| 557 |
+
if not role:
|
| 558 |
+
return f"Role not found: {role_name}"
|
| 559 |
+
reason = action.get("reason", "AI Admin add role")
|
| 560 |
+
await member.add_roles(role, reason=reason)
|
| 561 |
+
return f"Added role **{role.name}** to {member.mention}."
|
| 562 |
+
|
| 563 |
+
async def _remove_role(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 564 |
+
member = self._resolve_member(ctx.guild, action.get("member"))
|
| 565 |
+
if not member:
|
| 566 |
+
return "Member not found."
|
| 567 |
+
role_name = str(action.get("role", "")).strip()
|
| 568 |
+
role = discord.utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles)
|
| 569 |
+
if not role:
|
| 570 |
+
return f"Role not found: {role_name}"
|
| 571 |
+
reason = action.get("reason", "AI Admin remove role")
|
| 572 |
+
await member.remove_roles(role, reason=reason)
|
| 573 |
+
return f"Removed role **{role.name}** from {member.mention}."
|
| 574 |
+
|
| 575 |
+
async def _lock_channel(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 576 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 577 |
+
return "Manage Channels permission required."
|
| 578 |
+
channel = self._resolve_channel(ctx.guild, action.get("channel"), ctx.channel)
|
| 579 |
+
if not channel:
|
| 580 |
+
return "Channel not found."
|
| 581 |
+
reason = action.get("reason", "AI Admin lock channel")
|
| 582 |
+
await channel.set_permissions(ctx.guild.default_role, send_messages=False, reason=reason)
|
| 583 |
+
return f"Locked channel {channel.mention}."
|
| 584 |
+
|
| 585 |
+
async def _unlock_channel(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 586 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 587 |
+
return "Manage Channels permission required."
|
| 588 |
+
channel = self._resolve_channel(ctx.guild, action.get("channel"), ctx.channel)
|
| 589 |
+
if not channel:
|
| 590 |
+
return "Channel not found."
|
| 591 |
+
reason = action.get("reason", "AI Admin unlock channel")
|
| 592 |
+
await channel.set_permissions(ctx.guild.default_role, send_messages=True, reason=reason)
|
| 593 |
+
return f"Unlocked channel {channel.mention}."
|
| 594 |
+
|
| 595 |
+
async def _set_slowmode(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 596 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 597 |
+
return "Manage Channels permission required."
|
| 598 |
+
channel = self._resolve_channel(ctx.guild, action.get("channel"), ctx.channel)
|
| 599 |
+
if not channel:
|
| 600 |
+
return "Channel not found."
|
| 601 |
+
seconds = max(0, min(int(action.get("seconds", 0)), 21600))
|
| 602 |
+
reason = action.get("reason", "AI Admin slowmode")
|
| 603 |
+
await channel.edit(slowmode_delay=seconds, reason=reason)
|
| 604 |
+
return f"Set slowmode in {channel.mention} to {seconds}s."
|
| 605 |
+
|
| 606 |
+
async def _purge_messages(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 607 |
+
if not ctx.author.guild_permissions.manage_messages:
|
| 608 |
+
return "Manage Messages permission required."
|
| 609 |
+
channel = self._resolve_channel(ctx.guild, action.get("channel"), ctx.channel)
|
| 610 |
+
if not channel:
|
| 611 |
+
return "Channel not found."
|
| 612 |
+
amount = max(1, min(int(action.get("amount", 10)), 200))
|
| 613 |
+
deleted = await channel.purge(limit=amount)
|
| 614 |
+
return f"Purged {len(deleted)} message(s) in {channel.mention}."
|
| 615 |
|
| 616 |
+
async def _delete_channel(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 617 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 618 |
+
return "Manage Channels permission required."
|
| 619 |
+
channel = self._resolve_guild_channel(ctx.guild, action.get("channel"), None)
|
| 620 |
+
if not channel:
|
| 621 |
+
return "Channel not found."
|
| 622 |
+
if ctx.channel and channel.id == ctx.channel.id:
|
| 623 |
+
return "Refusing to delete the channel currently being used for command execution."
|
| 624 |
+
reason = action.get("reason", "AI Admin delete channel")
|
| 625 |
+
name = channel.name
|
| 626 |
+
await channel.delete(reason=reason)
|
| 627 |
+
return f"Deleted channel #{name}."
|
| 628 |
+
|
| 629 |
+
async def _rename_channel(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 630 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 631 |
+
return "Manage Channels permission required."
|
| 632 |
+
channel = self._resolve_guild_channel(ctx.guild, action.get("channel"), None)
|
| 633 |
+
if not channel:
|
| 634 |
+
return "Channel not found."
|
| 635 |
+
new_name = str(action.get("new_name", "")).strip()
|
| 636 |
+
if not new_name:
|
| 637 |
+
return "New channel name is required."
|
| 638 |
+
reason = action.get("reason", "AI Admin rename channel")
|
| 639 |
+
old_name = channel.name
|
| 640 |
+
await channel.edit(name=new_name[:100], reason=reason)
|
| 641 |
+
return f"Renamed channel **{old_name}** -> **{new_name[:100]}**."
|
| 642 |
+
|
| 643 |
+
async def _create_category(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 644 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 645 |
+
return "Manage Channels permission required."
|
| 646 |
+
name = str(action.get("name", "new-category")).strip()[:100]
|
| 647 |
+
if not name:
|
| 648 |
+
return "Category name is required."
|
| 649 |
+
reason = action.get("reason", "AI Admin create category")
|
| 650 |
+
category = await ctx.guild.create_category(name=name, reason=reason)
|
| 651 |
+
return f"Created category: **{category.name}**."
|
| 652 |
+
|
| 653 |
+
async def _rename_category(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 654 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 655 |
+
return "Manage Channels permission required."
|
| 656 |
+
category = self._resolve_category(ctx.guild, action.get("category"))
|
| 657 |
+
if not category:
|
| 658 |
+
return "Category not found."
|
| 659 |
+
new_name = str(action.get("new_name", "")).strip()[:100]
|
| 660 |
+
if not new_name:
|
| 661 |
+
return "New category name is required."
|
| 662 |
+
reason = action.get("reason", "AI Admin rename category")
|
| 663 |
+
old_name = category.name
|
| 664 |
+
await category.edit(name=new_name, reason=reason)
|
| 665 |
+
return f"Renamed category **{old_name}** -> **{new_name}**."
|
| 666 |
+
|
| 667 |
+
async def _delete_category(self, action: dict[str, Any], ctx: commands.Context) -> str:
|
| 668 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 669 |
+
return "Manage Channels permission required."
|
| 670 |
+
category = self._resolve_category(ctx.guild, action.get("category"))
|
| 671 |
+
if not category:
|
| 672 |
+
return "Category not found."
|
| 673 |
+
reason = action.get("reason", "AI Admin delete category")
|
| 674 |
+
name = category.name
|
| 675 |
+
channels_inside = len(category.channels)
|
| 676 |
+
await category.delete(reason=reason)
|
| 677 |
+
return f"Deleted category **{name}** (had {channels_inside} channel(s))."
|
| 678 |
+
|
| 679 |
class AIAdmin(commands.Cog):
|
| 680 |
+
"""Autonomous AI Administrator Cog."""
|
| 681 |
+
|
| 682 |
def __init__(self, bot: commands.Bot) -> None:
|
| 683 |
self.bot = bot
|
| 684 |
self.permission_guard = PermissionGuard()
|
| 685 |
self.intelligence = IntelligenceLayer(bot)
|
| 686 |
self.execution = ExecutionEngine(bot)
|
| 687 |
+
|
| 688 |
+
@staticmethod
|
| 689 |
+
def _parse_duration_minutes(text: str) -> int | None:
|
| 690 |
+
match = re.search(r"(\d+)\s*(m|min|mins|minute|minutes|h|hr|hour|hours|d|day|days)?", text, re.IGNORECASE)
|
| 691 |
+
if not match:
|
| 692 |
+
return None
|
| 693 |
+
value = int(match.group(1))
|
| 694 |
+
unit = (match.group(2) or "m").lower()
|
| 695 |
+
if unit.startswith("h"):
|
| 696 |
+
value *= 60
|
| 697 |
+
elif unit.startswith("d"):
|
| 698 |
+
value *= 60 * 24
|
| 699 |
+
return max(1, min(value, 40320))
|
| 700 |
+
|
| 701 |
+
async def _try_direct_moderation(self, ctx: commands.Context, request: str) -> str | None:
|
| 702 |
+
if not ctx.guild:
|
| 703 |
+
return "Server only."
|
| 704 |
+
text = (request or "").strip()
|
| 705 |
+
lower = text.lower()
|
| 706 |
+
|
| 707 |
+
target = None
|
| 708 |
+
if ctx.message and ctx.message.mentions:
|
| 709 |
+
target = ctx.message.mentions[0]
|
| 710 |
+
else:
|
| 711 |
+
match = re.search(r"<@!?(\d+)>", text)
|
| 712 |
+
if match:
|
| 713 |
+
target = ctx.guild.get_member(int(match.group(1)))
|
| 714 |
+
|
| 715 |
+
if lower.startswith("kick ") and target:
|
| 716 |
+
if not ctx.author.guild_permissions.kick_members:
|
| 717 |
+
return "You need Kick Members permission."
|
| 718 |
+
await target.kick(reason=f"AI Admin by {ctx.author}")
|
| 719 |
+
return f"OK: Kicked {target.mention}"
|
| 720 |
+
|
| 721 |
+
if lower.startswith("ban ") and target:
|
| 722 |
+
if not ctx.author.guild_permissions.ban_members:
|
| 723 |
+
return "You need Ban Members permission."
|
| 724 |
+
await target.ban(reason=f"AI Admin by {ctx.author}", delete_message_days=0)
|
| 725 |
+
return f"OK: Banned {target.mention}"
|
| 726 |
+
|
| 727 |
+
if (lower.startswith("mute ") or lower.startswith("timeout ")) and target:
|
| 728 |
+
if not ctx.author.guild_permissions.moderate_members:
|
| 729 |
+
return "You need Moderate Members permission."
|
| 730 |
+
minutes = self._parse_duration_minutes(text) or 10
|
| 731 |
+
until = discord.utils.utcnow() + dt.timedelta(minutes=minutes)
|
| 732 |
+
await target.timeout(until, reason=f"AI Admin by {ctx.author}")
|
| 733 |
+
return f"OK: Timed out {target.mention} for {minutes} minute(s)"
|
| 734 |
+
|
| 735 |
+
if (lower.startswith("unmute ") or lower.startswith("untimeout ")) and target:
|
| 736 |
+
if not ctx.author.guild_permissions.moderate_members:
|
| 737 |
+
return "You need Moderate Members permission."
|
| 738 |
+
await target.timeout(None, reason=f"AI Admin by {ctx.author}")
|
| 739 |
+
return f"OK: Removed timeout from {target.mention}"
|
| 740 |
+
|
| 741 |
+
role_match = re.search(r"(?:give|add)\s+role\s+(.+?)\s+(?:to|for)\s+<@!?(\d+)>", text, re.IGNORECASE)
|
| 742 |
+
if role_match:
|
| 743 |
+
role_name = role_match.group(1).strip(" \"'")
|
| 744 |
+
member = ctx.guild.get_member(int(role_match.group(2)))
|
| 745 |
+
if not member:
|
| 746 |
+
return "Target member not found."
|
| 747 |
+
role = discord.utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles)
|
| 748 |
+
if not role:
|
| 749 |
+
return f"Role not found: {role_name}"
|
| 750 |
+
if not ctx.author.guild_permissions.manage_roles:
|
| 751 |
+
return "You need Manage Roles permission."
|
| 752 |
+
await member.add_roles(role, reason=f"AI Admin by {ctx.author}")
|
| 753 |
+
return f"OK: Added role **{role.name}** to {member.mention}"
|
| 754 |
+
|
| 755 |
+
remove_role_match = re.search(r"(?:remove)\s+role\s+(.+?)\s+(?:from)\s+<@!?(\d+)>", text, re.IGNORECASE)
|
| 756 |
+
if remove_role_match:
|
| 757 |
+
role_name = remove_role_match.group(1).strip(" \"'")
|
| 758 |
+
member = ctx.guild.get_member(int(remove_role_match.group(2)))
|
| 759 |
+
if not member:
|
| 760 |
+
return "Target member not found."
|
| 761 |
+
role = discord.utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles)
|
| 762 |
+
if not role:
|
| 763 |
+
return f"Role not found: {role_name}"
|
| 764 |
+
if not ctx.author.guild_permissions.manage_roles:
|
| 765 |
+
return "You need Manage Roles permission."
|
| 766 |
+
await member.remove_roles(role, reason=f"AI Admin by {ctx.author}")
|
| 767 |
+
return f"OK: Removed role **{role.name}** from {member.mention}"
|
| 768 |
+
|
| 769 |
+
lock_match = re.search(r"^lock(?:\s+<#(\d+)>)?", lower)
|
| 770 |
+
if lock_match:
|
| 771 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 772 |
+
return "You need Manage Channels permission."
|
| 773 |
+
channel = ctx.guild.get_channel(int(lock_match.group(1))) if lock_match.group(1) else ctx.channel
|
| 774 |
+
if isinstance(channel, discord.TextChannel):
|
| 775 |
+
await channel.set_permissions(ctx.guild.default_role, send_messages=False, reason=f"AI Admin by {ctx.author}")
|
| 776 |
+
return f"OK: Locked {channel.mention}"
|
| 777 |
+
|
| 778 |
+
unlock_match = re.search(r"^unlock(?:\s+<#(\d+)>)?", lower)
|
| 779 |
+
if unlock_match:
|
| 780 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 781 |
+
return "You need Manage Channels permission."
|
| 782 |
+
channel = ctx.guild.get_channel(int(unlock_match.group(1))) if unlock_match.group(1) else ctx.channel
|
| 783 |
+
if isinstance(channel, discord.TextChannel):
|
| 784 |
+
await channel.set_permissions(ctx.guild.default_role, send_messages=True, reason=f"AI Admin by {ctx.author}")
|
| 785 |
+
return f"OK: Unlocked {channel.mention}"
|
| 786 |
+
|
| 787 |
+
rename_channel_match = re.search(
|
| 788 |
+
r"(?:rename)\s+channel\s+(.+?)\s+(?:to|->)\s+(.+)$",
|
| 789 |
+
text,
|
| 790 |
+
re.IGNORECASE,
|
| 791 |
+
)
|
| 792 |
+
if rename_channel_match:
|
| 793 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 794 |
+
return "You need Manage Channels permission."
|
| 795 |
+
old_ref = rename_channel_match.group(1).strip(" \"'")
|
| 796 |
+
new_name = rename_channel_match.group(2).strip(" \"'")
|
| 797 |
+
channel = ExecutionEngine._resolve_guild_channel(ctx.guild, old_ref)
|
| 798 |
+
if not channel:
|
| 799 |
+
return f"Channel not found: {old_ref}"
|
| 800 |
+
if not new_name:
|
| 801 |
+
return "New channel name is required."
|
| 802 |
+
old_name = channel.name
|
| 803 |
+
await channel.edit(name=new_name[:100], reason=f"AI Admin by {ctx.author}")
|
| 804 |
+
return f"OK: Renamed channel **{old_name}** -> **{new_name[:100]}**"
|
| 805 |
+
|
| 806 |
+
delete_channel_match = re.search(r"(?:delete|remove)\s+channel\s+(.+)$", text, re.IGNORECASE)
|
| 807 |
+
if delete_channel_match:
|
| 808 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 809 |
+
return "You need Manage Channels permission."
|
| 810 |
+
ref = delete_channel_match.group(1).strip(" \"'")
|
| 811 |
+
channel = ExecutionEngine._resolve_guild_channel(ctx.guild, ref)
|
| 812 |
+
if not channel:
|
| 813 |
+
return f"Channel not found: {ref}"
|
| 814 |
+
if ctx.channel and channel.id == ctx.channel.id:
|
| 815 |
+
return "Refusing to delete the channel currently being used for command execution."
|
| 816 |
+
name = channel.name
|
| 817 |
+
await channel.delete(reason=f"AI Admin by {ctx.author}")
|
| 818 |
+
return f"OK: Deleted channel **{name}**"
|
| 819 |
+
|
| 820 |
+
rename_category_match = re.search(
|
| 821 |
+
r"(?:rename)\s+(?:category|directory)\s+(.+?)\s+(?:to|->)\s+(.+)$",
|
| 822 |
+
text,
|
| 823 |
+
re.IGNORECASE,
|
| 824 |
+
)
|
| 825 |
+
if rename_category_match:
|
| 826 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 827 |
+
return "You need Manage Channels permission."
|
| 828 |
+
old_ref = rename_category_match.group(1).strip(" \"'")
|
| 829 |
+
new_name = rename_category_match.group(2).strip(" \"'")
|
| 830 |
+
category = ExecutionEngine._resolve_category(ctx.guild, old_ref)
|
| 831 |
+
if not category:
|
| 832 |
+
return f"Category not found: {old_ref}"
|
| 833 |
+
if not new_name:
|
| 834 |
+
return "New category name is required."
|
| 835 |
+
old_name = category.name
|
| 836 |
+
await category.edit(name=new_name[:100], reason=f"AI Admin by {ctx.author}")
|
| 837 |
+
return f"OK: Renamed category **{old_name}** -> **{new_name[:100]}**"
|
| 838 |
+
|
| 839 |
+
delete_category_match = re.search(r"(?:delete|remove)\s+(?:category|directory)\s+(.+)$", text, re.IGNORECASE)
|
| 840 |
+
if delete_category_match:
|
| 841 |
+
if not ctx.author.guild_permissions.manage_channels:
|
| 842 |
+
return "You need Manage Channels permission."
|
| 843 |
+
ref = delete_category_match.group(1).strip(" \"'")
|
| 844 |
+
category = ExecutionEngine._resolve_category(ctx.guild, ref)
|
| 845 |
+
if not category:
|
| 846 |
+
return f"Category not found: {ref}"
|
| 847 |
+
name = category.name
|
| 848 |
+
channels_inside = len(category.channels)
|
| 849 |
+
await category.delete(reason=f"AI Admin by {ctx.author}")
|
| 850 |
+
return f"OK: Deleted category **{name}** (had {channels_inside} channel(s))"
|
| 851 |
+
|
| 852 |
+
return None
|
| 853 |
+
|
| 854 |
+
async def cog_unload(self) -> None:
|
| 855 |
+
await self.intelligence.close()
|
| 856 |
+
|
| 857 |
@commands.hybrid_command(name="ai_admin", description="Let AI administrate the server")
|
| 858 |
async def ai_admin(self, ctx: commands.Context, *, request: str) -> None:
|
| 859 |
+
allowed, deny_reason = self.permission_guard.check(ctx)
|
| 860 |
+
if not allowed:
|
| 861 |
+
await ctx.send(deny_reason, ephemeral=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
return
|
| 863 |
+
|
| 864 |
+
direct_result = await self._try_direct_moderation(ctx, request)
|
| 865 |
+
if direct_result is not None:
|
| 866 |
+
await ctx.send(direct_result, ephemeral=True)
|
| 867 |
+
return
|
| 868 |
+
|
| 869 |
+
if ctx.interaction and not ctx.interaction.response.is_done():
|
| 870 |
+
try:
|
| 871 |
+
await ctx.defer()
|
| 872 |
+
except discord.InteractionResponded:
|
| 873 |
+
pass
|
| 874 |
+
|
| 875 |
+
actions = await self.intelligence.ask_ai(request)
|
| 876 |
+
if not actions:
|
| 877 |
+
await ctx.send("AI failed to generate actions. Try again.", ephemeral=True)
|
| 878 |
+
return
|
| 879 |
+
|
| 880 |
+
response_text = None
|
| 881 |
+
for action in actions:
|
| 882 |
+
if "response_to_user" in action:
|
| 883 |
+
response_text = action.pop("response_to_user")
|
| 884 |
+
break
|
| 885 |
+
|
| 886 |
+
results = await self.execution.execute(actions, ctx)
|
| 887 |
+
|
| 888 |
+
if response_text:
|
| 889 |
+
await ctx.send(response_text)
|
| 890 |
+
else:
|
| 891 |
+
await ctx.send("\n".join(results))
|
| 892 |
+
|
| 893 |
+
@commands.hybrid_command(name="ai_help", description="Show AI Admin capabilities")
|
| 894 |
+
async def ai_help(self, ctx: commands.Context) -> None:
|
| 895 |
+
embed = discord.Embed(
|
| 896 |
+
title="AI Administrator Help",
|
| 897 |
+
description=(
|
| 898 |
+
"I can help manage your server using AI. Examples:\n\n"
|
| 899 |
+
"**Create a role:**\n"
|
| 900 |
+
"`/ai_admin create a moderator role with purple color`\n\n"
|
| 901 |
+
"**Create a channel:**\n"
|
| 902 |
+
"`/ai_admin make a private channel for staff`\n\n"
|
| 903 |
+
"**Create a giveaway:**\n"
|
| 904 |
+
"`/ai_admin create a giveaway for Discord Nitro 24 hours`\n\n"
|
| 905 |
+
"**Create a tournament:**\n"
|
| 906 |
+
"`/ai_admin setup a Valorant tournament for 16 players`\n\n"
|
| 907 |
+
"**Create a poll:**\n"
|
| 908 |
+
"`/ai_admin make a poll about server events`\n\n"
|
| 909 |
"**Announce:**\n"
|
| 910 |
+
"`/ai_admin announce server maintenance in 1 hour`\n\n"
|
| 911 |
+
"**Direct moderation shortcuts:**\n"
|
| 912 |
+
"`/ai_admin kick @user`\n"
|
| 913 |
+
"`/ai_admin ban @user`\n"
|
| 914 |
+
"`/ai_admin mute @user 30m`\n"
|
| 915 |
+
"`/ai_admin unmute @user`\n"
|
| 916 |
+
"`/ai_admin add role VIP to @user`\n"
|
| 917 |
+
"`/ai_admin remove role VIP from @user`\n"
|
| 918 |
+
"`/ai_admin lock #channel`\n"
|
| 919 |
+
"`/ai_admin unlock #channel`\n"
|
| 920 |
+
"`/ai_admin rename channel old-name to new-name`\n"
|
| 921 |
+
"`/ai_admin delete channel old-name`\n"
|
| 922 |
+
"`/ai_admin rename category Staff to Team-Staff`\n"
|
| 923 |
+
"`/ai_admin delete category Team-Staff`"
|
| 924 |
),
|
| 925 |
color=discord.Color.blue()
|
| 926 |
)
|
| 927 |
+
await ctx.send(embed=embed, ephemeral=True)
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
async def setup(bot: commands.Bot) -> None:
|
| 931 |
+
await bot.add_cog(AIAdmin(bot))
|
bot/cogs/ai_suite.py
CHANGED
|
@@ -1354,12 +1354,11 @@ class AISuite(commands.Cog):
|
|
| 1354 |
# Fallback mappings for panel bootstrapping when planner is unavailable.
|
| 1355 |
lowered = text.lower()
|
| 1356 |
if any(k in lowered for k in ("اقتراح", "suggestion panel", "suggest panel", "suggestions panel")):
|
| 1357 |
-
|
| 1358 |
-
|
| 1359 |
-
if
|
| 1360 |
-
|
| 1361 |
-
|
| 1362 |
-
return "استخدم `/suggestion_panel` لنشر لوحة الاقتراحات التفاعلية." if guild_lang == "ar" else "Use `/suggestion_panel` to deploy the interactive suggestions UI."
|
| 1363 |
if any(k in lowered for k in ("تذكرة", "tickets", "ticket panel", "دعم")):
|
| 1364 |
if ctx:
|
| 1365 |
cmd = self.bot.get_command("ticket_panel")
|
|
|
|
| 1354 |
# Fallback mappings for panel bootstrapping when planner is unavailable.
|
| 1355 |
lowered = text.lower()
|
| 1356 |
if any(k in lowered for k in ("اقتراح", "suggestion panel", "suggest panel", "suggestions panel")):
|
| 1357 |
+
return (
|
| 1358 |
+
"نظام الاقتراحات يعمل عبر قناة مخصصة. استخدم `/setsuggestionchannel #channel` ثم أرسل اقتراحك هناك."
|
| 1359 |
+
if guild_lang == "ar"
|
| 1360 |
+
else "Suggestions use a dedicated channel. Run `/setsuggestionchannel #channel` then post suggestions there."
|
| 1361 |
+
)
|
|
|
|
| 1362 |
if any(k in lowered for k in ("تذكرة", "tickets", "ticket panel", "دعم")):
|
| 1363 |
if ctx:
|
| 1364 |
cmd = self.bot.get_command("ticket_panel")
|
bot/cogs/banner_manager.py
CHANGED
|
@@ -4,6 +4,9 @@ Server Banner Management Cog - Allows servers to set custom banners
|
|
| 4 |
|
| 5 |
from __future__ import annotations
|
| 6 |
|
|
|
|
|
|
|
|
|
|
| 7 |
import discord
|
| 8 |
from discord.ext import commands
|
| 9 |
from discord import app_commands
|
|
@@ -26,15 +29,19 @@ class SetBannerModal(discord.ui.Modal, title="🖼️ Set Server Banner"):
|
|
| 26 |
self.cog = cog
|
| 27 |
|
| 28 |
async def on_submit(self, interaction: discord.Interaction) -> None:
|
| 29 |
-
url = self.banner_url.value.strip()
|
| 30 |
|
| 31 |
# Validate URL
|
| 32 |
-
if not
|
| 33 |
-
await interaction.response.send_message(
|
|
|
|
|
|
|
|
|
|
| 34 |
return
|
| 35 |
|
| 36 |
guild_id = interaction.guild.id if interaction.guild else 0
|
| 37 |
-
|
|
|
|
| 38 |
# Save to database
|
| 39 |
await self.cog.bot.db.execute(
|
| 40 |
"INSERT INTO guild_config(guild_id, custom_banner_url) VALUES (?, ?) "
|
|
@@ -56,6 +63,30 @@ class SetBannerModal(discord.ui.Modal, title="🖼️ Set Server Banner"):
|
|
| 56 |
class BannerManager(commands.Cog):
|
| 57 |
def __init__(self, bot: commands.Bot) -> None:
|
| 58 |
self.bot = bot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
@app_commands.command(name="set_banner", description="Set a custom banner for this server")
|
| 61 |
@app_commands.checks.has_permissions(manage_guild=True)
|
|
@@ -65,6 +96,7 @@ class BannerManager(commands.Cog):
|
|
| 65 |
await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True)
|
| 66 |
return
|
| 67 |
|
|
|
|
| 68 |
await interaction.response.send_modal(SetBannerModal(self))
|
| 69 |
|
| 70 |
@app_commands.command(name="remove_banner", description="Remove the custom banner from this server")
|
|
@@ -75,6 +107,7 @@ class BannerManager(commands.Cog):
|
|
| 75 |
await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True)
|
| 76 |
return
|
| 77 |
|
|
|
|
| 78 |
guild_id = interaction.guild.id
|
| 79 |
|
| 80 |
await self.bot.db.execute(
|
|
@@ -97,6 +130,7 @@ class BannerManager(commands.Cog):
|
|
| 97 |
await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True)
|
| 98 |
return
|
| 99 |
|
|
|
|
| 100 |
guild_id = interaction.guild.id
|
| 101 |
|
| 102 |
row = await self.bot.db.fetchone(
|
|
|
|
| 4 |
|
| 5 |
from __future__ import annotations
|
| 6 |
|
| 7 |
+
import sqlite3
|
| 8 |
+
from urllib.parse import urlparse
|
| 9 |
+
|
| 10 |
import discord
|
| 11 |
from discord.ext import commands
|
| 12 |
from discord import app_commands
|
|
|
|
| 29 |
self.cog = cog
|
| 30 |
|
| 31 |
async def on_submit(self, interaction: discord.Interaction) -> None:
|
| 32 |
+
url = self.banner_url.value.strip().strip("<>")
|
| 33 |
|
| 34 |
# Validate URL
|
| 35 |
+
if not self.cog._looks_like_image_url(url):
|
| 36 |
+
await interaction.response.send_message(
|
| 37 |
+
"Invalid image URL. Use a public http(s) link ending with png/jpg/jpeg/webp/gif.",
|
| 38 |
+
ephemeral=True,
|
| 39 |
+
)
|
| 40 |
return
|
| 41 |
|
| 42 |
guild_id = interaction.guild.id if interaction.guild else 0
|
| 43 |
+
|
| 44 |
+
await self.cog._ensure_schema()
|
| 45 |
# Save to database
|
| 46 |
await self.cog.bot.db.execute(
|
| 47 |
"INSERT INTO guild_config(guild_id, custom_banner_url) VALUES (?, ?) "
|
|
|
|
| 63 |
class BannerManager(commands.Cog):
|
| 64 |
def __init__(self, bot: commands.Bot) -> None:
|
| 65 |
self.bot = bot
|
| 66 |
+
self._schema_checked = False
|
| 67 |
+
|
| 68 |
+
async def _ensure_schema(self) -> None:
|
| 69 |
+
if self._schema_checked:
|
| 70 |
+
return
|
| 71 |
+
try:
|
| 72 |
+
await self.bot.db.execute(
|
| 73 |
+
"ALTER TABLE guild_config ADD COLUMN custom_banner_url TEXT"
|
| 74 |
+
)
|
| 75 |
+
except sqlite3.OperationalError as exc:
|
| 76 |
+
if "duplicate column name" not in str(exc).lower():
|
| 77 |
+
raise
|
| 78 |
+
self._schema_checked = True
|
| 79 |
+
|
| 80 |
+
@staticmethod
|
| 81 |
+
def _looks_like_image_url(url: str) -> bool:
|
| 82 |
+
try:
|
| 83 |
+
parsed = urlparse(url)
|
| 84 |
+
except Exception:
|
| 85 |
+
return False
|
| 86 |
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
| 87 |
+
return False
|
| 88 |
+
path = (parsed.path or "").lower()
|
| 89 |
+
return path.endswith((".png", ".jpg", ".jpeg", ".webp", ".gif"))
|
| 90 |
|
| 91 |
@app_commands.command(name="set_banner", description="Set a custom banner for this server")
|
| 92 |
@app_commands.checks.has_permissions(manage_guild=True)
|
|
|
|
| 96 |
await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True)
|
| 97 |
return
|
| 98 |
|
| 99 |
+
await self._ensure_schema()
|
| 100 |
await interaction.response.send_modal(SetBannerModal(self))
|
| 101 |
|
| 102 |
@app_commands.command(name="remove_banner", description="Remove the custom banner from this server")
|
|
|
|
| 107 |
await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True)
|
| 108 |
return
|
| 109 |
|
| 110 |
+
await self._ensure_schema()
|
| 111 |
guild_id = interaction.guild.id
|
| 112 |
|
| 113 |
await self.bot.db.execute(
|
|
|
|
| 130 |
await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True)
|
| 131 |
return
|
| 132 |
|
| 133 |
+
await self._ensure_schema()
|
| 134 |
guild_id = interaction.guild.id
|
| 135 |
|
| 136 |
row = await self.bot.db.fetchone(
|
bot/cogs/board_games.py
CHANGED
|
@@ -808,7 +808,7 @@ class BoardGames(commands.Cog):
|
|
| 808 |
|
| 809 |
@commands.hybrid_command(name="games_panel", hidden=True, description="Deprecated: use /gamehub", with_app_command=False)
|
| 810 |
async def games_panel(self, ctx: commands.Context) -> None:
|
| 811 |
-
await ctx.reply("
|
| 812 |
|
| 813 |
@commands.hybrid_command(name="board_forfeit", hidden=True, description="Forfeit active board game", with_app_command=False)
|
| 814 |
async def board_forfeit(self, ctx: commands.Context) -> None:
|
|
|
|
| 808 |
|
| 809 |
@commands.hybrid_command(name="games_panel", hidden=True, description="Deprecated: use /gamehub", with_app_command=False)
|
| 810 |
async def games_panel(self, ctx: commands.Context) -> None:
|
| 811 |
+
await ctx.reply("<:animatedarrowgreen:1477261279428087979> This panel is deprecated. Use `/gamehub` for the improved game experience.")
|
| 812 |
|
| 813 |
@commands.hybrid_command(name="board_forfeit", hidden=True, description="Forfeit active board game", with_app_command=False)
|
| 814 |
async def board_forfeit(self, ctx: commands.Context) -> None:
|
bot/cogs/community.py
CHANGED
|
@@ -474,7 +474,7 @@ class QuickOptionsView(discord.ui.View):
|
|
| 474 |
|
| 475 |
@discord.ui.button(label="Priority", style=discord.ButtonStyle.primary, row=2)
|
| 476 |
async def priority(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
|
| 477 |
-
self.parent.options = ["🔴 High", "
|
| 478 |
await self._apply_options(interaction)
|
| 479 |
|
| 480 |
async def _apply_options(self, interaction: discord.Interaction) -> None:
|
|
@@ -933,9 +933,9 @@ class Community(commands.Cog):
|
|
| 933 |
await msg.edit(embed=embed, view=None)
|
| 934 |
if winner_id:
|
| 935 |
await channel.send(f"🎉 Giveaway **#{giveaway_id}** winner: <@{winner_id}> • Prize: **{prize}**")
|
| 936 |
-
|
| 937 |
if log_channel_id and log_channel_id[0]:
|
| 938 |
-
log_channel =
|
| 939 |
if log_channel:
|
| 940 |
log_embed = discord.Embed(
|
| 941 |
title="🎁 Giveaway Ended",
|
|
@@ -946,8 +946,6 @@ class Community(commands.Cog):
|
|
| 946 |
log_embed.add_field(name="Prize", value=prize, inline=True)
|
| 947 |
log_embed.add_field(name="Giveaway ID", value=str(giveaway_id), inline=True)
|
| 948 |
await log_channel.send(embed=log_embed)
|
| 949 |
-
else:
|
| 950 |
-
await ctx.reply(f"Winner: <@{winner_id}>")
|
| 951 |
|
| 952 |
@commands.hybrid_command(name="setsuggestionchannel", description="Set suggestion channel")
|
| 953 |
@commands.has_permissions(manage_guild=True)
|
|
@@ -1007,6 +1005,134 @@ class Community(commands.Cog):
|
|
| 1007 |
)
|
| 1008 |
await ctx.reply(embed=embed, view=view)
|
| 1009 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1010 |
|
| 1011 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 1012 |
# GLOBAL TICKETS + GIVEAWAYS
|
|
|
|
| 474 |
|
| 475 |
@discord.ui.button(label="Priority", style=discord.ButtonStyle.primary, row=2)
|
| 476 |
async def priority(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
|
| 477 |
+
self.parent.options = ["🔴 High", "<:animatedarrowyellow:1477261257592668271> Medium", "<:animatedarrowgreen:1477261279428087979> Low"]
|
| 478 |
await self._apply_options(interaction)
|
| 479 |
|
| 480 |
async def _apply_options(self, interaction: discord.Interaction) -> None:
|
|
|
|
| 933 |
await msg.edit(embed=embed, view=None)
|
| 934 |
if winner_id:
|
| 935 |
await channel.send(f"🎉 Giveaway **#{giveaway_id}** winner: <@{winner_id}> • Prize: **{prize}**")
|
| 936 |
+
log_channel_id = await self.bot.db.fetchone("SELECT log_channel_id FROM guild_config WHERE guild_id = ?", guild.id)
|
| 937 |
if log_channel_id and log_channel_id[0]:
|
| 938 |
+
log_channel = guild.get_channel(log_channel_id[0])
|
| 939 |
if log_channel:
|
| 940 |
log_embed = discord.Embed(
|
| 941 |
title="🎁 Giveaway Ended",
|
|
|
|
| 946 |
log_embed.add_field(name="Prize", value=prize, inline=True)
|
| 947 |
log_embed.add_field(name="Giveaway ID", value=str(giveaway_id), inline=True)
|
| 948 |
await log_channel.send(embed=log_embed)
|
|
|
|
|
|
|
| 949 |
|
| 950 |
@commands.hybrid_command(name="setsuggestionchannel", description="Set suggestion channel")
|
| 951 |
@commands.has_permissions(manage_guild=True)
|
|
|
|
| 1005 |
)
|
| 1006 |
await ctx.reply(embed=embed, view=view)
|
| 1007 |
|
| 1008 |
+
@commands.hybrid_group(name="giveaway", description="Giveaway commands")
|
| 1009 |
+
@commands.has_permissions(manage_guild=True)
|
| 1010 |
+
async def giveaway_group(self, ctx: commands.Context) -> None:
|
| 1011 |
+
if ctx.invoked_subcommand is None:
|
| 1012 |
+
await ctx.reply("Use: `/giveaway start`, `/giveaway end`, `/giveaway reroll`.")
|
| 1013 |
+
|
| 1014 |
+
async def giveaway_create(self, ctx_or_interaction: commands.Context | discord.Interaction, minutes: int, winner_count: int, *, prize: str) -> None:
|
| 1015 |
+
guild = ctx_or_interaction.guild
|
| 1016 |
+
channel = ctx_or_interaction.channel
|
| 1017 |
+
if not guild or not isinstance(channel, discord.TextChannel):
|
| 1018 |
+
if isinstance(ctx_or_interaction, commands.Context):
|
| 1019 |
+
await ctx_or_interaction.reply("Server text channel only.")
|
| 1020 |
+
else:
|
| 1021 |
+
await ctx_or_interaction.followup.send("Server text channel only.", ephemeral=True)
|
| 1022 |
+
return
|
| 1023 |
+
end_time = int(time.time()) + max(1, minutes) * 60
|
| 1024 |
+
view = GiveawayJoinView(self, guild.id)
|
| 1025 |
+
view.winners_count = max(1, winner_count)
|
| 1026 |
+
embed = view.build_embed(prize=prize, end_time=end_time, winners=view.winners_count, entrants=0, ended=False)
|
| 1027 |
+
msg = await channel.send(embed=embed, view=view)
|
| 1028 |
+
await self.bot.db.execute(
|
| 1029 |
+
"INSERT INTO giveaways(guild_id, channel_id, message_id, prize, end_time, winner_id, ended) VALUES (?, ?, ?, ?, ?, NULL, 0)",
|
| 1030 |
+
guild.id,
|
| 1031 |
+
channel.id,
|
| 1032 |
+
msg.id,
|
| 1033 |
+
prize[:250],
|
| 1034 |
+
end_time,
|
| 1035 |
+
)
|
| 1036 |
+
row = await self.bot.db.fetchone("SELECT id FROM giveaways WHERE message_id = ?", msg.id)
|
| 1037 |
+
view.giveaway_id = int(row[0]) if row else 0
|
| 1038 |
+
if view.giveaway_id:
|
| 1039 |
+
await msg.edit(embed=view.build_embed(prize=prize, end_time=end_time, winners=view.winners_count, entrants=0, ended=False), view=view)
|
| 1040 |
+
response = f"✅ Giveaway created (ID: `{view.giveaway_id}`) • ends <t:{end_time}:R>"
|
| 1041 |
+
if isinstance(ctx_or_interaction, commands.Context):
|
| 1042 |
+
await ctx_or_interaction.reply(response)
|
| 1043 |
+
else:
|
| 1044 |
+
await ctx_or_interaction.followup.send(response, ephemeral=True)
|
| 1045 |
+
|
| 1046 |
+
async def giveaway_end(self, ctx_or_interaction: commands.Context | discord.Interaction, giveaway_id: int) -> None:
|
| 1047 |
+
row = await self.bot.db.fetchone(
|
| 1048 |
+
"SELECT guild_id, channel_id, message_id, prize, ended FROM giveaways WHERE id = ?",
|
| 1049 |
+
giveaway_id,
|
| 1050 |
+
)
|
| 1051 |
+
if not row:
|
| 1052 |
+
msg = "Giveaway not found."
|
| 1053 |
+
if isinstance(ctx_or_interaction, commands.Context):
|
| 1054 |
+
await ctx_or_interaction.reply(msg)
|
| 1055 |
+
else:
|
| 1056 |
+
await ctx_or_interaction.followup.send(msg, ephemeral=True)
|
| 1057 |
+
return
|
| 1058 |
+
guild_id, channel_id, message_id, prize, ended = row
|
| 1059 |
+
if int(ended or 0) == 1:
|
| 1060 |
+
msg = "Giveaway already ended."
|
| 1061 |
+
if isinstance(ctx_or_interaction, commands.Context):
|
| 1062 |
+
await ctx_or_interaction.reply(msg)
|
| 1063 |
+
else:
|
| 1064 |
+
await ctx_or_interaction.followup.send(msg, ephemeral=True)
|
| 1065 |
+
return
|
| 1066 |
+
entries = await self.bot.db.fetchall("SELECT user_id FROM giveaway_entries WHERE giveaway_id = ?", giveaway_id)
|
| 1067 |
+
users = [uid for (uid,) in entries]
|
| 1068 |
+
winner_id = random.choice(users) if users else None
|
| 1069 |
+
await self.bot.db.execute("UPDATE giveaways SET ended = 1, winner_id = ? WHERE id = ?", winner_id, giveaway_id)
|
| 1070 |
+
guild = self.bot.get_guild(int(guild_id))
|
| 1071 |
+
channel = guild.get_channel(int(channel_id)) if guild else None
|
| 1072 |
+
if isinstance(channel, discord.TextChannel):
|
| 1073 |
+
try:
|
| 1074 |
+
msg_obj = await channel.fetch_message(int(message_id))
|
| 1075 |
+
embed = msg_obj.embeds[0] if msg_obj.embeds else discord.Embed(title="🎁 Giveaway")
|
| 1076 |
+
embed.color = NEON_ORANGE
|
| 1077 |
+
embed.add_field(name="Winner", value=(f"<@{winner_id}>" if winner_id else "No entrants"), inline=False)
|
| 1078 |
+
await msg_obj.edit(embed=embed, view=None)
|
| 1079 |
+
except Exception:
|
| 1080 |
+
pass
|
| 1081 |
+
if winner_id:
|
| 1082 |
+
await channel.send(f"🎉 Giveaway **#{giveaway_id}** winner: <@{winner_id}> • Prize: **{prize}**")
|
| 1083 |
+
else:
|
| 1084 |
+
await channel.send(f"🎉 Giveaway **#{giveaway_id}** ended with no entrants.")
|
| 1085 |
+
reply = f"✅ Giveaway `{giveaway_id}` ended."
|
| 1086 |
+
if isinstance(ctx_or_interaction, commands.Context):
|
| 1087 |
+
await ctx_or_interaction.reply(reply)
|
| 1088 |
+
else:
|
| 1089 |
+
await ctx_or_interaction.followup.send(reply, ephemeral=True)
|
| 1090 |
+
|
| 1091 |
+
@giveaway_group.command(name="start", description="Start a giveaway in current channel")
|
| 1092 |
+
async def giveaway_start(self, ctx: commands.Context, minutes: int, winners: int, *, prize: str) -> None:
|
| 1093 |
+
await self.giveaway_create(ctx, minutes, winners, prize=prize)
|
| 1094 |
+
|
| 1095 |
+
@giveaway_group.command(name="end", description="End a giveaway by ID")
|
| 1096 |
+
async def giveaway_end_cmd(self, ctx: commands.Context, giveaway_id: int) -> None:
|
| 1097 |
+
await self.giveaway_end(ctx, giveaway_id)
|
| 1098 |
+
|
| 1099 |
+
@giveaway_group.command(name="reroll", description="Reroll winner for ended giveaway")
|
| 1100 |
+
async def giveaway_reroll(self, ctx: commands.Context, giveaway_id: int) -> None:
|
| 1101 |
+
row = await self.bot.db.fetchone("SELECT ended FROM giveaways WHERE id = ?", giveaway_id)
|
| 1102 |
+
if not row or int(row[0] or 0) != 1:
|
| 1103 |
+
await ctx.reply("Giveaway must be ended before reroll.")
|
| 1104 |
+
return
|
| 1105 |
+
await self.bot.db.execute("UPDATE giveaways SET ended = 0, winner_id = NULL WHERE id = ?", giveaway_id)
|
| 1106 |
+
await self.giveaway_end(ctx, giveaway_id)
|
| 1107 |
+
|
| 1108 |
+
@commands.hybrid_group(name="ticket", description="Ticket commands")
|
| 1109 |
+
async def ticket_group(self, ctx: commands.Context) -> None:
|
| 1110 |
+
if ctx.invoked_subcommand is None:
|
| 1111 |
+
await ctx.reply("Use: `/ticket close` or `/ticket delete` in a ticket channel.")
|
| 1112 |
+
|
| 1113 |
+
@ticket_group.command(name="close", description="Close current ticket channel")
|
| 1114 |
+
async def ticket_close_cmd(self, ctx: commands.Context) -> None:
|
| 1115 |
+
channel = ctx.channel
|
| 1116 |
+
if not isinstance(channel, discord.TextChannel) or not channel.name.startswith("ticket-"):
|
| 1117 |
+
await ctx.reply("This is not a ticket channel.")
|
| 1118 |
+
return
|
| 1119 |
+
await self.bot.db.execute("UPDATE tickets SET status = 'closed' WHERE channel_id = ?", channel.id)
|
| 1120 |
+
await ctx.reply("🔒 Closing ticket in 5 seconds...")
|
| 1121 |
+
await asyncio.sleep(5)
|
| 1122 |
+
await channel.delete(reason=f"Closed by {ctx.author}")
|
| 1123 |
+
|
| 1124 |
+
@ticket_group.command(name="delete", description="Delete current ticket channel")
|
| 1125 |
+
@commands.has_permissions(manage_channels=True)
|
| 1126 |
+
async def ticket_delete_cmd(self, ctx: commands.Context) -> None:
|
| 1127 |
+
channel = ctx.channel
|
| 1128 |
+
if not isinstance(channel, discord.TextChannel) or not channel.name.startswith("ticket-"):
|
| 1129 |
+
await ctx.reply("This is not a ticket channel.")
|
| 1130 |
+
return
|
| 1131 |
+
await self.bot.db.execute("DELETE FROM tickets WHERE channel_id = ?", channel.id)
|
| 1132 |
+
await ctx.reply("🗑️ Deleting ticket in 3 seconds...")
|
| 1133 |
+
await asyncio.sleep(3)
|
| 1134 |
+
await channel.delete(reason=f"Deleted by {ctx.author}")
|
| 1135 |
+
|
| 1136 |
|
| 1137 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 1138 |
# GLOBAL TICKETS + GIVEAWAYS
|
bot/cogs/configuration.py
CHANGED
|
@@ -273,15 +273,15 @@ class FreeGameSetupView(discord.ui.View):
|
|
| 273 |
min_values=1,
|
| 274 |
max_values=3,
|
| 275 |
options=[
|
| 276 |
-
discord.SelectOption(label="Epic Games", value="epic", emoji="
|
| 277 |
-
discord.SelectOption(label="Steam", value="steam", emoji="
|
| 278 |
-
discord.SelectOption(label="GOG", value="gog", emoji="
|
| 279 |
],
|
| 280 |
)
|
| 281 |
async def stores(self, interaction: discord.Interaction, select: discord.ui.Select) -> None:
|
| 282 |
self.platforms = set(select.values)
|
| 283 |
await self._save(interaction)
|
| 284 |
-
await interaction.response.send_message("
|
| 285 |
|
| 286 |
@discord.ui.select(
|
| 287 |
placeholder="Choose ping role (optional)",
|
|
@@ -620,7 +620,7 @@ class Configuration(commands.Cog):
|
|
| 620 |
)
|
| 621 |
view = FreeGameSetupView(self, ctx.guild.id, channel.id, role.id if role else None)
|
| 622 |
embed = discord.Embed(
|
| 623 |
-
title="
|
| 624 |
description=(
|
| 625 |
f"Channel: {channel.mention}\n"
|
| 626 |
f"Role ping: {role.mention if role else 'None'}\n"
|
|
|
|
| 273 |
min_values=1,
|
| 274 |
max_values=3,
|
| 275 |
options=[
|
| 276 |
+
discord.SelectOption(label="Epic Games", value="epic", emoji="<:animatedarrowgreen:1477261279428087979>"),
|
| 277 |
+
discord.SelectOption(label="Steam", value="steam", emoji="<:animatedarrowgreen:1477261279428087979>"),
|
| 278 |
+
discord.SelectOption(label="GOG", value="gog", emoji="<:animatedarrowgreen:1477261279428087979>"),
|
| 279 |
],
|
| 280 |
)
|
| 281 |
async def stores(self, interaction: discord.Interaction, select: discord.ui.Select) -> None:
|
| 282 |
self.platforms = set(select.values)
|
| 283 |
await self._save(interaction)
|
| 284 |
+
await interaction.response.send_message("<:animatedarrowgreen:1477261279428087979> Free games stores updated.", ephemeral=True)
|
| 285 |
|
| 286 |
@discord.ui.select(
|
| 287 |
placeholder="Choose ping role (optional)",
|
|
|
|
| 620 |
)
|
| 621 |
view = FreeGameSetupView(self, ctx.guild.id, channel.id, role.id if role else None)
|
| 622 |
embed = discord.Embed(
|
| 623 |
+
title="<:animatedarrowgreen:1477261279428087979> Free Games Setup",
|
| 624 |
description=(
|
| 625 |
f"Channel: {channel.mention}\n"
|
| 626 |
f"Role ping: {role.mention if role else 'None'}\n"
|
bot/cogs/engagement.py
CHANGED
|
@@ -15,7 +15,7 @@ from bot.theme import (
|
|
| 15 |
level_display, economy_embed, success_embed, error_embed, info_embed,
|
| 16 |
leaderboard_embed, profile_embed, gaming_embed, tournament_embed,
|
| 17 |
format_leaderboard, money_display, timestamp_display, double_line, triple_line,
|
| 18 |
-
pick_neon_color, shimmer
|
| 19 |
)
|
| 20 |
from bot.emojis import (
|
| 21 |
ui, E_DIAMOND, E_STAR, E_CATJAM, E_ARROW_BLUE, E_ARROW_GREEN,
|
|
@@ -189,8 +189,27 @@ class EconomyPanelView(discord.ui.View):
|
|
| 189 |
if not interaction.guild:
|
| 190 |
await interaction.response.send_message("Server only.", ephemeral=True)
|
| 191 |
return
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
@discord.ui.button(label="Rob", emoji=ui("gun"), style=discord.ButtonStyle.danger, custom_id="economy:rob", row=1)
|
| 196 |
async def rob_btn(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
|
|
@@ -224,7 +243,15 @@ class EconomyPanelView(discord.ui.View):
|
|
| 224 |
guild_id,
|
| 225 |
)
|
| 226 |
if not rows:
|
| 227 |
-
await interaction.followup.send(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
return
|
| 229 |
lines = []
|
| 230 |
for rank, (uid, total) in enumerate(rows, 1):
|
|
@@ -461,9 +488,9 @@ class GambleModal(discord.ui.Modal, title="🎲 Gamble Coins"):
|
|
| 461 |
|
| 462 |
class RobModal(discord.ui.Modal, title="🔫 Rob a User"):
|
| 463 |
target = discord.ui.TextInput(
|
| 464 |
-
label="User ID or Mention",
|
| 465 |
-
placeholder="
|
| 466 |
-
required=
|
| 467 |
max_length=64,
|
| 468 |
)
|
| 469 |
|
|
@@ -475,11 +502,23 @@ class RobModal(discord.ui.Modal, title="🔫 Rob a User"):
|
|
| 475 |
|
| 476 |
async def on_submit(self, interaction: discord.Interaction) -> None:
|
| 477 |
await interaction.response.defer(ephemeral=True)
|
|
|
|
| 478 |
val = self.target.value.strip().replace("<", "").replace(">", "").replace("@", "").replace("!", "")
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
return
|
| 484 |
if tid == self.user_id:
|
| 485 |
await interaction.followup.send("You can't rob yourself!", ephemeral=True)
|
|
@@ -721,9 +760,19 @@ class Engagement(commands.Cog):
|
|
| 721 |
)
|
| 722 |
if not row:
|
| 723 |
if lang == "ar":
|
| 724 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 725 |
else:
|
| 726 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
return
|
| 728 |
|
| 729 |
xp, level = row
|
|
@@ -766,9 +815,19 @@ class Engagement(commands.Cog):
|
|
| 766 |
)
|
| 767 |
if not rows:
|
| 768 |
if lang == "ar":
|
| 769 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
else:
|
| 771 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 772 |
return
|
| 773 |
|
| 774 |
lines = []
|
|
@@ -969,10 +1028,20 @@ class Engagement(commands.Cog):
|
|
| 969 |
await ctx.reply(embed=embed)
|
| 970 |
|
| 971 |
@commands.hybrid_command(name="rob", description="Attempt to rob another user (risky!)", hidden=True, with_app_command=False)
|
| 972 |
-
async def rob(self, ctx: commands.Context, target: discord.Member) -> None:
|
| 973 |
guild_id = ctx.guild.id
|
| 974 |
lang = await self.bot.get_guild_language(guild_id)
|
| 975 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 976 |
if target.id == ctx.author.id or target.bot:
|
| 977 |
if lang == "ar":
|
| 978 |
await ctx.reply("❌ هدف غير صالح.")
|
|
@@ -1082,7 +1151,7 @@ class Engagement(commands.Cog):
|
|
| 1082 |
await self.gamble(ctx, bet)
|
| 1083 |
|
| 1084 |
@economy.command(name="rob", description="Attempt to rob another user")
|
| 1085 |
-
async def economy_rob(self, ctx: commands.Context, target: discord.Member) -> None:
|
| 1086 |
await self.rob(ctx, target)
|
| 1087 |
|
| 1088 |
@commands.hybrid_command(name="profile")
|
|
@@ -1486,9 +1555,19 @@ class Engagement(commands.Cog):
|
|
| 1486 |
)
|
| 1487 |
if not row:
|
| 1488 |
if lang == "ar":
|
| 1489 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1490 |
else:
|
| 1491 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1492 |
return
|
| 1493 |
|
| 1494 |
_, status, winner_id = row
|
|
@@ -1542,9 +1621,19 @@ class Engagement(commands.Cog):
|
|
| 1542 |
)
|
| 1543 |
if not rows:
|
| 1544 |
if lang == "ar":
|
| 1545 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1546 |
else:
|
| 1547 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1548 |
return
|
| 1549 |
|
| 1550 |
games = "🎮 " + " • ".join(game for (game,) in rows)
|
|
@@ -1579,9 +1668,19 @@ class Engagement(commands.Cog):
|
|
| 1579 |
)
|
| 1580 |
if not row:
|
| 1581 |
if lang == "ar":
|
| 1582 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1583 |
else:
|
| 1584 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1585 |
return
|
| 1586 |
|
| 1587 |
game_rows = await self.bot.db.fetchall(
|
|
@@ -1635,7 +1734,20 @@ class Engagement(commands.Cog):
|
|
| 1635 |
name,
|
| 1636 |
)
|
| 1637 |
if not row:
|
| 1638 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1639 |
return
|
| 1640 |
if lang == "ar":
|
| 1641 |
msg = (
|
|
@@ -1662,9 +1774,19 @@ class Engagement(commands.Cog):
|
|
| 1662 |
)
|
| 1663 |
if not rows:
|
| 1664 |
if lang == "ar":
|
| 1665 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1666 |
else:
|
| 1667 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1668 |
return
|
| 1669 |
|
| 1670 |
lines = []
|
|
|
|
| 15 |
level_display, economy_embed, success_embed, error_embed, info_embed,
|
| 16 |
leaderboard_embed, profile_embed, gaming_embed, tournament_embed,
|
| 17 |
format_leaderboard, money_display, timestamp_display, double_line, triple_line,
|
| 18 |
+
pick_neon_color, shimmer, idle_embed_for_guild
|
| 19 |
)
|
| 20 |
from bot.emojis import (
|
| 21 |
ui, E_DIAMOND, E_STAR, E_CATJAM, E_ARROW_BLUE, E_ARROW_GREEN,
|
|
|
|
| 189 |
if not interaction.guild:
|
| 190 |
await interaction.response.send_message("Server only.", ephemeral=True)
|
| 191 |
return
|
| 192 |
+
gambling_cog = interaction.client.get_cog("Gambling")
|
| 193 |
+
if gambling_cog is None:
|
| 194 |
+
guild_id = interaction.guild.id
|
| 195 |
+
await interaction.response.send_modal(GambleModal(self.cog, guild_id, interaction.user.id))
|
| 196 |
+
return
|
| 197 |
+
from bot.cogs.gambling import GamblingPanelView
|
| 198 |
+
embed = discord.Embed(
|
| 199 |
+
title="Casino & Gambling Panel",
|
| 200 |
+
description=(
|
| 201 |
+
"Choose a game:\n\n"
|
| 202 |
+
"Blackjack\n"
|
| 203 |
+
"Roulette\n"
|
| 204 |
+
"RPG Adventure"
|
| 205 |
+
),
|
| 206 |
+
color=NEON_ORANGE,
|
| 207 |
+
)
|
| 208 |
+
await interaction.response.send_message(
|
| 209 |
+
embed=embed,
|
| 210 |
+
view=GamblingPanelView(gambling_cog, interaction.guild.id, interaction.user.id),
|
| 211 |
+
ephemeral=True,
|
| 212 |
+
)
|
| 213 |
|
| 214 |
@discord.ui.button(label="Rob", emoji=ui("gun"), style=discord.ButtonStyle.danger, custom_id="economy:rob", row=1)
|
| 215 |
async def rob_btn(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
|
|
|
|
| 243 |
guild_id,
|
| 244 |
)
|
| 245 |
if not rows:
|
| 246 |
+
await interaction.followup.send(
|
| 247 |
+
embed=await idle_embed_for_guild(
|
| 248 |
+
"Economy Leaderboard Idle",
|
| 249 |
+
"No economy data is available yet.",
|
| 250 |
+
guild=interaction.guild,
|
| 251 |
+
bot=self.cog.bot,
|
| 252 |
+
),
|
| 253 |
+
ephemeral=True,
|
| 254 |
+
)
|
| 255 |
return
|
| 256 |
lines = []
|
| 257 |
for rank, (uid, total) in enumerate(rows, 1):
|
|
|
|
| 488 |
|
| 489 |
class RobModal(discord.ui.Modal, title="🔫 Rob a User"):
|
| 490 |
target = discord.ui.TextInput(
|
| 491 |
+
label="User ID or Mention (optional)",
|
| 492 |
+
placeholder="Leave empty for random target",
|
| 493 |
+
required=False,
|
| 494 |
max_length=64,
|
| 495 |
)
|
| 496 |
|
|
|
|
| 502 |
|
| 503 |
async def on_submit(self, interaction: discord.Interaction) -> None:
|
| 504 |
await interaction.response.defer(ephemeral=True)
|
| 505 |
+
tid: int | None = None
|
| 506 |
val = self.target.value.strip().replace("<", "").replace(">", "").replace("@", "").replace("!", "")
|
| 507 |
+
if val:
|
| 508 |
+
try:
|
| 509 |
+
tid = int(val)
|
| 510 |
+
except ValueError:
|
| 511 |
+
await interaction.followup.send("Invalid user ID.", ephemeral=True)
|
| 512 |
+
return
|
| 513 |
+
elif interaction.guild:
|
| 514 |
+
eligible = [
|
| 515 |
+
m for m in interaction.guild.members
|
| 516 |
+
if (not m.bot) and m.id != self.user_id and m.guild_permissions.send_messages
|
| 517 |
+
]
|
| 518 |
+
if eligible:
|
| 519 |
+
tid = random.choice(eligible).id
|
| 520 |
+
if tid is None:
|
| 521 |
+
await interaction.followup.send("No valid robbery target found.", ephemeral=True)
|
| 522 |
return
|
| 523 |
if tid == self.user_id:
|
| 524 |
await interaction.followup.send("You can't rob yourself!", ephemeral=True)
|
|
|
|
| 760 |
)
|
| 761 |
if not row:
|
| 762 |
if lang == "ar":
|
| 763 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 764 |
+
"حالة الرتبة الخاملة",
|
| 765 |
+
"لا توجد بيانات مستوى بعد. ابدأ الدردشة لكسب XP!",
|
| 766 |
+
guild=ctx.guild,
|
| 767 |
+
bot=self.bot,
|
| 768 |
+
))
|
| 769 |
else:
|
| 770 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 771 |
+
"Rank Idle",
|
| 772 |
+
"No rank data yet. Start chatting to earn XP!",
|
| 773 |
+
guild=ctx.guild,
|
| 774 |
+
bot=self.bot,
|
| 775 |
+
))
|
| 776 |
return
|
| 777 |
|
| 778 |
xp, level = row
|
|
|
|
| 815 |
)
|
| 816 |
if not rows:
|
| 817 |
if lang == "ar":
|
| 818 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 819 |
+
"اللوحة الخاملة",
|
| 820 |
+
"لا توجد بيانات بعد. ابدأ الدردشة لكسب XP!",
|
| 821 |
+
guild=ctx.guild,
|
| 822 |
+
bot=self.bot,
|
| 823 |
+
))
|
| 824 |
else:
|
| 825 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 826 |
+
"Leaderboard Idle",
|
| 827 |
+
"No data yet. Start chatting to earn XP!",
|
| 828 |
+
guild=ctx.guild,
|
| 829 |
+
bot=self.bot,
|
| 830 |
+
))
|
| 831 |
return
|
| 832 |
|
| 833 |
lines = []
|
|
|
|
| 1028 |
await ctx.reply(embed=embed)
|
| 1029 |
|
| 1030 |
@commands.hybrid_command(name="rob", description="Attempt to rob another user (risky!)", hidden=True, with_app_command=False)
|
| 1031 |
+
async def rob(self, ctx: commands.Context, target: discord.Member | None = None) -> None:
|
| 1032 |
guild_id = ctx.guild.id
|
| 1033 |
lang = await self.bot.get_guild_language(guild_id)
|
| 1034 |
+
|
| 1035 |
+
if target is None:
|
| 1036 |
+
eligible = [
|
| 1037 |
+
member for member in ctx.guild.members
|
| 1038 |
+
if (not member.bot) and member.id != ctx.author.id and member.guild_permissions.send_messages
|
| 1039 |
+
]
|
| 1040 |
+
target = random.choice(eligible) if eligible else None
|
| 1041 |
+
if target is None:
|
| 1042 |
+
await ctx.reply("❌ No valid robbery targets found.")
|
| 1043 |
+
return
|
| 1044 |
+
|
| 1045 |
if target.id == ctx.author.id or target.bot:
|
| 1046 |
if lang == "ar":
|
| 1047 |
await ctx.reply("❌ هدف غير صالح.")
|
|
|
|
| 1151 |
await self.gamble(ctx, bet)
|
| 1152 |
|
| 1153 |
@economy.command(name="rob", description="Attempt to rob another user")
|
| 1154 |
+
async def economy_rob(self, ctx: commands.Context, target: discord.Member | None = None) -> None:
|
| 1155 |
await self.rob(ctx, target)
|
| 1156 |
|
| 1157 |
@commands.hybrid_command(name="profile")
|
|
|
|
| 1555 |
)
|
| 1556 |
if not row:
|
| 1557 |
if lang == "ar":
|
| 1558 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 1559 |
+
"البطولة غير متاحة",
|
| 1560 |
+
"لا توجد بطولة بهذا الاسم حالياً.",
|
| 1561 |
+
guild=ctx.guild,
|
| 1562 |
+
bot=self.bot,
|
| 1563 |
+
))
|
| 1564 |
else:
|
| 1565 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 1566 |
+
"Tournament Not Found",
|
| 1567 |
+
"No tournament exists with that name right now.",
|
| 1568 |
+
guild=ctx.guild,
|
| 1569 |
+
bot=self.bot,
|
| 1570 |
+
))
|
| 1571 |
return
|
| 1572 |
|
| 1573 |
_, status, winner_id = row
|
|
|
|
| 1621 |
)
|
| 1622 |
if not rows:
|
| 1623 |
if lang == "ar":
|
| 1624 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 1625 |
+
"قائمة الألعاب فارغة",
|
| 1626 |
+
"لم يتم إعداد ألعاب لهذه البطولة بعد.",
|
| 1627 |
+
guild=ctx.guild,
|
| 1628 |
+
bot=self.bot,
|
| 1629 |
+
))
|
| 1630 |
else:
|
| 1631 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 1632 |
+
"Tournament Games Idle",
|
| 1633 |
+
"No games are configured for this tournament yet.",
|
| 1634 |
+
guild=ctx.guild,
|
| 1635 |
+
bot=self.bot,
|
| 1636 |
+
))
|
| 1637 |
return
|
| 1638 |
|
| 1639 |
games = "🎮 " + " • ".join(game for (game,) in rows)
|
|
|
|
| 1668 |
)
|
| 1669 |
if not row:
|
| 1670 |
if lang == "ar":
|
| 1671 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 1672 |
+
"البطولة غير متاحة",
|
| 1673 |
+
"لا توجد بطولة بهذا الاسم حالياً.",
|
| 1674 |
+
guild=ctx.guild,
|
| 1675 |
+
bot=self.bot,
|
| 1676 |
+
))
|
| 1677 |
else:
|
| 1678 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 1679 |
+
"Tournament Not Found",
|
| 1680 |
+
"No tournament exists with that name right now.",
|
| 1681 |
+
guild=ctx.guild,
|
| 1682 |
+
bot=self.bot,
|
| 1683 |
+
))
|
| 1684 |
return
|
| 1685 |
|
| 1686 |
game_rows = await self.bot.db.fetchall(
|
|
|
|
| 1734 |
name,
|
| 1735 |
)
|
| 1736 |
if not row:
|
| 1737 |
+
if lang == "ar":
|
| 1738 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 1739 |
+
"البطولة غير متاحة",
|
| 1740 |
+
"لا توجد بطولة بهذا الاسم حالياً.",
|
| 1741 |
+
guild=ctx.guild,
|
| 1742 |
+
bot=self.bot,
|
| 1743 |
+
))
|
| 1744 |
+
else:
|
| 1745 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 1746 |
+
"Tournament Not Found",
|
| 1747 |
+
"No tournament exists with that name right now.",
|
| 1748 |
+
guild=ctx.guild,
|
| 1749 |
+
bot=self.bot,
|
| 1750 |
+
))
|
| 1751 |
return
|
| 1752 |
if lang == "ar":
|
| 1753 |
msg = (
|
|
|
|
| 1774 |
)
|
| 1775 |
if not rows:
|
| 1776 |
if lang == "ar":
|
| 1777 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 1778 |
+
"لوحة البطولات الخاملة",
|
| 1779 |
+
"لا يوجد فائزون مسجلون حتى الآن.",
|
| 1780 |
+
guild=ctx.guild,
|
| 1781 |
+
bot=self.bot,
|
| 1782 |
+
))
|
| 1783 |
else:
|
| 1784 |
+
await ctx.reply(embed=await idle_embed_for_guild(
|
| 1785 |
+
"Tournament Leaderboard Idle",
|
| 1786 |
+
"No tournament winners are recorded yet.",
|
| 1787 |
+
guild=ctx.guild,
|
| 1788 |
+
bot=self.bot,
|
| 1789 |
+
))
|
| 1790 |
return
|
| 1791 |
|
| 1792 |
lines = []
|
bot/cogs/events.py
CHANGED
|
@@ -4,6 +4,7 @@ import asyncio
|
|
| 4 |
import base64
|
| 5 |
import datetime as dt
|
| 6 |
import hashlib
|
|
|
|
| 7 |
import json
|
| 8 |
import os
|
| 9 |
import re
|
|
@@ -28,15 +29,23 @@ try:
|
|
| 28 |
except Exception: # pragma: no cover
|
| 29 |
genai = None
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
BAD_WORDS = {"badword1", "badword2", "badword3"}
|
| 32 |
SCAM_LINK_RE = re.compile(r"(https?://\S+|discord\.gg/\S+|t\.me/\S+)", re.IGNORECASE)
|
| 33 |
SCAM_SYSTEM_PROMPT = """You are a strict Discord anti-scam classifier specialized in detecting:
|
| 34 |
1. Low-quality scam images (blurry, pixelated, poor compression)
|
| 35 |
2. Fake giveaways and fraudulent offers
|
| 36 |
-
3.
|
| 37 |
-
4.
|
|
|
|
| 38 |
|
| 39 |
-
Analyze
|
| 40 |
Reply ONLY TRUE (if scam) or FALSE (if safe)."""
|
| 41 |
|
| 42 |
SCAM_CALL_TO_ACTION_RE = re.compile(
|
|
@@ -45,7 +54,7 @@ SCAM_CALL_TO_ACTION_RE = re.compile(
|
|
| 45 |
re.IGNORECASE,
|
| 46 |
)
|
| 47 |
SCAM_KEYWORDS_RE = re.compile(
|
| 48 |
-
r"\b(free money|free nitro|crypto|airdrop|giveaway|gift card|usdt|btc|eth|investment|"
|
| 49 |
r"ربح مجاني|نيترو مجاني|كريبتو|هدية|هدايا|قسيمة|استثمار|محفظة)\b",
|
| 50 |
re.IGNORECASE,
|
| 51 |
)
|
|
@@ -64,6 +73,21 @@ BENIGN_DM_RE = re.compile(
|
|
| 64 |
r"راسلني إذا|راسلني للمساعدة|خاص للمساعدة)\b",
|
| 65 |
re.IGNORECASE,
|
| 66 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
|
|
@@ -72,6 +96,8 @@ class Events(commands.Cog):
|
|
| 72 |
self.bot = bot
|
| 73 |
self._scam_scan_semaphore = asyncio.Semaphore(4)
|
| 74 |
self._recent_user_messages: dict[tuple[int, int], deque[tuple[float, str]]] = defaultdict(lambda: deque(maxlen=8))
|
|
|
|
|
|
|
| 75 |
self._gemini_key = (os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "").strip()
|
| 76 |
if self._gemini_key and genai is not None:
|
| 77 |
try:
|
|
@@ -197,6 +223,149 @@ class Events(commands.Cog):
|
|
| 197 |
reasons.append("benign dm-context detected")
|
| 198 |
return (score, reasons)
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
async def _log_image_scan(self, message: discord.Message, result: str, detail: str) -> None:
|
| 201 |
try:
|
| 202 |
await self.send_log(
|
|
@@ -208,17 +377,30 @@ class Events(commands.Cog):
|
|
| 208 |
except Exception:
|
| 209 |
return
|
| 210 |
|
| 211 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
if aiohttp is None:
|
| 213 |
return []
|
| 214 |
urls = re.findall(r"https?://\S+", content or "")
|
| 215 |
image_urls = [u.rstrip(").,!?") for u in urls if re.search(r"\.(png|jpg|jpeg|webp|gif)(\?|$)", u, re.IGNORECASE)]
|
| 216 |
if not image_urls:
|
| 217 |
return []
|
| 218 |
-
out: list[bytes] = []
|
| 219 |
timeout = aiohttp.ClientTimeout(total=8)
|
| 220 |
async with aiohttp.ClientSession(timeout=timeout) as session:
|
| 221 |
-
for url in image_urls[:
|
| 222 |
try:
|
| 223 |
async with session.get(url) as resp:
|
| 224 |
if resp.status >= 400:
|
|
@@ -227,12 +409,169 @@ class Events(commands.Cog):
|
|
| 227 |
if "image/" not in ctype:
|
| 228 |
continue
|
| 229 |
data = await resp.read()
|
| 230 |
-
if data:
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
| 232 |
except Exception:
|
| 233 |
continue
|
| 234 |
return out
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
async def _analyze_scam_with_openrouter(self, message: discord.Message) -> bool:
|
| 237 |
if aiohttp is None or not self.bot.settings.openrouter_api_key:
|
| 238 |
return False
|
|
@@ -242,7 +581,18 @@ class Events(commands.Cog):
|
|
| 242 |
"meta-llama/llama-3.2-11b-vision-instruct:free",
|
| 243 |
"nvidia/llama-3.1-nemotron-nano-vl-8b-v1:free",
|
| 244 |
]
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
base_text = (
|
| 247 |
"You are an advanced scam detector for Discord specialized in identifying:\n"
|
| 248 |
"1. LOW-QUALITY IMAGES: Blurry, pixelated, heavily compressed, or distorted images are HIGH RISK\n"
|
|
@@ -251,7 +601,9 @@ class Events(commands.Cog):
|
|
| 251 |
"4. SOCIAL ENGINEERING: Impersonation, urgency tactics, pressure to act quickly\n\n"
|
| 252 |
"IMPORTANT: Low image quality + suspicious content = ALMOST ALWAYS SCAM\n\n"
|
| 253 |
f"Heuristic analysis: score={score}/10, reasons: {', '.join(reasons) if reasons else 'none'}.\n\n"
|
| 254 |
-
f"Message content:\n{(message.content or '')[:2000]}"
|
|
|
|
|
|
|
| 255 |
)
|
| 256 |
content_parts: list[dict] = [{"type": "text", "text": base_text}]
|
| 257 |
image_count = 0
|
|
@@ -265,6 +617,14 @@ class Events(commands.Cog):
|
|
| 265 |
image_count += 1
|
| 266 |
except Exception:
|
| 267 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
if image_count == 0 and not message.content.strip():
|
| 269 |
return False
|
| 270 |
|
|
@@ -302,11 +662,23 @@ class Events(commands.Cog):
|
|
| 302 |
return False
|
| 303 |
try:
|
| 304 |
model = genai.GenerativeModel("gemini-1.5-flash")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
prompt = (
|
| 306 |
"Classify if this Discord message is a scam/phishing attempt.\n"
|
| 307 |
"Use context. Do NOT flag harmless 'dm me' alone.\n"
|
| 308 |
"Reply with TRUE or FALSE only.\n\n"
|
| 309 |
-
f"Message: {(message.content or '')[:2000]}"
|
|
|
|
|
|
|
| 310 |
)
|
| 311 |
parts: list = [prompt]
|
| 312 |
for att in message.attachments[:3]:
|
|
@@ -790,6 +1162,7 @@ class Events(commands.Cog):
|
|
| 790 |
if message.author.bot or not message.guild:
|
| 791 |
return
|
| 792 |
text_content = message.content or ""
|
|
|
|
| 793 |
await self.send_log(
|
| 794 |
message.guild,
|
| 795 |
"💬 Message Sent",
|
|
@@ -801,6 +1174,23 @@ class Events(commands.Cog):
|
|
| 801 |
bucket = self._recent_user_messages[key]
|
| 802 |
bucket.append((now_ts, text_content.strip()))
|
| 803 |
stitched = " ".join(chunk for ts, chunk in bucket if now_ts - ts <= 90 and chunk)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 804 |
|
| 805 |
if stitched and self._is_high_risk_scam_text(stitched):
|
| 806 |
violation = await self._openrouter_shield_violation(stitched)
|
|
@@ -811,6 +1201,8 @@ class Events(commands.Cog):
|
|
| 811 |
if message.attachments:
|
| 812 |
has_image = any((att.content_type or "").startswith("image/") for att in message.attachments)
|
| 813 |
if has_image:
|
|
|
|
|
|
|
| 814 |
for att in message.attachments:
|
| 815 |
if not (att.content_type or "").startswith("image/"):
|
| 816 |
continue
|
|
@@ -819,6 +1211,8 @@ class Events(commands.Cog):
|
|
| 819 |
except Exception:
|
| 820 |
continue
|
| 821 |
digest = hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
|
| 822 |
row = await self.bot.db.fetchone(
|
| 823 |
"SELECT image_hash FROM scam_images WHERE guild_id = ? AND image_hash = ?",
|
| 824 |
message.guild.id,
|
|
@@ -829,24 +1223,90 @@ class Events(commands.Cog):
|
|
| 829 |
deleted = await message.channel.purge(limit=15, check=lambda m: m.author.id == message.author.id and m.attachments)
|
| 830 |
await self.send_log(message.guild, "🧹 Scam Image Purge", f"Deleted {len(deleted)} image messages from {message.author.mention}")
|
| 831 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
is_image_scam = await self._analyze_scam_with_openrouter(message)
|
| 833 |
if not is_image_scam:
|
| 834 |
is_image_scam = await self._analyze_scam_with_gemini(message)
|
| 835 |
-
|
| 836 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 837 |
await self._log_image_scan(message, "SCAM" if is_image_scam else "SAFE", detail)
|
| 838 |
if is_image_scam:
|
|
|
|
|
|
|
| 839 |
await self._apply_ai_shield(message, "SCAM")
|
| 840 |
return
|
| 841 |
elif re.search(r"https?://\S+\.(png|jpg|jpeg|webp|gif)(\?|$)", message.content or "", re.IGNORECASE):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 842 |
proxy_message = message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 843 |
is_image_scam = await self._analyze_scam_with_openrouter(proxy_message)
|
| 844 |
if not is_image_scam:
|
| 845 |
is_image_scam = await self._analyze_scam_with_gemini(proxy_message)
|
| 846 |
-
|
| 847 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 848 |
await self._log_image_scan(message, "SCAM" if is_image_scam else "SAFE", detail)
|
| 849 |
if is_image_scam:
|
|
|
|
|
|
|
| 850 |
await self._apply_ai_shield(message, "SCAM")
|
| 851 |
return
|
| 852 |
if self._is_high_risk_scam_text(text_content):
|
|
@@ -919,10 +1379,26 @@ class Events(commands.Cog):
|
|
| 919 |
elif mention_type == "role" and role_id:
|
| 920 |
mention = f"<@&{role_id}> "
|
| 921 |
for item in fresh[:3]:
|
| 922 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 923 |
if item.get("image"):
|
| 924 |
embed.set_image(url=item["image"])
|
| 925 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 926 |
await channel.send(content=mention or None, embed=embed, view=view)
|
| 927 |
latest_ids = ",".join(item["id"] for item in items[:20])
|
| 928 |
await self.bot.db.execute(
|
|
|
|
| 4 |
import base64
|
| 5 |
import datetime as dt
|
| 6 |
import hashlib
|
| 7 |
+
import io
|
| 8 |
import json
|
| 9 |
import os
|
| 10 |
import re
|
|
|
|
| 29 |
except Exception: # pragma: no cover
|
| 30 |
genai = None
|
| 31 |
|
| 32 |
+
try:
|
| 33 |
+
from PIL import Image, ImageFilter, ImageStat
|
| 34 |
+
except Exception: # pragma: no cover
|
| 35 |
+
Image = None
|
| 36 |
+
ImageFilter = None
|
| 37 |
+
ImageStat = None
|
| 38 |
+
|
| 39 |
BAD_WORDS = {"badword1", "badword2", "badword3"}
|
| 40 |
SCAM_LINK_RE = re.compile(r"(https?://\S+|discord\.gg/\S+|t\.me/\S+)", re.IGNORECASE)
|
| 41 |
SCAM_SYSTEM_PROMPT = """You are a strict Discord anti-scam classifier specialized in detecting:
|
| 42 |
1. Low-quality scam images (blurry, pixelated, poor compression)
|
| 43 |
2. Fake giveaways and fraudulent offers
|
| 44 |
+
3. Celebrity/creator impersonation scams (example: MrBeast giveaway, fake verified pages)
|
| 45 |
+
4. Phishing attempts and social engineering
|
| 46 |
+
5. Misleading/deceptive content (wallet verification, Telegram/WhatsApp contact, urgent claim flow)
|
| 47 |
|
| 48 |
+
Analyze image text, logos, badges, links/handles, and context carefully.
|
| 49 |
Reply ONLY TRUE (if scam) or FALSE (if safe)."""
|
| 50 |
|
| 51 |
SCAM_CALL_TO_ACTION_RE = re.compile(
|
|
|
|
| 54 |
re.IGNORECASE,
|
| 55 |
)
|
| 56 |
SCAM_KEYWORDS_RE = re.compile(
|
| 57 |
+
r"\b(free money|free nitro|crypto|airdrop|giveaway|gift card|usdt|btc|eth|investment|mrbeast|mr beast|"
|
| 58 |
r"ربح مجاني|نيترو مجاني|كريبتو|هدية|هدايا|قسيمة|استثمار|محفظة)\b",
|
| 59 |
re.IGNORECASE,
|
| 60 |
)
|
|
|
|
| 73 |
r"راسلني إذا|راسلني للمساعدة|خاص للمساعدة)\b",
|
| 74 |
re.IGNORECASE,
|
| 75 |
)
|
| 76 |
+
CUSTOM_EMOJI_RE = re.compile(r"<a?:[A-Za-z0-9_~\-]{2,32}:\d{6,}>")
|
| 77 |
+
UNICODE_EMOJI_RE = re.compile(
|
| 78 |
+
r"[\U0001F1E6-\U0001F1FF]" # flags
|
| 79 |
+
r"|[\U0001F300-\U0001FAFF]" # symbols & pictographs
|
| 80 |
+
r"|[\u2600-\u27BF]" # dingbats/misc
|
| 81 |
+
)
|
| 82 |
+
SCAM_LURE_EMOJIS = {
|
| 83 |
+
"🎁", "💰", "💸", "🏆", "🚀", "🔥", "✅", "💎", "🔗", "🎉", "🪙", "📈", "💳", "💵", "🤑"
|
| 84 |
+
}
|
| 85 |
+
SCAM_PRESSURE_EMOJIS = {"⚠️", "⏰", "🚨", "❗", "‼️", "⌛"}
|
| 86 |
+
OCR_SCAM_RE = re.compile(
|
| 87 |
+
r"\b(mrbeast|mr beast|giveaway|airdrop|free|claim|winner|verify wallet|wallet|crypto|usdt|btc|eth|"
|
| 88 |
+
r"telegram|whatsapp|dm me|join now|limited time|urgent|bit\.ly|tinyurl)\b",
|
| 89 |
+
re.IGNORECASE,
|
| 90 |
+
)
|
| 91 |
|
| 92 |
|
| 93 |
|
|
|
|
| 96 |
self.bot = bot
|
| 97 |
self._scam_scan_semaphore = asyncio.Semaphore(4)
|
| 98 |
self._recent_user_messages: dict[tuple[int, int], deque[tuple[float, str]]] = defaultdict(lambda: deque(maxlen=8))
|
| 99 |
+
self._recent_user_context: dict[tuple[int, int], deque[tuple[float, int, str, list[str]]]] = defaultdict(lambda: deque(maxlen=40))
|
| 100 |
+
self._last_context_ai_check: dict[tuple[int, int], float] = {}
|
| 101 |
self._gemini_key = (os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "").strip()
|
| 102 |
if self._gemini_key and genai is not None:
|
| 103 |
try:
|
|
|
|
| 223 |
reasons.append("benign dm-context detected")
|
| 224 |
return (score, reasons)
|
| 225 |
|
| 226 |
+
@staticmethod
|
| 227 |
+
def _extract_emojis(content: str) -> list[str]:
|
| 228 |
+
text = (content or "").strip()
|
| 229 |
+
if not text:
|
| 230 |
+
return []
|
| 231 |
+
custom = CUSTOM_EMOJI_RE.findall(text)
|
| 232 |
+
# Keep unicode emoji extraction approximate but fast.
|
| 233 |
+
unicode_hits = UNICODE_EMOJI_RE.findall(text)
|
| 234 |
+
return custom + unicode_hits
|
| 235 |
+
|
| 236 |
+
def _emoji_scam_heuristics(self, content: str, emojis: list[str]) -> tuple[int, list[str]]:
|
| 237 |
+
if not emojis:
|
| 238 |
+
return (0, [])
|
| 239 |
+
text = (content or "").strip().lower()
|
| 240 |
+
score = 0
|
| 241 |
+
reasons: list[str] = []
|
| 242 |
+
|
| 243 |
+
lure_count = sum(1 for e in emojis if e in SCAM_LURE_EMOJIS)
|
| 244 |
+
pressure_count = sum(1 for e in emojis if e in SCAM_PRESSURE_EMOJIS)
|
| 245 |
+
has_link = bool(SCAM_LINK_RE.search(text))
|
| 246 |
+
has_cta = bool(SCAM_CALL_TO_ACTION_RE.search(text))
|
| 247 |
+
has_keyword = bool(SCAM_KEYWORDS_RE.search(text))
|
| 248 |
+
|
| 249 |
+
if lure_count >= 2:
|
| 250 |
+
score += 2
|
| 251 |
+
reasons.append("multiple lure emojis")
|
| 252 |
+
if pressure_count >= 1:
|
| 253 |
+
score += 1
|
| 254 |
+
reasons.append("urgency/pressure emojis")
|
| 255 |
+
if len(emojis) >= 6:
|
| 256 |
+
score += 1
|
| 257 |
+
reasons.append("emoji-heavy promotional style")
|
| 258 |
+
if has_link and (lure_count >= 1 or pressure_count >= 1):
|
| 259 |
+
score += 2
|
| 260 |
+
reasons.append("link + lure/pressure emojis")
|
| 261 |
+
if has_cta and (lure_count >= 1 or has_keyword):
|
| 262 |
+
score += 2
|
| 263 |
+
reasons.append("cta + emoji bait")
|
| 264 |
+
if has_keyword and lure_count >= 1:
|
| 265 |
+
score += 2
|
| 266 |
+
reasons.append("financial/scam keywords + emoji bait")
|
| 267 |
+
return (score, reasons)
|
| 268 |
+
|
| 269 |
+
async def _shield_level(self, guild_id: int) -> str:
|
| 270 |
+
row = await self.bot.db.fetchone("SELECT level FROM shield_settings WHERE guild_id = ?", guild_id)
|
| 271 |
+
level = str(row[0]).strip().lower() if row and row[0] else "medium"
|
| 272 |
+
if level not in {"low", "medium", "high"}:
|
| 273 |
+
return "medium"
|
| 274 |
+
return level
|
| 275 |
+
|
| 276 |
+
@staticmethod
|
| 277 |
+
def _context_window_seconds(level: str) -> int:
|
| 278 |
+
if level == "high":
|
| 279 |
+
return 6 * 60 * 60
|
| 280 |
+
if level == "low":
|
| 281 |
+
return 90 * 60
|
| 282 |
+
return 3 * 60 * 60
|
| 283 |
+
|
| 284 |
+
@staticmethod
|
| 285 |
+
def _context_check_cooldown_seconds(level: str) -> int:
|
| 286 |
+
if level == "high":
|
| 287 |
+
return 8
|
| 288 |
+
if level == "low":
|
| 289 |
+
return 25
|
| 290 |
+
return 15
|
| 291 |
+
|
| 292 |
+
def _build_recent_context(self, guild_id: int, user_id: int, *, now_ts: float, horizon_seconds: int) -> list[tuple[float, int, str, list[str]]]:
|
| 293 |
+
key = (guild_id, user_id)
|
| 294 |
+
bucket = self._recent_user_context[key]
|
| 295 |
+
return [entry for entry in bucket if (now_ts - entry[0]) <= horizon_seconds]
|
| 296 |
+
|
| 297 |
+
async def _openrouter_contextual_scam_verdict(
|
| 298 |
+
self,
|
| 299 |
+
message: discord.Message,
|
| 300 |
+
*,
|
| 301 |
+
emojis: list[str],
|
| 302 |
+
stitched_text: str,
|
| 303 |
+
emoji_score: int,
|
| 304 |
+
emoji_reasons: list[str],
|
| 305 |
+
) -> bool:
|
| 306 |
+
api_key = (self.bot.settings.openrouter_api_key or "").strip()
|
| 307 |
+
if not api_key or aiohttp is None:
|
| 308 |
+
return False
|
| 309 |
+
|
| 310 |
+
level = await self._shield_level(message.guild.id)
|
| 311 |
+
now_ts = time.time()
|
| 312 |
+
key = (message.guild.id, message.author.id)
|
| 313 |
+
cooldown = self._context_check_cooldown_seconds(level)
|
| 314 |
+
if (now_ts - self._last_context_ai_check.get(key, 0.0)) < cooldown:
|
| 315 |
+
return False
|
| 316 |
+
self._last_context_ai_check[key] = now_ts
|
| 317 |
+
|
| 318 |
+
horizon = self._context_window_seconds(level)
|
| 319 |
+
context_rows = self._build_recent_context(message.guild.id, message.author.id, now_ts=now_ts, horizon_seconds=horizon)
|
| 320 |
+
context_rows = context_rows[-20:] # keep prompt bounded
|
| 321 |
+
|
| 322 |
+
context_lines: list[str] = []
|
| 323 |
+
for ts, channel_id, text, row_emojis in context_rows:
|
| 324 |
+
delta_min = int(max(0, now_ts - ts) // 60)
|
| 325 |
+
compact_text = (text or "").replace("\n", " ").strip()[:220]
|
| 326 |
+
emoji_str = " ".join(row_emojis[:10]) if row_emojis else "-"
|
| 327 |
+
context_lines.append(f"[{delta_min}m ago][#{channel_id}] text={compact_text} | emojis={emoji_str}")
|
| 328 |
+
|
| 329 |
+
prompt = (
|
| 330 |
+
"You are a Discord anti-scam contextual classifier.\n"
|
| 331 |
+
"Goal: detect hacked-account scam campaigns and phishing even when messages are separated over time.\n"
|
| 332 |
+
"Treat repeated lure emojis + links/CTA + fake rewards/wallet verification as high risk.\n"
|
| 333 |
+
"Do not flag harmless emoji-only chatting.\n"
|
| 334 |
+
"Reply with one word only: SCAM or SAFE.\n\n"
|
| 335 |
+
f"Guild shield level: {level}\n"
|
| 336 |
+
f"Current message text: {(message.content or '').strip()[:1200]}\n"
|
| 337 |
+
f"Current message emojis: {' '.join(emojis[:30]) if emojis else '-'}\n"
|
| 338 |
+
f"Emoji heuristic score: {emoji_score} ({', '.join(emoji_reasons) if emoji_reasons else 'none'})\n"
|
| 339 |
+
f"Recent stitched text: {stitched_text[:1200] if stitched_text else '-'}\n\n"
|
| 340 |
+
"Recent same-user context (can be different channels/time):\n"
|
| 341 |
+
+ ("\n".join(context_lines) if context_lines else "-")
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
payload = {
|
| 345 |
+
"model": (self.bot.settings.openrouter_model or "").strip() or "meta-llama/llama-3.3-70b-instruct:free",
|
| 346 |
+
"messages": [
|
| 347 |
+
{"role": "system", "content": "Classify as SCAM or SAFE only."},
|
| 348 |
+
{"role": "user", "content": prompt},
|
| 349 |
+
],
|
| 350 |
+
"temperature": 0,
|
| 351 |
+
}
|
| 352 |
+
headers = {
|
| 353 |
+
"Authorization": f"Bearer {api_key}",
|
| 354 |
+
"Content-Type": "application/json",
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
try:
|
| 358 |
+
async with self._scam_scan_semaphore:
|
| 359 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=18)) as session:
|
| 360 |
+
async with session.post("https://openrouter.ai/api/v1/chat/completions", json=payload, headers=headers) as resp:
|
| 361 |
+
if resp.status >= 400:
|
| 362 |
+
return False
|
| 363 |
+
data = await resp.json(content_type=None)
|
| 364 |
+
verdict = str((((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "")).strip().upper()
|
| 365 |
+
return "SCAM" in verdict and "SAFE" not in verdict
|
| 366 |
+
except Exception:
|
| 367 |
+
return False
|
| 368 |
+
|
| 369 |
async def _log_image_scan(self, message: discord.Message, result: str, detail: str) -> None:
|
| 370 |
try:
|
| 371 |
await self.send_log(
|
|
|
|
| 377 |
except Exception:
|
| 378 |
return
|
| 379 |
|
| 380 |
+
async def _store_scam_hash(self, guild_id: int, digest: str, *, created_by: int = 0) -> None:
|
| 381 |
+
if not digest:
|
| 382 |
+
return
|
| 383 |
+
try:
|
| 384 |
+
await self.bot.db.execute(
|
| 385 |
+
"INSERT OR IGNORE INTO scam_images(guild_id, image_hash, created_by) VALUES (?, ?, ?)",
|
| 386 |
+
guild_id,
|
| 387 |
+
digest,
|
| 388 |
+
created_by,
|
| 389 |
+
)
|
| 390 |
+
except Exception:
|
| 391 |
+
return
|
| 392 |
+
|
| 393 |
+
async def _image_items_from_text_urls(self, content: str) -> list[tuple[str, bytes, str]]:
|
| 394 |
if aiohttp is None:
|
| 395 |
return []
|
| 396 |
urls = re.findall(r"https?://\S+", content or "")
|
| 397 |
image_urls = [u.rstrip(").,!?") for u in urls if re.search(r"\.(png|jpg|jpeg|webp|gif)(\?|$)", u, re.IGNORECASE)]
|
| 398 |
if not image_urls:
|
| 399 |
return []
|
| 400 |
+
out: list[tuple[str, bytes, str]] = []
|
| 401 |
timeout = aiohttp.ClientTimeout(total=8)
|
| 402 |
async with aiohttp.ClientSession(timeout=timeout) as session:
|
| 403 |
+
for url in image_urls[:3]:
|
| 404 |
try:
|
| 405 |
async with session.get(url) as resp:
|
| 406 |
if resp.status >= 400:
|
|
|
|
| 409 |
if "image/" not in ctype:
|
| 410 |
continue
|
| 411 |
data = await resp.read()
|
| 412 |
+
if not data:
|
| 413 |
+
continue
|
| 414 |
+
raw = data[:2_000_000]
|
| 415 |
+
digest = hashlib.sha256(raw).hexdigest()
|
| 416 |
+
out.append((url, raw, digest))
|
| 417 |
except Exception:
|
| 418 |
continue
|
| 419 |
return out
|
| 420 |
|
| 421 |
+
async def _image_bytes_from_text_urls(self, content: str) -> list[bytes]:
|
| 422 |
+
return [raw for _, raw, _ in await self._image_items_from_text_urls(content)]
|
| 423 |
+
|
| 424 |
+
@staticmethod
|
| 425 |
+
def _image_quality_heuristics(images: list[bytes]) -> tuple[int, list[str], dict[str, float | int]]:
|
| 426 |
+
if not images:
|
| 427 |
+
return (0, [], {})
|
| 428 |
+
if Image is None or ImageFilter is None or ImageStat is None:
|
| 429 |
+
return (0, [], {})
|
| 430 |
+
|
| 431 |
+
score = 0
|
| 432 |
+
reasons: list[str] = []
|
| 433 |
+
total_pixels = 0
|
| 434 |
+
total_bytes = 0
|
| 435 |
+
min_dim = None
|
| 436 |
+
edge_mean_avg = 0.0
|
| 437 |
+
analyzed = 0
|
| 438 |
+
|
| 439 |
+
for raw in images[:4]:
|
| 440 |
+
try:
|
| 441 |
+
with Image.open(io.BytesIO(raw)) as img:
|
| 442 |
+
img.load()
|
| 443 |
+
w, h = img.size
|
| 444 |
+
if w <= 0 or h <= 0:
|
| 445 |
+
continue
|
| 446 |
+
analyzed += 1
|
| 447 |
+
total_pixels += (w * h)
|
| 448 |
+
total_bytes += len(raw)
|
| 449 |
+
md = min(w, h)
|
| 450 |
+
min_dim = md if min_dim is None else min(min_dim, md)
|
| 451 |
+
|
| 452 |
+
gray = img.convert("L")
|
| 453 |
+
edges = gray.filter(ImageFilter.FIND_EDGES)
|
| 454 |
+
st = ImageStat.Stat(edges)
|
| 455 |
+
edge_mean = float(st.mean[0]) if st.mean else 0.0
|
| 456 |
+
edge_mean_avg += edge_mean
|
| 457 |
+
except Exception:
|
| 458 |
+
continue
|
| 459 |
+
|
| 460 |
+
if analyzed == 0 or total_pixels <= 0:
|
| 461 |
+
return (0, [], {})
|
| 462 |
+
|
| 463 |
+
mp = total_pixels / 1_000_000.0
|
| 464 |
+
bpp = total_bytes / float(total_pixels)
|
| 465 |
+
edge_mean_avg = edge_mean_avg / analyzed if analyzed else 0.0
|
| 466 |
+
|
| 467 |
+
if mp < 0.45 or (min_dim is not None and min_dim < 520):
|
| 468 |
+
score += 2
|
| 469 |
+
reasons.append("low resolution image quality")
|
| 470 |
+
if edge_mean_avg < 11.0:
|
| 471 |
+
score += 2
|
| 472 |
+
reasons.append("blurry/soft edges (likely low quality)")
|
| 473 |
+
if bpp < 0.16:
|
| 474 |
+
score += 1
|
| 475 |
+
reasons.append("high compression artifacts likely")
|
| 476 |
+
|
| 477 |
+
metrics: dict[str, float | int] = {
|
| 478 |
+
"analyzed_images": analyzed,
|
| 479 |
+
"megapixels": round(mp, 3),
|
| 480 |
+
"bytes_per_pixel": round(bpp, 4),
|
| 481 |
+
"edge_mean": round(edge_mean_avg, 3),
|
| 482 |
+
"min_dimension": int(min_dim or 0),
|
| 483 |
+
}
|
| 484 |
+
return (score, reasons, metrics)
|
| 485 |
+
|
| 486 |
+
@staticmethod
|
| 487 |
+
def _ocr_scam_heuristics(ocr_text: str) -> tuple[int, list[str]]:
|
| 488 |
+
text = (ocr_text or "").strip().lower()
|
| 489 |
+
if not text:
|
| 490 |
+
return (0, [])
|
| 491 |
+
reasons: list[str] = []
|
| 492 |
+
score = 0
|
| 493 |
+
|
| 494 |
+
hits = OCR_SCAM_RE.findall(text)
|
| 495 |
+
hit_count = len(hits)
|
| 496 |
+
if hit_count >= 2:
|
| 497 |
+
score += 2
|
| 498 |
+
reasons.append("ocr contains multiple scam terms")
|
| 499 |
+
if ("verify" in text and "wallet" in text) or "verify wallet" in text:
|
| 500 |
+
score += 2
|
| 501 |
+
reasons.append("ocr indicates wallet verification flow")
|
| 502 |
+
if ("mrbeast" in text or "mr beast" in text) and ("giveaway" in text or "free" in text or "winner" in text):
|
| 503 |
+
score += 2
|
| 504 |
+
reasons.append("ocr indicates creator impersonation giveaway")
|
| 505 |
+
if ("bit.ly" in text or "tinyurl" in text) and ("claim" in text or "winner" in text or "free" in text):
|
| 506 |
+
score += 1
|
| 507 |
+
reasons.append("short-link + prize phrasing in ocr")
|
| 508 |
+
|
| 509 |
+
return (score, reasons)
|
| 510 |
+
|
| 511 |
+
async def _openrouter_extract_image_text(self, message: discord.Message, *, preloaded_images: list[bytes] | None = None) -> str:
|
| 512 |
+
api_key = (self.bot.settings.openrouter_api_key or "").strip()
|
| 513 |
+
if not api_key or aiohttp is None:
|
| 514 |
+
return ""
|
| 515 |
+
|
| 516 |
+
images: list[bytes] = []
|
| 517 |
+
if preloaded_images:
|
| 518 |
+
images.extend(preloaded_images[:3])
|
| 519 |
+
else:
|
| 520 |
+
for att in message.attachments[:3]:
|
| 521 |
+
if not ((att.content_type or "").startswith("image/")):
|
| 522 |
+
continue
|
| 523 |
+
try:
|
| 524 |
+
raw = await att.read(use_cached=True)
|
| 525 |
+
if raw:
|
| 526 |
+
images.append(raw[:2_000_000])
|
| 527 |
+
except Exception:
|
| 528 |
+
continue
|
| 529 |
+
if not images:
|
| 530 |
+
images.extend((await self._image_bytes_from_text_urls(message.content or ""))[:3])
|
| 531 |
+
|
| 532 |
+
if not images:
|
| 533 |
+
return ""
|
| 534 |
+
|
| 535 |
+
content_parts: list[dict] = [
|
| 536 |
+
{
|
| 537 |
+
"type": "text",
|
| 538 |
+
"text": (
|
| 539 |
+
"Extract all visible text from these images (OCR). "
|
| 540 |
+
"Return plain text only. Keep URLs, @handles, numbers, and symbols exactly if visible."
|
| 541 |
+
),
|
| 542 |
+
}
|
| 543 |
+
]
|
| 544 |
+
for raw in images[:3]:
|
| 545 |
+
try:
|
| 546 |
+
data_url = "data:image/png;base64," + base64.b64encode(raw[:2_000_000]).decode("utf-8")
|
| 547 |
+
content_parts.append({"type": "image_url", "image_url": {"url": data_url}})
|
| 548 |
+
except Exception:
|
| 549 |
+
continue
|
| 550 |
+
|
| 551 |
+
payload = {
|
| 552 |
+
"model": "meta-llama/llama-3.2-11b-vision-instruct:free",
|
| 553 |
+
"messages": [
|
| 554 |
+
{"role": "system", "content": "You are OCR. Return extracted text only."},
|
| 555 |
+
{"role": "user", "content": content_parts},
|
| 556 |
+
],
|
| 557 |
+
"temperature": 0,
|
| 558 |
+
}
|
| 559 |
+
headers = {
|
| 560 |
+
"Authorization": f"Bearer {api_key}",
|
| 561 |
+
"Content-Type": "application/json",
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
try:
|
| 565 |
+
async with self._scam_scan_semaphore:
|
| 566 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=20)) as session:
|
| 567 |
+
async with session.post("https://openrouter.ai/api/v1/chat/completions", json=payload, headers=headers) as resp:
|
| 568 |
+
if resp.status >= 400:
|
| 569 |
+
return ""
|
| 570 |
+
data = await resp.json(content_type=None)
|
| 571 |
+
return str((((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "")).strip()
|
| 572 |
+
except Exception:
|
| 573 |
+
return ""
|
| 574 |
+
|
| 575 |
async def _analyze_scam_with_openrouter(self, message: discord.Message) -> bool:
|
| 576 |
if aiohttp is None or not self.bot.settings.openrouter_api_key:
|
| 577 |
return False
|
|
|
|
| 581 |
"meta-llama/llama-3.2-11b-vision-instruct:free",
|
| 582 |
"nvidia/llama-3.1-nemotron-nano-vl-8b-v1:free",
|
| 583 |
]
|
| 584 |
+
has_url_images = bool(re.search(r"https?://\S+\.(png|jpg|jpeg|webp|gif)(\?|$)", message.content or "", re.IGNORECASE))
|
| 585 |
+
score, reasons = self._scam_heuristics(message.content or "", has_images=bool(message.attachments) or has_url_images)
|
| 586 |
+
level = await self._shield_level(message.guild.id)
|
| 587 |
+
now_ts = time.time()
|
| 588 |
+
horizon = self._context_window_seconds(level)
|
| 589 |
+
recent_rows = self._build_recent_context(message.guild.id, message.author.id, now_ts=now_ts, horizon_seconds=horizon)
|
| 590 |
+
recent_lines: list[str] = []
|
| 591 |
+
for ts, channel_id, text, row_emojis in recent_rows[-12:]:
|
| 592 |
+
delta_min = int(max(0, now_ts - ts) // 60)
|
| 593 |
+
compact_text = (text or "").replace("\n", " ").strip()[:180]
|
| 594 |
+
emoji_str = " ".join(row_emojis[:8]) if row_emojis else "-"
|
| 595 |
+
recent_lines.append(f"[{delta_min}m][#{channel_id}] {compact_text} | emojis={emoji_str}")
|
| 596 |
base_text = (
|
| 597 |
"You are an advanced scam detector for Discord specialized in identifying:\n"
|
| 598 |
"1. LOW-QUALITY IMAGES: Blurry, pixelated, heavily compressed, or distorted images are HIGH RISK\n"
|
|
|
|
| 601 |
"4. SOCIAL ENGINEERING: Impersonation, urgency tactics, pressure to act quickly\n\n"
|
| 602 |
"IMPORTANT: Low image quality + suspicious content = ALMOST ALWAYS SCAM\n\n"
|
| 603 |
f"Heuristic analysis: score={score}/10, reasons: {', '.join(reasons) if reasons else 'none'}.\n\n"
|
| 604 |
+
f"Message content:\n{(message.content or '')[:2000]}\n\n"
|
| 605 |
+
"Recent same-user context (for delayed scam campaigns):\n"
|
| 606 |
+
f"{chr(10).join(recent_lines) if recent_lines else '-'}"
|
| 607 |
)
|
| 608 |
content_parts: list[dict] = [{"type": "text", "text": base_text}]
|
| 609 |
image_count = 0
|
|
|
|
| 617 |
image_count += 1
|
| 618 |
except Exception:
|
| 619 |
continue
|
| 620 |
+
url_images = await self._image_bytes_from_text_urls(message.content or "")
|
| 621 |
+
for raw in url_images[:3]:
|
| 622 |
+
try:
|
| 623 |
+
data_url = "data:image/png;base64," + base64.b64encode(raw[:2_000_000]).decode("utf-8")
|
| 624 |
+
content_parts.append({"type": "image_url", "image_url": {"url": data_url}})
|
| 625 |
+
image_count += 1
|
| 626 |
+
except Exception:
|
| 627 |
+
continue
|
| 628 |
if image_count == 0 and not message.content.strip():
|
| 629 |
return False
|
| 630 |
|
|
|
|
| 662 |
return False
|
| 663 |
try:
|
| 664 |
model = genai.GenerativeModel("gemini-1.5-flash")
|
| 665 |
+
level = await self._shield_level(message.guild.id)
|
| 666 |
+
now_ts = time.time()
|
| 667 |
+
horizon = self._context_window_seconds(level)
|
| 668 |
+
recent_rows = self._build_recent_context(message.guild.id, message.author.id, now_ts=now_ts, horizon_seconds=horizon)
|
| 669 |
+
recent_lines: list[str] = []
|
| 670 |
+
for ts, channel_id, text, row_emojis in recent_rows[-12:]:
|
| 671 |
+
delta_min = int(max(0, now_ts - ts) // 60)
|
| 672 |
+
compact_text = (text or "").replace("\n", " ").strip()[:180]
|
| 673 |
+
emoji_str = " ".join(row_emojis[:8]) if row_emojis else "-"
|
| 674 |
+
recent_lines.append(f"[{delta_min}m][#{channel_id}] {compact_text} | emojis={emoji_str}")
|
| 675 |
prompt = (
|
| 676 |
"Classify if this Discord message is a scam/phishing attempt.\n"
|
| 677 |
"Use context. Do NOT flag harmless 'dm me' alone.\n"
|
| 678 |
"Reply with TRUE or FALSE only.\n\n"
|
| 679 |
+
f"Message: {(message.content or '')[:2000]}\n\n"
|
| 680 |
+
"Recent same-user context (for delayed scam campaigns):\n"
|
| 681 |
+
f"{chr(10).join(recent_lines) if recent_lines else '-'}"
|
| 682 |
)
|
| 683 |
parts: list = [prompt]
|
| 684 |
for att in message.attachments[:3]:
|
|
|
|
| 1162 |
if message.author.bot or not message.guild:
|
| 1163 |
return
|
| 1164 |
text_content = message.content or ""
|
| 1165 |
+
emoji_tokens = self._extract_emojis(text_content)
|
| 1166 |
await self.send_log(
|
| 1167 |
message.guild,
|
| 1168 |
"💬 Message Sent",
|
|
|
|
| 1174 |
bucket = self._recent_user_messages[key]
|
| 1175 |
bucket.append((now_ts, text_content.strip()))
|
| 1176 |
stitched = " ".join(chunk for ts, chunk in bucket if now_ts - ts <= 90 and chunk)
|
| 1177 |
+
context_bucket = self._recent_user_context[key]
|
| 1178 |
+
context_bucket.append((now_ts, message.channel.id, text_content.strip()[:350], emoji_tokens[:20]))
|
| 1179 |
+
|
| 1180 |
+
level = await self._shield_level(message.guild.id)
|
| 1181 |
+
emoji_score, emoji_reasons = self._emoji_scam_heuristics(text_content, emoji_tokens)
|
| 1182 |
+
emoji_threshold = 3 if level == "low" else (2 if level == "medium" else 1)
|
| 1183 |
+
if emoji_tokens and emoji_score >= emoji_threshold:
|
| 1184 |
+
contextual_scam = await self._openrouter_contextual_scam_verdict(
|
| 1185 |
+
message,
|
| 1186 |
+
emojis=emoji_tokens,
|
| 1187 |
+
stitched_text=stitched,
|
| 1188 |
+
emoji_score=emoji_score,
|
| 1189 |
+
emoji_reasons=emoji_reasons,
|
| 1190 |
+
)
|
| 1191 |
+
if contextual_scam:
|
| 1192 |
+
await self._apply_ai_shield(message, "SCAM")
|
| 1193 |
+
return
|
| 1194 |
|
| 1195 |
if stitched and self._is_high_risk_scam_text(stitched):
|
| 1196 |
violation = await self._openrouter_shield_violation(stitched)
|
|
|
|
| 1201 |
if message.attachments:
|
| 1202 |
has_image = any((att.content_type or "").startswith("image/") for att in message.attachments)
|
| 1203 |
if has_image:
|
| 1204 |
+
attachment_hashes: list[str] = []
|
| 1205 |
+
attachment_images: list[bytes] = []
|
| 1206 |
for att in message.attachments:
|
| 1207 |
if not (att.content_type or "").startswith("image/"):
|
| 1208 |
continue
|
|
|
|
| 1211 |
except Exception:
|
| 1212 |
continue
|
| 1213 |
digest = hashlib.sha256(raw).hexdigest()
|
| 1214 |
+
attachment_hashes.append(digest)
|
| 1215 |
+
attachment_images.append(raw[:2_000_000])
|
| 1216 |
row = await self.bot.db.fetchone(
|
| 1217 |
"SELECT image_hash FROM scam_images WHERE guild_id = ? AND image_hash = ?",
|
| 1218 |
message.guild.id,
|
|
|
|
| 1223 |
deleted = await message.channel.purge(limit=15, check=lambda m: m.author.id == message.author.id and m.attachments)
|
| 1224 |
await self.send_log(message.guild, "🧹 Scam Image Purge", f"Deleted {len(deleted)} image messages from {message.author.mention}")
|
| 1225 |
return
|
| 1226 |
+
quality_score, quality_reasons, quality_metrics = self._image_quality_heuristics(attachment_images)
|
| 1227 |
+
score, reasons = self._scam_heuristics(message.content or "", has_images=True)
|
| 1228 |
+
ocr_text = ""
|
| 1229 |
+
ocr_score = 0
|
| 1230 |
+
ocr_reasons: list[str] = []
|
| 1231 |
+
if score >= 2 or quality_score >= 2:
|
| 1232 |
+
ocr_text = await self._openrouter_extract_image_text(message, preloaded_images=attachment_images)
|
| 1233 |
+
ocr_score, ocr_reasons = self._ocr_scam_heuristics(ocr_text)
|
| 1234 |
+
|
| 1235 |
is_image_scam = await self._analyze_scam_with_openrouter(message)
|
| 1236 |
if not is_image_scam:
|
| 1237 |
is_image_scam = await self._analyze_scam_with_gemini(message)
|
| 1238 |
+
|
| 1239 |
+
combined_score = score + quality_score + ocr_score
|
| 1240 |
+
has_direct_scam_cue = bool(
|
| 1241 |
+
SCAM_LINK_RE.search(message.content or "")
|
| 1242 |
+
or SCAM_KEYWORDS_RE.search((message.content or "") + " " + (ocr_text or ""))
|
| 1243 |
+
or ocr_score >= 2
|
| 1244 |
+
)
|
| 1245 |
+
if not is_image_scam and combined_score >= 7 and has_direct_scam_cue:
|
| 1246 |
+
is_image_scam = True
|
| 1247 |
+
|
| 1248 |
+
quality_text = ", ".join(quality_reasons) if quality_reasons else "none"
|
| 1249 |
+
ocr_snippet = (ocr_text or "").replace("\n", " ")[:180]
|
| 1250 |
+
detail = (
|
| 1251 |
+
f"heuristic_score={score}; heuristic_reasons={', '.join(reasons) if reasons else 'none'}; "
|
| 1252 |
+
f"quality_score={quality_score}; quality_reasons={quality_text}; quality_metrics={quality_metrics}; "
|
| 1253 |
+
f"ocr_score={ocr_score}; ocr_reasons={', '.join(ocr_reasons) if ocr_reasons else 'none'}; "
|
| 1254 |
+
f"ocr_text={ocr_snippet or 'none'}; combined_score={combined_score}; ai_scam={is_image_scam}"
|
| 1255 |
+
)
|
| 1256 |
await self._log_image_scan(message, "SCAM" if is_image_scam else "SAFE", detail)
|
| 1257 |
if is_image_scam:
|
| 1258 |
+
for digest in attachment_hashes:
|
| 1259 |
+
await self._store_scam_hash(message.guild.id, digest, created_by=(self.bot.user.id if self.bot.user else 0))
|
| 1260 |
await self._apply_ai_shield(message, "SCAM")
|
| 1261 |
return
|
| 1262 |
elif re.search(r"https?://\S+\.(png|jpg|jpeg|webp|gif)(\?|$)", message.content or "", re.IGNORECASE):
|
| 1263 |
+
url_items = await self._image_items_from_text_urls(message.content or "")
|
| 1264 |
+
url_hashes = [digest for _, _, digest in url_items]
|
| 1265 |
+
url_images = [raw for _, raw, _ in url_items]
|
| 1266 |
+
for digest in url_hashes:
|
| 1267 |
+
row = await self.bot.db.fetchone(
|
| 1268 |
+
"SELECT image_hash FROM scam_images WHERE guild_id = ? AND image_hash = ?",
|
| 1269 |
+
message.guild.id,
|
| 1270 |
+
digest,
|
| 1271 |
+
)
|
| 1272 |
+
if row:
|
| 1273 |
+
await self._apply_ai_shield(message, "SCAM")
|
| 1274 |
+
return
|
| 1275 |
proxy_message = message
|
| 1276 |
+
quality_score, quality_reasons, quality_metrics = self._image_quality_heuristics(url_images)
|
| 1277 |
+
score, reasons = self._scam_heuristics(message.content or "", has_images=True)
|
| 1278 |
+
ocr_text = ""
|
| 1279 |
+
ocr_score = 0
|
| 1280 |
+
ocr_reasons: list[str] = []
|
| 1281 |
+
if score >= 2 or quality_score >= 2:
|
| 1282 |
+
ocr_text = await self._openrouter_extract_image_text(proxy_message, preloaded_images=url_images)
|
| 1283 |
+
ocr_score, ocr_reasons = self._ocr_scam_heuristics(ocr_text)
|
| 1284 |
+
|
| 1285 |
is_image_scam = await self._analyze_scam_with_openrouter(proxy_message)
|
| 1286 |
if not is_image_scam:
|
| 1287 |
is_image_scam = await self._analyze_scam_with_gemini(proxy_message)
|
| 1288 |
+
|
| 1289 |
+
combined_score = score + quality_score + ocr_score
|
| 1290 |
+
has_direct_scam_cue = bool(
|
| 1291 |
+
SCAM_LINK_RE.search(message.content or "")
|
| 1292 |
+
or SCAM_KEYWORDS_RE.search((message.content or "") + " " + (ocr_text or ""))
|
| 1293 |
+
or ocr_score >= 2
|
| 1294 |
+
)
|
| 1295 |
+
if not is_image_scam and combined_score >= 7 and has_direct_scam_cue:
|
| 1296 |
+
is_image_scam = True
|
| 1297 |
+
|
| 1298 |
+
quality_text = ", ".join(quality_reasons) if quality_reasons else "none"
|
| 1299 |
+
ocr_snippet = (ocr_text or "").replace("\n", " ")[:180]
|
| 1300 |
+
detail = (
|
| 1301 |
+
f"url_image_scan=1; heuristic_score={score}; heuristic_reasons={', '.join(reasons) if reasons else 'none'}; "
|
| 1302 |
+
f"quality_score={quality_score}; quality_reasons={quality_text}; quality_metrics={quality_metrics}; "
|
| 1303 |
+
f"ocr_score={ocr_score}; ocr_reasons={', '.join(ocr_reasons) if ocr_reasons else 'none'}; "
|
| 1304 |
+
f"ocr_text={ocr_snippet or 'none'}; combined_score={combined_score}; ai_scam={is_image_scam}"
|
| 1305 |
+
)
|
| 1306 |
await self._log_image_scan(message, "SCAM" if is_image_scam else "SAFE", detail)
|
| 1307 |
if is_image_scam:
|
| 1308 |
+
for digest in url_hashes:
|
| 1309 |
+
await self._store_scam_hash(message.guild.id, digest, created_by=(self.bot.user.id if self.bot.user else 0))
|
| 1310 |
await self._apply_ai_shield(message, "SCAM")
|
| 1311 |
return
|
| 1312 |
if self._is_high_risk_scam_text(text_content):
|
|
|
|
| 1379 |
elif mention_type == "role" and role_id:
|
| 1380 |
mention = f"<@&{role_id}> "
|
| 1381 |
for item in fresh[:3]:
|
| 1382 |
+
link = item.get("link", "")
|
| 1383 |
+
embed = discord.Embed(
|
| 1384 |
+
title=f"🎁 Free Game Drop: {item.get('title', '')}",
|
| 1385 |
+
url=link,
|
| 1386 |
+
color=NEON_CYAN,
|
| 1387 |
+
description=(
|
| 1388 |
+
f"**{item.get('description', 'Limited-time free game offer!')}**\n\n"
|
| 1389 |
+
f"Grab it before the offer expires."
|
| 1390 |
+
),
|
| 1391 |
+
)
|
| 1392 |
if item.get("image"):
|
| 1393 |
embed.set_image(url=item["image"])
|
| 1394 |
+
embed.set_thumbnail(url=self._store_icon(item.get("platform", "")))
|
| 1395 |
+
embed.add_field(name="🏪 Platform", value=item.get("platform", "Unknown"), inline=True)
|
| 1396 |
+
embed.add_field(name="🧩 Type", value=item.get("game_type", "Game"), inline=True)
|
| 1397 |
+
embed.add_field(name="💸 Previous Price", value=item.get("original_price", "N/A"), inline=True)
|
| 1398 |
+
embed.add_field(name="⏳ Offer Ends", value=item.get("end_date", "N/A"), inline=True)
|
| 1399 |
+
embed.add_field(name="🔗 Claim Link", value=f"[Open Giveaway]({link})" if link else "N/A", inline=True)
|
| 1400 |
+
embed.set_footer(text="BOT- Free Games Radar • Epic / Steam / GOG")
|
| 1401 |
+
view = FreeGameClaimView(link, item.get("id", "")) if link else None
|
| 1402 |
await channel.send(content=mention or None, embed=embed, view=view)
|
| 1403 |
latest_ids = ",".join(item["id"] for item in items[:20])
|
| 1404 |
await self.bot.db.execute(
|
bot/cogs/fun.py
CHANGED
|
@@ -605,14 +605,19 @@ class Fun(commands.Cog):
|
|
| 605 |
link = item["link"]
|
| 606 |
embed = ImperialMotaz.craft_embed(
|
| 607 |
title=f"[FREE GAME] | {title}",
|
| 608 |
-
description=
|
|
|
|
|
|
|
|
|
|
| 609 |
color=NEON_PINK,
|
| 610 |
footer="Free Games Tracker",
|
| 611 |
)
|
| 612 |
embed.url = link
|
| 613 |
embed.add_field(name="Platform", value=f"「 {item.get('platform', 'Unknown')} 」", inline=True)
|
|
|
|
| 614 |
embed.add_field(name="Original Price", value=f"「 {item.get('original_price', 'N/A')} 」", inline=True)
|
| 615 |
embed.add_field(name="Ends On", value=f"「 {item.get('end_date', 'N/A')} 」", inline=True)
|
|
|
|
| 616 |
if item.get("image"):
|
| 617 |
embed.set_image(url=item["image"])
|
| 618 |
embed.set_thumbnail(url=self._store_icon(item.get("platform", "")))
|
|
@@ -760,7 +765,7 @@ class Fun(commands.Cog):
|
|
| 760 |
|
| 761 |
@gamehub.command(name="trivia", description="Gaming/Movies/Series trivia")
|
| 762 |
async def gamehub_trivia(self, ctx: commands.Context, category: str = "gaming", difficulty: str = "medium") -> None:
|
| 763 |
-
await self.trivia(ctx, category=category
|
| 764 |
|
| 765 |
@commands.hybrid_command(name="xo", description="TicTacToe vs member or bot", hidden=True, with_app_command=False)
|
| 766 |
async def xo(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
|
|
@@ -845,7 +850,7 @@ class Fun(commands.Cog):
|
|
| 845 |
),
|
| 846 |
color=NEON_ORANGE,
|
| 847 |
)
|
| 848 |
-
embed.add_field(name="🎁 مكافأة", value="تم فتح combo باك مان!
|
| 849 |
else:
|
| 850 |
embed = discord.Embed(
|
| 851 |
title="🍄 Arcade Challenge",
|
|
@@ -857,7 +862,7 @@ class Fun(commands.Cog):
|
|
| 857 |
),
|
| 858 |
color=NEON_ORANGE,
|
| 859 |
)
|
| 860 |
-
embed.add_field(name="🎁 Bonus", value="Pac-Man combo unlocked!
|
| 861 |
embed.set_thumbnail(url="https://upload.wikimedia.org/wikipedia/en/a/a9/MarioNSMBUDeluxe.png")
|
| 862 |
await ctx.reply(embed=embed)
|
| 863 |
|
|
|
|
| 605 |
link = item["link"]
|
| 606 |
embed = ImperialMotaz.craft_embed(
|
| 607 |
title=f"[FREE GAME] | {title}",
|
| 608 |
+
description=(
|
| 609 |
+
f"**{item.get('description', 'Limited-time free game offer!')}**\n\n"
|
| 610 |
+
"Claim it now from the official store page before it expires."
|
| 611 |
+
),
|
| 612 |
color=NEON_PINK,
|
| 613 |
footer="Free Games Tracker",
|
| 614 |
)
|
| 615 |
embed.url = link
|
| 616 |
embed.add_field(name="Platform", value=f"「 {item.get('platform', 'Unknown')} 」", inline=True)
|
| 617 |
+
embed.add_field(name="Type", value=f"「 {item.get('game_type', 'Game')} 」", inline=True)
|
| 618 |
embed.add_field(name="Original Price", value=f"「 {item.get('original_price', 'N/A')} 」", inline=True)
|
| 619 |
embed.add_field(name="Ends On", value=f"「 {item.get('end_date', 'N/A')} 」", inline=True)
|
| 620 |
+
embed.add_field(name="Link", value=f"[Open Giveaway]({link})", inline=False)
|
| 621 |
if item.get("image"):
|
| 622 |
embed.set_image(url=item["image"])
|
| 623 |
embed.set_thumbnail(url=self._store_icon(item.get("platform", "")))
|
|
|
|
| 765 |
|
| 766 |
@gamehub.command(name="trivia", description="Gaming/Movies/Series trivia")
|
| 767 |
async def gamehub_trivia(self, ctx: commands.Context, category: str = "gaming", difficulty: str = "medium") -> None:
|
| 768 |
+
await self.trivia(ctx, category=category)
|
| 769 |
|
| 770 |
@commands.hybrid_command(name="xo", description="TicTacToe vs member or bot", hidden=True, with_app_command=False)
|
| 771 |
async def xo(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
|
|
|
|
| 850 |
),
|
| 851 |
color=NEON_ORANGE,
|
| 852 |
)
|
| 853 |
+
embed.add_field(name="🎁 مكافأة", value="تم فتح combo باك مان! <:animatedarrowyellow:1477261257592668271>", inline=False)
|
| 854 |
else:
|
| 855 |
embed = discord.Embed(
|
| 856 |
title="🍄 Arcade Challenge",
|
|
|
|
| 862 |
),
|
| 863 |
color=NEON_ORANGE,
|
| 864 |
)
|
| 865 |
+
embed.add_field(name="🎁 Bonus", value="Pac-Man combo unlocked! <:animatedarrowyellow:1477261257592668271>", inline=False)
|
| 866 |
embed.set_thumbnail(url="https://upload.wikimedia.org/wikipedia/en/a/a9/MarioNSMBUDeluxe.png")
|
| 867 |
await ctx.reply(embed=embed)
|
| 868 |
|
bot/cogs/gambling.py
CHANGED
|
@@ -1,771 +1,689 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Interactive Gambling & RPG Cog - Casino Games with UI and Full RPG System
|
| 3 |
-
"""
|
| 4 |
-
|
| 5 |
-
from __future__ import annotations
|
| 6 |
-
|
| 7 |
-
import random
|
| 8 |
-
import asyncio
|
| 9 |
-
from typing import List, Optional, Dict
|
| 10 |
-
|
| 11 |
-
import discord
|
| 12 |
-
from discord.ext import commands
|
| 13 |
-
|
| 14 |
-
from bot.theme import NEON_CYAN, NEON_LIME, NEON_RED, NEON_GOLD, NEON_PURPLE, NEON_PINK, NEON_BLUE, panel_divider, add_banner_to_embed
|
| 15 |
-
from bot.emojis import ui
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
class Card:
|
| 19 |
-
def __init__(self, suit: str, rank: str):
|
| 20 |
-
self.suit = suit
|
| 21 |
-
self.rank = rank
|
| 22 |
-
self.value = self._get_value()
|
| 23 |
-
|
| 24 |
-
def _get_value(self) -> int:
|
| 25 |
-
if self.rank in ['J', 'Q', 'K']:
|
| 26 |
-
return 10
|
| 27 |
-
elif self.rank == 'A':
|
| 28 |
-
return 11
|
| 29 |
-
else:
|
| 30 |
-
return int(self.rank)
|
| 31 |
-
|
| 32 |
-
def __str__(self) -> str:
|
| 33 |
-
return f"{self.rank}{self.suit}"
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
class Deck:
|
| 37 |
-
def __init__(self):
|
| 38 |
-
self.cards = []
|
| 39 |
-
suits = ['♠', '♥', '♦', '♣']
|
| 40 |
-
ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']
|
| 41 |
-
for suit in suits:
|
| 42 |
-
for rank in ranks:
|
| 43 |
-
self.cards.append(Card(suit, rank))
|
| 44 |
-
random.shuffle(self.cards)
|
| 45 |
-
|
| 46 |
-
def draw(self) -> Card:
|
| 47 |
-
return self.cards.pop()
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
class BlackjackView(discord.ui.View):
|
| 51 |
-
def __init__(self, cog, guild_id: int, user_id: int, bet: int):
|
| 52 |
-
super().__init__(timeout=300)
|
| 53 |
-
self.cog = cog
|
| 54 |
-
self.guild_id = guild_id
|
| 55 |
-
self.user_id = user_id
|
| 56 |
-
self.bet = bet
|
| 57 |
-
self.deck = Deck()
|
| 58 |
-
self.player_hand = [self.deck.draw(), self.deck.draw()]
|
| 59 |
-
self.dealer_hand = [self.deck.draw(), self.deck.draw()]
|
| 60 |
-
self.game_over = False
|
| 61 |
-
self.message: Optional[discord.Message] = None
|
| 62 |
-
|
| 63 |
-
def hand_value(self, hand: List[Card]) -> int:
|
| 64 |
-
value = sum(card.value for card in hand)
|
| 65 |
-
aces = sum(1 for card in hand if card.rank == 'A')
|
| 66 |
-
while value > 21 and aces:
|
| 67 |
-
value -= 10
|
| 68 |
-
aces -= 1
|
| 69 |
-
return value
|
| 70 |
-
|
| 71 |
-
def format_hand(self, hand: List[Card], hide_first: bool = False) -> str:
|
| 72 |
-
if hide_first and len(hand) >= 2:
|
| 73 |
-
return f"[❓❓] [{hand[1]}]"
|
| 74 |
-
return ' '.join([f"[{str(card)}]" for card in hand])
|
| 75 |
-
|
| 76 |
-
def build_embed(self, show_dealer_card: bool = True) -> discord.Embed:
|
| 77 |
-
player_value = self.hand_value(self.player_hand)
|
| 78 |
-
dealer_value = self.hand_value(self.dealer_hand) if show_dealer_card else '?'
|
| 79 |
-
|
| 80 |
-
embed = discord.Embed(
|
| 81 |
-
title="🃏 Blackjack Casino",
|
| 82 |
-
description=panel_divider('purple'),
|
| 83 |
-
color=NEON_PURPLE,
|
| 84 |
-
)
|
| 85 |
-
|
| 86 |
-
dealer_display = self.format_hand(self.dealer_hand, hide_first=not show_dealer_card)
|
| 87 |
-
embed.add_field(
|
| 88 |
-
name="🎩 Dealer's Hand",
|
| 89 |
-
value=f"{dealer_display}\n**Value:** {dealer_value}",
|
| 90 |
-
inline=False,
|
| 91 |
-
)
|
| 92 |
-
|
| 93 |
-
player_display = self.format_hand(self.player_hand)
|
| 94 |
-
embed.add_field(
|
| 95 |
-
name="👤 Your Hand",
|
| 96 |
-
value=f"{player_display}\n**Value:** {player_value}",
|
| 97 |
-
inline=False,
|
| 98 |
-
)
|
| 99 |
-
|
| 100 |
-
embed.add_field(name="💰 Bet", value=f"`{self.bet:,}` coins", inline=False)
|
| 101 |
-
|
| 102 |
-
if self.game_over:
|
| 103 |
-
if player_value > 21:
|
| 104 |
-
embed.description = f"❌ **BUST!** You lost `{self.bet:,}` coins."
|
| 105 |
-
embed.color = NEON_RED
|
| 106 |
-
elif dealer_value == 21 and len(self.dealer_hand) == 2:
|
| 107 |
-
embed.description = f"❌ **Dealer Blackjack!** You lost `{self.bet:,}` coins."
|
| 108 |
-
embed.color = NEON_RED
|
| 109 |
-
elif dealer_value > 21:
|
| 110 |
-
winnings = self.bet
|
| 111 |
-
embed.description = f"✅ **Dealer Busts! You Win!** +`{winnings:,}` coins!"
|
| 112 |
-
embed.color = NEON_LIME
|
| 113 |
-
elif player_value > dealer_value:
|
| 114 |
-
winnings = self.bet
|
| 115 |
-
embed.description = f"✅ **You Win!** +`{winnings:,}` coins!"
|
| 116 |
-
embed.color = NEON_LIME
|
| 117 |
-
elif player_value < dealer_value:
|
| 118 |
-
embed.description = f"❌ **Dealer Wins!** You lost `{self.bet:,}` coins."
|
| 119 |
-
embed.color = NEON_RED
|
| 120 |
-
else:
|
| 121 |
-
embed.description = f"🤝 **Push!** Bet returned."
|
| 122 |
-
embed.color = NEON_GOLD
|
| 123 |
-
|
| 124 |
-
return embed
|
| 125 |
-
|
| 126 |
-
async def end_game(self, interaction: discord.Interaction):
|
| 127 |
-
self.game_over = True
|
| 128 |
-
|
| 129 |
-
engagement_cog = interaction.
|
| 130 |
-
if not engagement_cog:
|
| 131 |
-
return
|
| 132 |
-
|
| 133 |
-
player_value = self.hand_value(self.player_hand)
|
| 134 |
-
dealer_value = self.hand_value(self.dealer_hand)
|
| 135 |
-
|
| 136 |
-
if player_value > 21:
|
| 137 |
-
await engagement_cog._remove_coins(self.guild_id, self.user_id, self.bet)
|
| 138 |
-
elif dealer_value == 21 and len(self.dealer_hand) == 2:
|
| 139 |
-
await engagement_cog._remove_coins(self.guild_id, self.user_id, self.bet)
|
| 140 |
-
elif dealer_value > 21 or player_value > dealer_value:
|
| 141 |
-
await engagement_cog._add_coins(self.guild_id, self.user_id, self.bet)
|
| 142 |
-
elif player_value < dealer_value:
|
| 143 |
-
await engagement_cog._remove_coins(self.guild_id, self.user_id, self.bet)
|
| 144 |
-
|
| 145 |
-
embed = self.build_embed(show_dealer_card=True)
|
| 146 |
-
for child in self.children:
|
| 147 |
-
child.disabled = True
|
| 148 |
-
if self.message:
|
| 149 |
-
await self.message.edit(embed=embed, view=self)
|
| 150 |
-
|
| 151 |
-
@discord.ui.button(label="Hit", emoji=ui("plus"), style=discord.ButtonStyle.primary, custom_id="bj_hit")
|
| 152 |
-
async def hit(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 153 |
-
if self.game_over or interaction.user.id != self.user_id:
|
| 154 |
-
await interaction.response.send_message("❌ Not your game.", ephemeral=True)
|
| 155 |
-
return
|
| 156 |
-
|
| 157 |
-
self.player_hand.append(self.deck.draw())
|
| 158 |
-
player_value = self.hand_value(self.player_hand)
|
| 159 |
-
|
| 160 |
-
if player_value > 21:
|
| 161 |
-
await self.end_game(interaction)
|
| 162 |
-
else:
|
| 163 |
-
embed = self.build_embed(show_dealer_card=False)
|
| 164 |
-
await interaction.response.edit_message(embed=embed, view=self)
|
| 165 |
-
|
| 166 |
-
@discord.ui.button(label="Stand", emoji=ui("stop"), style=discord.ButtonStyle.danger, custom_id="bj_stand")
|
| 167 |
-
async def stand(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 168 |
-
if self.game_over or interaction.user.id != self.user_id:
|
| 169 |
-
await interaction.response.send_message("❌ Not your game.", ephemeral=True)
|
| 170 |
-
return
|
| 171 |
-
|
| 172 |
-
while self.hand_value(self.dealer_hand) < 17:
|
| 173 |
-
self.dealer_hand.append(self.deck.draw())
|
| 174 |
-
|
| 175 |
-
await self.end_game(interaction)
|
| 176 |
-
|
| 177 |
-
@discord.ui.button(label="Double Down", emoji=ui("star"), style=discord.ButtonStyle.success, custom_id="bj_double")
|
| 178 |
-
async def double_down(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 179 |
-
if self.game_over or interaction.user.id != self.user_id:
|
| 180 |
-
await interaction.response.send_message("❌ Not your game.", ephemeral=True)
|
| 181 |
-
return
|
| 182 |
-
|
| 183 |
-
if len(self.player_hand) != 2:
|
| 184 |
-
await interaction.response.send_message("❌ Can only double on first turn.", ephemeral=True)
|
| 185 |
-
return
|
| 186 |
-
|
| 187 |
-
engagement_cog = interaction.
|
| 188 |
-
if not engagement_cog:
|
| 189 |
-
return
|
| 190 |
-
|
| 191 |
-
row = await interaction.
|
| 192 |
-
"SELECT wallet FROM user_balance WHERE guild_id = ? AND user_id = ?",
|
| 193 |
-
self.guild_id, self.user_id,
|
| 194 |
-
)
|
| 195 |
-
wallet = row[0] if row else 0
|
| 196 |
-
|
| 197 |
-
if wallet < self.bet:
|
| 198 |
-
await interaction.response.send_message("❌ Insufficient funds to double.", ephemeral=True)
|
| 199 |
-
return
|
| 200 |
-
|
| 201 |
-
self.bet *= 2
|
| 202 |
-
self.player_hand.append(self.deck.draw())
|
| 203 |
-
|
| 204 |
-
player_value = self.hand_value(self.player_hand)
|
| 205 |
-
if player_value > 21:
|
| 206 |
-
await self.end_game(interaction)
|
| 207 |
-
else:
|
| 208 |
-
while self.hand_value(self.dealer_hand) < 17:
|
| 209 |
-
self.dealer_hand.append(self.deck.draw())
|
| 210 |
-
await self.end_game(interaction)
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
class RouletteGameView(discord.ui.View):
|
| 214 |
-
def __init__(self, cog, guild_id: int, user_id: int):
|
| 215 |
-
super().__init__(timeout=300)
|
| 216 |
-
self.cog = cog
|
| 217 |
-
self.guild_id = guild_id
|
| 218 |
-
self.user_id = user_id
|
| 219 |
-
self.bet_amount = 0
|
| 220 |
-
self.bet_type = ""
|
| 221 |
-
self.spinning = False
|
| 222 |
-
|
| 223 |
-
RED_NUMBERS = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]
|
| 224 |
-
BLACK_NUMBERS = [2, 4, 6, 8, 10, 11, 13, 15, 17, 20, 22, 24, 26, 28, 29, 31, 33, 35]
|
| 225 |
-
|
| 226 |
-
def get_color(self, number: int) -> str:
|
| 227 |
-
if number == 0:
|
| 228 |
-
return "
|
| 229 |
-
elif number in self.RED_NUMBERS:
|
| 230 |
-
return "🔴"
|
| 231 |
-
else:
|
| 232 |
-
return "⚫"
|
| 233 |
-
|
| 234 |
-
def build_embed(self, number: Optional[int] = None, won: bool = False, winnings: int = 0) -> discord.Embed:
|
| 235 |
-
embed = discord.Embed(
|
| 236 |
-
title="🎰 Roulette Casino",
|
| 237 |
-
description=panel_divider('gold'),
|
| 238 |
-
color=NEON_GOLD,
|
| 239 |
-
)
|
| 240 |
-
|
| 241 |
-
if number is not None:
|
| 242 |
-
color_emoji = self.get_color(number)
|
| 243 |
-
embed.add_field(name="🎯 Result", value=f"{color_emoji} **{number}**", inline=False)
|
| 244 |
-
embed.add_field(name="💰 Bet", value=f"`{self.bet_amount:,}` on {self.bet_type}", inline=True)
|
| 245 |
-
embed.add_field(name="💵 Result", value=f"`{'+' if won else '-'}{abs(winnings):,}` coins", inline=True)
|
| 246 |
-
|
| 247 |
-
if won:
|
| 248 |
-
embed.description = f"✅ **You Won!** +`{winnings:,}` coins!"
|
| 249 |
-
embed.color = NEON_LIME
|
| 250 |
-
else:
|
| 251 |
-
embed.description = f"❌ **You Lost!** -`{abs(winnings):,}` coins."
|
| 252 |
-
embed.color = NEON_RED
|
| 253 |
-
else:
|
| 254 |
-
embed.description = "Place your bet and spin the wheel!"
|
| 255 |
-
grid = ""
|
| 256 |
-
for i in range(0, 37, 3):
|
| 257 |
-
if i == 0:
|
| 258 |
-
grid += "
|
| 259 |
-
else:
|
| 260 |
-
c1 = "🔴" if i in self.RED_NUMBERS else "
|
| 261 |
-
c2 = "🔴" if (i+1) in self.RED_NUMBERS else "⚫"
|
| 262 |
-
c3 = "🔴" if (i+2) in self.RED_NUMBERS else "⚫"
|
| 263 |
-
grid += f"{c1} {i} {c2} {i+1} {c3} {i+2}\n"
|
| 264 |
-
embed.add_field(name="🎡 Roulette Wheel", value=f"```\n{grid}```", inline=False)
|
| 265 |
-
|
| 266 |
-
return embed
|
| 267 |
-
|
| 268 |
-
@discord.ui.button(label="Bet Red 🔴", style=discord.ButtonStyle.danger, custom_id="roulette_red")
|
| 269 |
-
async def bet_red(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 270 |
-
await self.prompt_bet(interaction, "red")
|
| 271 |
-
|
| 272 |
-
@discord.ui.button(label="Bet Black ⚫", style=discord.ButtonStyle.gray, custom_id="roulette_black")
|
| 273 |
-
async def bet_black(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 274 |
-
await self.prompt_bet(interaction, "black")
|
| 275 |
-
|
| 276 |
-
@discord.ui.button(label="Bet Even", style=discord.ButtonStyle.primary, custom_id="roulette_even")
|
| 277 |
-
async def bet_even(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 278 |
-
await self.prompt_bet(interaction, "even")
|
| 279 |
-
|
| 280 |
-
@discord.ui.button(label="Bet Odd", style=discord.ButtonStyle.primary, custom_id="roulette_odd")
|
| 281 |
-
async def bet_odd(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 282 |
-
await self.prompt_bet(interaction, "odd")
|
| 283 |
-
|
| 284 |
-
async def prompt_bet(self, interaction: discord.Interaction, bet_type: str):
|
| 285 |
-
if interaction.user.id != self.user_id:
|
| 286 |
-
return
|
| 287 |
-
await interaction.response.send_modal(RouletteBetModal(self.cog, self.guild_id, self.user_id, bet_type))
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
class RouletteBetModal(discord.ui.Modal, title="🎰 Place Your Bet"):
|
| 291 |
-
bet_amount = discord.ui.TextInput(
|
| 292 |
-
label="Bet Amount",
|
| 293 |
-
placeholder="Enter amount (min 10)",
|
| 294 |
-
required=True,
|
| 295 |
-
min_length=1,
|
| 296 |
-
max_length=10,
|
| 297 |
-
)
|
| 298 |
-
|
| 299 |
-
def __init__(self, cog, guild_id: int, user_id: int, bet_type: str):
|
| 300 |
-
super().__init__()
|
| 301 |
-
self.cog = cog
|
| 302 |
-
self.guild_id = guild_id
|
| 303 |
-
self.user_id = user_id
|
| 304 |
-
self.bet_type = bet_type
|
| 305 |
-
|
| 306 |
-
async def on_submit(self, interaction: discord.Interaction) -> None:
|
| 307 |
-
try:
|
| 308 |
-
bet = int(self.bet_amount.value.strip())
|
| 309 |
-
except ValueError:
|
| 310 |
-
await interaction.response.send_message("❌ Invalid bet amount.", ephemeral=True)
|
| 311 |
-
return
|
| 312 |
-
|
| 313 |
-
if bet < 10:
|
| 314 |
-
await interaction.response.send_message("❌ Minimum bet is 10 coins.", ephemeral=True)
|
| 315 |
-
return
|
| 316 |
-
|
| 317 |
-
engagement_cog = interaction.
|
| 318 |
-
if not engagement_cog:
|
| 319 |
-
await interaction.response.send_message("❌ Economy not available.", ephemeral=True)
|
| 320 |
-
return
|
| 321 |
-
|
| 322 |
-
row = await interaction.
|
| 323 |
-
"SELECT wallet FROM user_balance WHERE guild_id = ? AND user_id = ?",
|
| 324 |
-
self.guild_id, self.user_id,
|
| 325 |
-
)
|
| 326 |
-
wallet = row[0] if row else 0
|
| 327 |
-
|
| 328 |
-
if wallet < bet:
|
| 329 |
-
await interaction.response.send_message(f"❌ Insufficient funds. You have {wallet:,} coins.", ephemeral=True)
|
| 330 |
-
return
|
| 331 |
-
|
| 332 |
-
await interaction.response.defer()
|
| 333 |
-
|
| 334 |
-
number = random.randint(0, 36)
|
| 335 |
-
is_red = number in RouletteGameView.RED_NUMBERS
|
| 336 |
-
is_black = number in RouletteGameView.BLACK_NUMBERS
|
| 337 |
-
is_even = number != 0 and number % 2 == 0
|
| 338 |
-
is_odd = number != 0 and number % 2 == 1
|
| 339 |
-
|
| 340 |
-
won = False
|
| 341 |
-
multiplier = 2
|
| 342 |
-
|
| 343 |
-
if self.bet_type == 'red' and is_red:
|
| 344 |
-
won = True
|
| 345 |
-
elif self.bet_type == 'black' and is_black:
|
| 346 |
-
won = True
|
| 347 |
-
elif self.bet_type == 'even' and is_even:
|
| 348 |
-
won = True
|
| 349 |
-
elif self.bet_type == 'odd' and is_odd:
|
| 350 |
-
won = True
|
| 351 |
-
|
| 352 |
-
winnings = bet * multiplier if won else -bet
|
| 353 |
-
|
| 354 |
-
if won:
|
| 355 |
-
await engagement_cog._add_coins(self.guild_id, self.user_id, winnings)
|
| 356 |
-
else:
|
| 357 |
-
await engagement_cog._remove_coins(self.guild_id, self.user_id, bet)
|
| 358 |
-
|
| 359 |
-
view = RouletteGameView(self.cog, self.guild_id, self.user_id)
|
| 360 |
-
view.bet_amount = bet
|
| 361 |
-
view.bet_type = self.bet_type
|
| 362 |
-
embed = view.build_embed(number=number, won=won, winnings=winnings)
|
| 363 |
-
|
| 364 |
-
for child in view.children:
|
| 365 |
-
child.disabled = False
|
| 366 |
-
|
| 367 |
-
await interaction.followup.send(embed=embed, view=view)
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
class RPGView(discord.ui.View):
|
| 371 |
-
def __init__(self, cog, guild_id: int, user_id: int):
|
| 372 |
-
super().__init__(timeout=None)
|
| 373 |
-
self.cog = cog
|
| 374 |
-
self.guild_id = guild_id
|
| 375 |
-
self.user_id = user_id
|
| 376 |
-
self.adventure_image = "https://images.unsplash.com/photo-1519074069444-1ba4fff66d16?w=500"
|
| 377 |
-
|
| 378 |
-
def build_embed(self, title: str, description: str, color: discord.Color) -> discord.Embed:
|
| 379 |
-
embed = discord.Embed(
|
| 380 |
-
title=title,
|
| 381 |
-
description=description,
|
| 382 |
-
color=color,
|
| 383 |
-
)
|
| 384 |
-
embed.set_image(url=self.adventure_image)
|
| 385 |
-
return embed
|
| 386 |
-
|
| 387 |
-
@discord.ui.button(label="⚔️ Fight Monster", emoji=ui("game"), style=discord.ButtonStyle.danger, custom_id="rpg_fight")
|
| 388 |
-
async def fight_monster(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 389 |
-
if interaction.user.id != self.user_id:
|
| 390 |
-
return
|
| 391 |
-
|
| 392 |
-
engagement_cog = interaction.
|
| 393 |
-
if not engagement_cog:
|
| 394 |
-
await interaction.response.send_message("❌ Economy not available.", ephemeral=True)
|
| 395 |
-
return
|
| 396 |
-
|
| 397 |
-
row = await interaction.
|
| 398 |
-
"SELECT level FROM user_xp WHERE guild_id = ? AND user_id = ?",
|
| 399 |
-
self.guild_id, self.user_id,
|
| 400 |
-
)
|
| 401 |
-
level = row[0] if row else 1
|
| 402 |
-
|
| 403 |
-
monsters = [
|
| 404 |
-
("Goblin", 50, 100, 20, 40),
|
| 405 |
-
("Dragon", 200, 500, 50, 100),
|
| 406 |
-
("Skeleton", 30, 80, 15, 30),
|
| 407 |
-
("Orc", 80, 200, 25, 50),
|
| 408 |
-
("Troll", 150, 350, 40, 80),
|
| 409 |
-
("Demon", 300, 600, 60, 120),
|
| 410 |
-
]
|
| 411 |
-
|
| 412 |
-
monster_name, min_reward, max_reward, min_xp, max_xp = random.choice(monsters)
|
| 413 |
-
win_chance = min(0.8, 0.3 + (level * 0.05))
|
| 414 |
-
won = random.random() < win_chance
|
| 415 |
-
|
| 416 |
-
if won:
|
| 417 |
-
reward = random.randint(min_reward, max_reward) + (level * 10)
|
| 418 |
-
xp_gain = random.randint(min_xp, max_xp)
|
| 419 |
-
await engagement_cog._add_coins(self.guild_id, self.user_id, reward)
|
| 420 |
-
await engagement_cog.add_xp(self.guild_id, self.user_id, xp_gain)
|
| 421 |
-
|
| 422 |
-
embed = discord.Embed(
|
| 423 |
-
title="⚔️ RPG Adventure - Victory!",
|
| 424 |
-
description=f"✅ **You defeated the {monster_name}!**\n\n💰 **+{reward:,} coins**\n⭐ **+{xp_gain} XP**\n📈 Level {level} → {level + (1 if xp_gain > 40 else 0)}",
|
| 425 |
-
color=NEON_LIME,
|
| 426 |
-
)
|
| 427 |
-
embed.set_image(url="https://images.unsplash.com/photo-1535905557558-afc4877a26fc?w=500")
|
| 428 |
-
else:
|
| 429 |
-
penalty = random.randint(10, 50) + (level * 5)
|
| 430 |
-
await engagement_cog._remove_coins(self.guild_id, self.user_id, penalty)
|
| 431 |
-
|
| 432 |
-
embed = discord.Embed(
|
| 433 |
-
title="⚔️ RPG Adventure - Defeat!",
|
| 434 |
-
description=f"❌ **The {monster_name} defeated you!**\n\n💀 **-{penalty:,} coins**\n\n💡 Tip: Level up to increase win chance!",
|
| 435 |
-
color=NEON_RED,
|
| 436 |
-
)
|
| 437 |
-
embed.set_image(url="https://images.unsplash.com/photo-1519791883288-dc8bd696e667?w=500")
|
| 438 |
-
|
| 439 |
-
if interaction.guild:
|
| 440 |
-
await add_banner_to_embed(embed, interaction.guild)
|
| 441 |
-
|
| 442 |
-
await interaction.response.send_message(embed=embed, ephemeral=True)
|
| 443 |
-
|
| 444 |
-
@discord.ui.button(label="🎁 Find Treasure", emoji=ui("gift"), style=discord.ButtonStyle.success, custom_id="rpg_treasure")
|
| 445 |
-
async def find_treasure(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 446 |
-
if interaction.user.id != self.user_id:
|
| 447 |
-
return
|
| 448 |
-
|
| 449 |
-
engagement_cog = interaction.
|
| 450 |
-
if not engagement_cog:
|
| 451 |
-
await interaction.response.send_message("❌ Economy not available.", ephemeral=True)
|
| 452 |
-
return
|
| 453 |
-
|
| 454 |
-
found = random.random() < 0.4
|
| 455 |
-
|
| 456 |
-
if found:
|
| 457 |
-
reward = random.randint(30, 150)
|
| 458 |
-
await engagement_cog._add_coins(self.guild_id, self.user_id, reward)
|
| 459 |
-
|
| 460 |
-
embed = discord.Embed(
|
| 461 |
-
title="🎁 Treasure Hunt - Success!",
|
| 462 |
-
description=f"✅ **You found a hidden treasure!**\n\n💰 **+{reward:,} coins**\n\n🗺️ The treasure map led you to riches!",
|
| 463 |
-
color=NEON_GOLD,
|
| 464 |
-
)
|
| 465 |
-
embed.set_image(url="https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=500")
|
| 466 |
-
else:
|
| 467 |
-
embed = discord.Embed(
|
| 468 |
-
title="🎁 Treasure Hunt - Nothing Found",
|
| 469 |
-
description="❌ **No treasure found in this area...**\n\n🗺️ Try exploring a different location next time!",
|
| 470 |
-
color=NEON_RED,
|
| 471 |
-
)
|
| 472 |
-
embed.set_image(url="https://images.unsplash.com/photo-1500353391678-d7b57970d9b2?w=500")
|
| 473 |
-
|
| 474 |
-
if interaction.guild:
|
| 475 |
-
await add_banner_to_embed(embed, interaction.guild)
|
| 476 |
-
|
| 477 |
-
await interaction.response.send_message(embed=embed, ephemeral=True)
|
| 478 |
-
|
| 479 |
-
@discord.ui.button(label="🏪 Shop", emoji=ui("cart"), style=discord.ButtonStyle.blurple, custom_id="rpg_shop")
|
| 480 |
-
async def shop(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 481 |
-
if interaction.user.id != self.user_id:
|
| 482 |
-
return
|
| 483 |
-
|
| 484 |
-
embed = discord.Embed(
|
| 485 |
-
title="🏪 RPG Shop",
|
| 486 |
-
description=(
|
| 487 |
-
"Welcome to the shop! Buy items to help in your adventures.\n\n"
|
| 488 |
-
"🗡️ **Sword** - Increase win chance (Coming Soon)\n"
|
| 489 |
-
"🛡️ **Shield** - Reduce coin loss on defeat (Coming Soon)\n"
|
| 490 |
-
"🧪 **Potion** - Heal after battle (Coming Soon)\n"
|
| 491 |
-
"📜 **Map** - Better treasure finds (Coming Soon)"
|
| 492 |
-
),
|
| 493 |
-
color=NEON_CYAN,
|
| 494 |
-
)
|
| 495 |
-
embed.set_image(url="https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=500")
|
| 496 |
-
|
| 497 |
-
if interaction.guild:
|
| 498 |
-
await add_banner_to_embed(embed, interaction.guild)
|
| 499 |
-
|
| 500 |
-
await interaction.response.send_message(embed=embed, ephemeral=True)
|
| 501 |
-
|
| 502 |
-
@discord.ui.button(label="📊 Stats", emoji=ui("stats"), style=discord.ButtonStyle.secondary, custom_id="rpg_stats")
|
| 503 |
-
async def stats(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 504 |
-
if interaction.user.id != self.user_id:
|
| 505 |
-
return
|
| 506 |
-
|
| 507 |
-
engagement_cog = interaction.
|
| 508 |
-
if not engagement_cog:
|
| 509 |
-
return
|
| 510 |
-
|
| 511 |
-
row = await interaction.
|
| 512 |
-
"SELECT wallet, bank FROM user_balance WHERE guild_id = ? AND user_id = ?",
|
| 513 |
-
self.guild_id, self.user_id,
|
| 514 |
-
)
|
| 515 |
-
wallet, bank = row if row else (0, 0)
|
| 516 |
-
|
| 517 |
-
xp_row = await interaction.
|
| 518 |
-
"SELECT xp, level FROM user_xp WHERE guild_id = ? AND user_id = ?",
|
| 519 |
-
self.guild_id, self.user_id,
|
| 520 |
-
)
|
| 521 |
-
xp, level = xp_row if xp_row else (0, 1)
|
| 522 |
-
|
| 523 |
-
embed = discord.Embed(
|
| 524 |
-
title="📊 RPG Stats",
|
| 525 |
-
description=(
|
| 526 |
-
f"**Level:** {level}\n"
|
| 527 |
-
f"**XP:** {xp:,}\n"
|
| 528 |
-
f"**Wallet:** {wallet:,} coins\n"
|
| 529 |
-
f"**Bank:** {bank:,} coins\n"
|
| 530 |
-
f"**Total Wealth:** {wallet + bank:,} coins"
|
| 531 |
-
),
|
| 532 |
-
color=NEON_PURPLE,
|
| 533 |
-
)
|
| 534 |
-
|
| 535 |
-
if interaction.guild:
|
| 536 |
-
await add_banner_to_embed(embed, interaction.guild)
|
| 537 |
-
|
| 538 |
-
await interaction.response.send_message(embed=embed, ephemeral=True)
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
class GamblingPanelView(discord.ui.View):
|
| 542 |
-
def __init__(self, cog, guild_id: int, user_id: int):
|
| 543 |
-
super().__init__(timeout=None)
|
| 544 |
-
self.cog = cog
|
| 545 |
-
self.guild_id = guild_id
|
| 546 |
-
self.user_id = user_id
|
| 547 |
-
|
| 548 |
-
@discord.ui.button(label="🃏 Play Blackjack", emoji=ui("game"), style=discord.ButtonStyle.danger, custom_id="gambling_blackjack")
|
| 549 |
-
async def play_blackjack(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 550 |
-
if interaction.user.id != self.user_id:
|
| 551 |
-
return
|
| 552 |
-
await interaction.response.send_modal(BlackjackBetModal(self.cog, self.guild_id, self.user_id))
|
| 553 |
-
|
| 554 |
-
@discord.ui.button(label="🎰 Play Roulette", emoji=ui("game"), style=discord.ButtonStyle.danger, custom_id="gambling_roulette")
|
| 555 |
-
async def play_roulette(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 556 |
-
if interaction.user.id != self.user_id:
|
| 557 |
-
return
|
| 558 |
-
view = RouletteGameView(self.cog, self.guild_id, self.user_id)
|
| 559 |
-
embed = view.build_embed()
|
| 560 |
-
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
|
| 561 |
-
|
| 562 |
-
@discord.ui.button(label="⚔️ RPG Adventure", emoji=ui("game"), style=discord.ButtonStyle.success, custom_id="gambling_rpg")
|
| 563 |
-
async def play_rpg(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 564 |
-
if interaction.user.id != self.user_id:
|
| 565 |
-
return
|
| 566 |
-
view = RPGView(self.cog, self.guild_id, self.user_id)
|
| 567 |
-
embed = view.build_embed(
|
| 568 |
-
title="⚔️ RPG Adventure",
|
| 569 |
-
description="Choose your action below to begin your adventure!",
|
| 570 |
-
color=NEON_PURPLE,
|
| 571 |
-
)
|
| 572 |
-
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
class BlackjackBetModal(discord.ui.Modal, title="🃏 Place Your Bet"):
|
| 576 |
-
bet_amount = discord.ui.TextInput(
|
| 577 |
-
label="Bet Amount",
|
| 578 |
-
placeholder="Enter amount (min 10)",
|
| 579 |
-
required=True,
|
| 580 |
-
min_length=1,
|
| 581 |
-
max_length=10,
|
| 582 |
-
)
|
| 583 |
-
|
| 584 |
-
def __init__(self, cog, guild_id: int, user_id: int):
|
| 585 |
-
super().__init__()
|
| 586 |
-
self.cog = cog
|
| 587 |
-
self.guild_id = guild_id
|
| 588 |
-
self.user_id = user_id
|
| 589 |
-
|
| 590 |
-
async def on_submit(self, interaction: discord.Interaction) -> None:
|
| 591 |
-
try:
|
| 592 |
-
bet = int(self.bet_amount.value.strip())
|
| 593 |
-
except ValueError:
|
| 594 |
-
await interaction.response.send_message("❌ Invalid bet amount.", ephemeral=True)
|
| 595 |
-
return
|
| 596 |
-
|
| 597 |
-
if bet < 10:
|
| 598 |
-
await interaction.response.send_message("❌ Minimum bet is 10 coins.", ephemeral=True)
|
| 599 |
-
return
|
| 600 |
-
|
| 601 |
-
engagement_cog = interaction.
|
| 602 |
-
if not engagement_cog:
|
| 603 |
-
await interaction.response.send_message("❌ Economy not available.", ephemeral=True)
|
| 604 |
-
return
|
| 605 |
-
|
| 606 |
-
row = await interaction.
|
| 607 |
-
"SELECT wallet FROM user_balance WHERE guild_id = ? AND user_id = ?",
|
| 608 |
-
self.guild_id, self.user_id,
|
| 609 |
-
)
|
| 610 |
-
wallet = row[0] if row else 0
|
| 611 |
-
|
| 612 |
-
if wallet < bet:
|
| 613 |
-
await interaction.response.send_message(f"❌ Insufficient funds. You have {wallet:,} coins.", ephemeral=True)
|
| 614 |
-
return
|
| 615 |
-
|
| 616 |
-
view = BlackjackView(self.cog, self.guild_id, self.user_id, bet)
|
| 617 |
-
embed = view.build_embed(show_dealer_card=False)
|
| 618 |
-
message = await interaction.channel.send(embed=embed, view=view)
|
| 619 |
-
view.message = message
|
| 620 |
-
await interaction.response.send_message("✅ Blackjack game started!", ephemeral=True)
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
class GamblingPanelView(discord.ui.View):
|
| 624 |
-
def __init__(self, cog, guild_id: int, user_id: int):
|
| 625 |
-
super().__init__(timeout=None)
|
| 626 |
-
self.cog = cog
|
| 627 |
-
self.guild_id = guild_id
|
| 628 |
-
self.user_id = user_id
|
| 629 |
-
|
| 630 |
-
@discord.ui.button(label="🃏 Play Blackjack", emoji=ui("game"), style=discord.ButtonStyle.danger, custom_id="gambling_blackjack")
|
| 631 |
-
async def play_blackjack(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 632 |
-
if interaction.user.id != self.user_id:
|
| 633 |
-
return
|
| 634 |
-
await interaction.response.send_modal(BlackjackBetModal(self.cog, self.guild_id, self.user_id))
|
| 635 |
-
|
| 636 |
-
@discord.ui.button(label="🎰 Play Roulette", emoji=ui("game"), style=discord.ButtonStyle.danger, custom_id="gambling_roulette")
|
| 637 |
-
async def play_roulette(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 638 |
-
if interaction.user.id != self.user_id:
|
| 639 |
-
return
|
| 640 |
-
view = RouletteGameView(self.cog, self.guild_id, self.user_id)
|
| 641 |
-
embed = view.build_embed()
|
| 642 |
-
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
|
| 643 |
-
|
| 644 |
-
@discord.ui.button(label="⚔️ RPG Adventure", emoji=ui("game"), style=discord.ButtonStyle.success, custom_id="gambling_rpg")
|
| 645 |
-
async def play_rpg(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 646 |
-
if interaction.user.id != self.user_id:
|
| 647 |
-
return
|
| 648 |
-
view = RPGView(self.cog, self.guild_id, self.user_id)
|
| 649 |
-
embed = view.build_embed(
|
| 650 |
-
title="⚔️ RPG Adventure",
|
| 651 |
-
description="Choose your action below to begin your adventure!",
|
| 652 |
-
color=NEON_PURPLE,
|
| 653 |
-
)
|
| 654 |
-
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
class BlackjackBetModal(discord.ui.Modal, title="🃏 Place Your Bet"):
|
| 658 |
-
bet_amount = discord.ui.TextInput(
|
| 659 |
-
label="Bet Amount",
|
| 660 |
-
placeholder="Enter amount (min 10)",
|
| 661 |
-
required=True,
|
| 662 |
-
min_length=1,
|
| 663 |
-
max_length=10,
|
| 664 |
-
)
|
| 665 |
-
|
| 666 |
-
def __init__(self, cog, guild_id: int, user_id: int):
|
| 667 |
-
super().__init__()
|
| 668 |
-
self.cog = cog
|
| 669 |
-
self.guild_id = guild_id
|
| 670 |
-
self.user_id = user_id
|
| 671 |
-
|
| 672 |
-
async def on_submit(self, interaction: discord.Interaction) -> None:
|
| 673 |
-
try:
|
| 674 |
-
bet = int(self.bet_amount.value.strip())
|
| 675 |
-
except ValueError:
|
| 676 |
-
await interaction.response.send_message("❌ Invalid bet amount.", ephemeral=True)
|
| 677 |
-
return
|
| 678 |
-
|
| 679 |
-
if bet < 10:
|
| 680 |
-
await interaction.response.send_message("❌ Minimum bet is 10 coins.", ephemeral=True)
|
| 681 |
-
return
|
| 682 |
-
|
| 683 |
-
engagement_cog = interaction.bot.get_cog("Engagement")
|
| 684 |
-
if not engagement_cog:
|
| 685 |
-
await interaction.response.send_message("❌ Economy not available.", ephemeral=True)
|
| 686 |
-
return
|
| 687 |
-
|
| 688 |
-
row = await interaction.bot.db.fetchone(
|
| 689 |
-
"SELECT wallet FROM user_balance WHERE guild_id = ? AND user_id = ?",
|
| 690 |
-
self.guild_id, self.user_id,
|
| 691 |
-
)
|
| 692 |
-
wallet = row[0] if row else 0
|
| 693 |
-
|
| 694 |
-
if wallet < bet:
|
| 695 |
-
await interaction.response.send_message(f"❌ Insufficient funds. You have {wallet:,} coins.", ephemeral=True)
|
| 696 |
-
return
|
| 697 |
-
|
| 698 |
-
view = BlackjackView(self.cog, self.guild_id, self.user_id, bet)
|
| 699 |
-
embed = view.build_embed(show_dealer_card=False)
|
| 700 |
-
message = await interaction.channel.send(embed=embed, view=view)
|
| 701 |
-
view.message = message
|
| 702 |
-
await interaction.response.send_message("✅ Blackjack game started!", ephemeral=True)
|
| 703 |
-
|
| 704 |
-
|
| 705 |
class Gambling(commands.Cog):
|
| 706 |
-
def __init__(self, bot: commands.Bot) -> None:
|
| 707 |
-
self.bot = bot
|
| 708 |
-
|
| 709 |
-
@commands.hybrid_command(name="blackjack", description="Play interactive Blackjack")
|
| 710 |
-
async def blackjack(self, ctx: commands.Context, bet: int) -> None:
|
| 711 |
-
if bet < 10:
|
| 712 |
-
await ctx.send("❌ Minimum bet is 10 coins.", ephemeral=True)
|
| 713 |
-
return
|
| 714 |
-
|
| 715 |
-
engagement_cog = self.bot.get_cog("Engagement")
|
| 716 |
-
if not engagement_cog:
|
| 717 |
-
await ctx.send("❌ Economy not available.", ephemeral=True)
|
| 718 |
-
return
|
| 719 |
-
|
| 720 |
-
row = await self.bot.db.fetchone(
|
| 721 |
-
"SELECT wallet FROM user_balance WHERE guild_id = ? AND user_id = ?",
|
| 722 |
-
ctx.guild.id if ctx.guild else 0, ctx.author.id,
|
| 723 |
-
)
|
| 724 |
-
wallet = row[0] if row else 0
|
| 725 |
-
|
| 726 |
-
if wallet < bet:
|
| 727 |
-
await ctx.send(f"❌ Insufficient funds. You have {wallet:,} coins.", ephemeral=True)
|
| 728 |
-
return
|
| 729 |
-
|
| 730 |
-
view = BlackjackView(self, ctx.guild.id if ctx.guild else 0, ctx.author.id, bet)
|
| 731 |
-
embed = view.build_embed(show_dealer_card=False)
|
| 732 |
-
message = await ctx.send(embed=embed, view=view)
|
| 733 |
-
view.message = message
|
| 734 |
-
|
| 735 |
-
@commands.hybrid_command(name="roulette", description="Play interactive Roulette")
|
| 736 |
-
async def roulette(self, ctx: commands.Context) -> None:
|
| 737 |
-
view = RouletteGameView(self, ctx.guild.id if ctx.guild else 0, ctx.author.id)
|
| 738 |
-
embed = view.build_embed()
|
| 739 |
-
await ctx.send(embed=embed, view=view)
|
| 740 |
-
|
| 741 |
-
@commands.hybrid_command(name="rpg", description="Start an RPG adventure")
|
| 742 |
-
async def rpg(self, ctx: commands.Context) -> None:
|
| 743 |
-
view = RPGView(self, ctx.guild.id if ctx.guild else 0, ctx.author.id)
|
| 744 |
-
embed = view.build_embed(
|
| 745 |
-
title="⚔️ RPG Adventure",
|
| 746 |
-
description="Choose your action below to begin your adventure!",
|
| 747 |
-
color=NEON_PURPLE,
|
| 748 |
-
)
|
| 749 |
-
await ctx.send(embed=embed, view=view, ephemeral=True)
|
| 750 |
-
|
| 751 |
-
@commands.hybrid_command(name="gambling_panel", description="Open the gambling panel")
|
| 752 |
-
async def gambling_panel(self, ctx: commands.Context) -> None:
|
| 753 |
-
view = GamblingPanelView(self, ctx.guild.id if ctx.guild else 0, ctx.author.id)
|
| 754 |
-
embed = discord.Embed(
|
| 755 |
-
title="🎰 Casino & Gambling Panel",
|
| 756 |
-
description=(
|
| 757 |
-
"Welcome to the Casino! Choose a game to play:\n\n"
|
| 758 |
-
"🃏 **Blackjack** - Beat the dealer to 21\n"
|
| 759 |
-
"🎰 **Roulette** - Bet on numbers and colors\n"
|
| 760 |
-
"⚔️ **RPG Adventure** - Fight monsters and find treasure"
|
| 761 |
-
),
|
| 762 |
-
color=NEON_GOLD,
|
| 763 |
-
)
|
| 764 |
-
if ctx.guild:
|
| 765 |
-
from bot.theme import add_banner_to_embed
|
| 766 |
-
await add_banner_to_embed(embed, ctx.guild)
|
| 767 |
-
await ctx.send(embed=embed, view=view, ephemeral=True)
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
async def setup(bot: commands.Bot) -> None:
|
| 771 |
-
await bot.add_cog(Gambling(bot))
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Interactive Gambling & RPG Cog - Casino Games with UI and Full RPG System
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import random
|
| 8 |
+
import asyncio
|
| 9 |
+
from typing import List, Optional, Dict
|
| 10 |
+
|
| 11 |
+
import discord
|
| 12 |
+
from discord.ext import commands
|
| 13 |
+
|
| 14 |
+
from bot.theme import NEON_CYAN, NEON_LIME, NEON_RED, NEON_GOLD, NEON_PURPLE, NEON_PINK, NEON_BLUE, panel_divider, add_banner_to_embed
|
| 15 |
+
from bot.emojis import ui
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Card:
|
| 19 |
+
def __init__(self, suit: str, rank: str):
|
| 20 |
+
self.suit = suit
|
| 21 |
+
self.rank = rank
|
| 22 |
+
self.value = self._get_value()
|
| 23 |
+
|
| 24 |
+
def _get_value(self) -> int:
|
| 25 |
+
if self.rank in ['J', 'Q', 'K']:
|
| 26 |
+
return 10
|
| 27 |
+
elif self.rank == 'A':
|
| 28 |
+
return 11
|
| 29 |
+
else:
|
| 30 |
+
return int(self.rank)
|
| 31 |
+
|
| 32 |
+
def __str__(self) -> str:
|
| 33 |
+
return f"{self.rank}{self.suit}"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class Deck:
|
| 37 |
+
def __init__(self):
|
| 38 |
+
self.cards = []
|
| 39 |
+
suits = ['♠', '♥', '♦', '♣']
|
| 40 |
+
ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']
|
| 41 |
+
for suit in suits:
|
| 42 |
+
for rank in ranks:
|
| 43 |
+
self.cards.append(Card(suit, rank))
|
| 44 |
+
random.shuffle(self.cards)
|
| 45 |
+
|
| 46 |
+
def draw(self) -> Card:
|
| 47 |
+
return self.cards.pop()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class BlackjackView(discord.ui.View):
|
| 51 |
+
def __init__(self, cog, guild_id: int, user_id: int, bet: int):
|
| 52 |
+
super().__init__(timeout=300)
|
| 53 |
+
self.cog = cog
|
| 54 |
+
self.guild_id = guild_id
|
| 55 |
+
self.user_id = user_id
|
| 56 |
+
self.bet = bet
|
| 57 |
+
self.deck = Deck()
|
| 58 |
+
self.player_hand = [self.deck.draw(), self.deck.draw()]
|
| 59 |
+
self.dealer_hand = [self.deck.draw(), self.deck.draw()]
|
| 60 |
+
self.game_over = False
|
| 61 |
+
self.message: Optional[discord.Message] = None
|
| 62 |
+
|
| 63 |
+
def hand_value(self, hand: List[Card]) -> int:
|
| 64 |
+
value = sum(card.value for card in hand)
|
| 65 |
+
aces = sum(1 for card in hand if card.rank == 'A')
|
| 66 |
+
while value > 21 and aces:
|
| 67 |
+
value -= 10
|
| 68 |
+
aces -= 1
|
| 69 |
+
return value
|
| 70 |
+
|
| 71 |
+
def format_hand(self, hand: List[Card], hide_first: bool = False) -> str:
|
| 72 |
+
if hide_first and len(hand) >= 2:
|
| 73 |
+
return f"[❓❓] [{hand[1]}]"
|
| 74 |
+
return ' '.join([f"[{str(card)}]" for card in hand])
|
| 75 |
+
|
| 76 |
+
def build_embed(self, show_dealer_card: bool = True) -> discord.Embed:
|
| 77 |
+
player_value = self.hand_value(self.player_hand)
|
| 78 |
+
dealer_value = self.hand_value(self.dealer_hand) if show_dealer_card else '?'
|
| 79 |
+
|
| 80 |
+
embed = discord.Embed(
|
| 81 |
+
title="🃏 Blackjack Casino",
|
| 82 |
+
description=panel_divider('purple'),
|
| 83 |
+
color=NEON_PURPLE,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
dealer_display = self.format_hand(self.dealer_hand, hide_first=not show_dealer_card)
|
| 87 |
+
embed.add_field(
|
| 88 |
+
name="🎩 Dealer's Hand",
|
| 89 |
+
value=f"{dealer_display}\n**Value:** {dealer_value}",
|
| 90 |
+
inline=False,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
player_display = self.format_hand(self.player_hand)
|
| 94 |
+
embed.add_field(
|
| 95 |
+
name="👤 Your Hand",
|
| 96 |
+
value=f"{player_display}\n**Value:** {player_value}",
|
| 97 |
+
inline=False,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
embed.add_field(name="💰 Bet", value=f"`{self.bet:,}` coins", inline=False)
|
| 101 |
+
|
| 102 |
+
if self.game_over:
|
| 103 |
+
if player_value > 21:
|
| 104 |
+
embed.description = f"❌ **BUST!** You lost `{self.bet:,}` coins."
|
| 105 |
+
embed.color = NEON_RED
|
| 106 |
+
elif dealer_value == 21 and len(self.dealer_hand) == 2:
|
| 107 |
+
embed.description = f"❌ **Dealer Blackjack!** You lost `{self.bet:,}` coins."
|
| 108 |
+
embed.color = NEON_RED
|
| 109 |
+
elif dealer_value > 21:
|
| 110 |
+
winnings = self.bet
|
| 111 |
+
embed.description = f"✅ **Dealer Busts! You Win!** +`{winnings:,}` coins!"
|
| 112 |
+
embed.color = NEON_LIME
|
| 113 |
+
elif player_value > dealer_value:
|
| 114 |
+
winnings = self.bet
|
| 115 |
+
embed.description = f"✅ **You Win!** +`{winnings:,}` coins!"
|
| 116 |
+
embed.color = NEON_LIME
|
| 117 |
+
elif player_value < dealer_value:
|
| 118 |
+
embed.description = f"❌ **Dealer Wins!** You lost `{self.bet:,}` coins."
|
| 119 |
+
embed.color = NEON_RED
|
| 120 |
+
else:
|
| 121 |
+
embed.description = f"🤝 **Push!** Bet returned."
|
| 122 |
+
embed.color = NEON_GOLD
|
| 123 |
+
|
| 124 |
+
return embed
|
| 125 |
+
|
| 126 |
+
async def end_game(self, interaction: discord.Interaction):
|
| 127 |
+
self.game_over = True
|
| 128 |
+
|
| 129 |
+
engagement_cog = interaction.client.get_cog("Engagement")
|
| 130 |
+
if not engagement_cog:
|
| 131 |
+
return
|
| 132 |
+
|
| 133 |
+
player_value = self.hand_value(self.player_hand)
|
| 134 |
+
dealer_value = self.hand_value(self.dealer_hand)
|
| 135 |
+
|
| 136 |
+
if player_value > 21:
|
| 137 |
+
await engagement_cog._remove_coins(self.guild_id, self.user_id, self.bet)
|
| 138 |
+
elif dealer_value == 21 and len(self.dealer_hand) == 2:
|
| 139 |
+
await engagement_cog._remove_coins(self.guild_id, self.user_id, self.bet)
|
| 140 |
+
elif dealer_value > 21 or player_value > dealer_value:
|
| 141 |
+
await engagement_cog._add_coins(self.guild_id, self.user_id, self.bet)
|
| 142 |
+
elif player_value < dealer_value:
|
| 143 |
+
await engagement_cog._remove_coins(self.guild_id, self.user_id, self.bet)
|
| 144 |
+
|
| 145 |
+
embed = self.build_embed(show_dealer_card=True)
|
| 146 |
+
for child in self.children:
|
| 147 |
+
child.disabled = True
|
| 148 |
+
if self.message:
|
| 149 |
+
await self.message.edit(embed=embed, view=self)
|
| 150 |
+
|
| 151 |
+
@discord.ui.button(label="Hit", emoji=ui("plus"), style=discord.ButtonStyle.primary, custom_id="bj_hit")
|
| 152 |
+
async def hit(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 153 |
+
if self.game_over or interaction.user.id != self.user_id:
|
| 154 |
+
await interaction.response.send_message("❌ Not your game.", ephemeral=True)
|
| 155 |
+
return
|
| 156 |
+
|
| 157 |
+
self.player_hand.append(self.deck.draw())
|
| 158 |
+
player_value = self.hand_value(self.player_hand)
|
| 159 |
+
|
| 160 |
+
if player_value > 21:
|
| 161 |
+
await self.end_game(interaction)
|
| 162 |
+
else:
|
| 163 |
+
embed = self.build_embed(show_dealer_card=False)
|
| 164 |
+
await interaction.response.edit_message(embed=embed, view=self)
|
| 165 |
+
|
| 166 |
+
@discord.ui.button(label="Stand", emoji=ui("stop"), style=discord.ButtonStyle.danger, custom_id="bj_stand")
|
| 167 |
+
async def stand(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 168 |
+
if self.game_over or interaction.user.id != self.user_id:
|
| 169 |
+
await interaction.response.send_message("❌ Not your game.", ephemeral=True)
|
| 170 |
+
return
|
| 171 |
+
|
| 172 |
+
while self.hand_value(self.dealer_hand) < 17:
|
| 173 |
+
self.dealer_hand.append(self.deck.draw())
|
| 174 |
+
|
| 175 |
+
await self.end_game(interaction)
|
| 176 |
+
|
| 177 |
+
@discord.ui.button(label="Double Down", emoji=ui("star"), style=discord.ButtonStyle.success, custom_id="bj_double")
|
| 178 |
+
async def double_down(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 179 |
+
if self.game_over or interaction.user.id != self.user_id:
|
| 180 |
+
await interaction.response.send_message("❌ Not your game.", ephemeral=True)
|
| 181 |
+
return
|
| 182 |
+
|
| 183 |
+
if len(self.player_hand) != 2:
|
| 184 |
+
await interaction.response.send_message("❌ Can only double on first turn.", ephemeral=True)
|
| 185 |
+
return
|
| 186 |
+
|
| 187 |
+
engagement_cog = interaction.client.get_cog("Engagement")
|
| 188 |
+
if not engagement_cog:
|
| 189 |
+
return
|
| 190 |
+
|
| 191 |
+
row = await interaction.client.db.fetchone(
|
| 192 |
+
"SELECT wallet FROM user_balance WHERE guild_id = ? AND user_id = ?",
|
| 193 |
+
self.guild_id, self.user_id,
|
| 194 |
+
)
|
| 195 |
+
wallet = row[0] if row else 0
|
| 196 |
+
|
| 197 |
+
if wallet < self.bet:
|
| 198 |
+
await interaction.response.send_message("❌ Insufficient funds to double.", ephemeral=True)
|
| 199 |
+
return
|
| 200 |
+
|
| 201 |
+
self.bet *= 2
|
| 202 |
+
self.player_hand.append(self.deck.draw())
|
| 203 |
+
|
| 204 |
+
player_value = self.hand_value(self.player_hand)
|
| 205 |
+
if player_value > 21:
|
| 206 |
+
await self.end_game(interaction)
|
| 207 |
+
else:
|
| 208 |
+
while self.hand_value(self.dealer_hand) < 17:
|
| 209 |
+
self.dealer_hand.append(self.deck.draw())
|
| 210 |
+
await self.end_game(interaction)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
class RouletteGameView(discord.ui.View):
|
| 214 |
+
def __init__(self, cog, guild_id: int, user_id: int):
|
| 215 |
+
super().__init__(timeout=300)
|
| 216 |
+
self.cog = cog
|
| 217 |
+
self.guild_id = guild_id
|
| 218 |
+
self.user_id = user_id
|
| 219 |
+
self.bet_amount = 0
|
| 220 |
+
self.bet_type = ""
|
| 221 |
+
self.spinning = False
|
| 222 |
+
|
| 223 |
+
RED_NUMBERS = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]
|
| 224 |
+
BLACK_NUMBERS = [2, 4, 6, 8, 10, 11, 13, 15, 17, 20, 22, 24, 26, 28, 29, 31, 33, 35]
|
| 225 |
+
|
| 226 |
+
def get_color(self, number: int) -> str:
|
| 227 |
+
if number == 0:
|
| 228 |
+
return "<:animatedarrowgreen:1477261279428087979>"
|
| 229 |
+
elif number in self.RED_NUMBERS:
|
| 230 |
+
return "🔴"
|
| 231 |
+
else:
|
| 232 |
+
return "⚫"
|
| 233 |
+
|
| 234 |
+
def build_embed(self, number: Optional[int] = None, won: bool = False, winnings: int = 0) -> discord.Embed:
|
| 235 |
+
embed = discord.Embed(
|
| 236 |
+
title="🎰 Roulette Casino",
|
| 237 |
+
description=panel_divider('gold'),
|
| 238 |
+
color=NEON_GOLD,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
if number is not None:
|
| 242 |
+
color_emoji = self.get_color(number)
|
| 243 |
+
embed.add_field(name="🎯 Result", value=f"{color_emoji} **{number}**", inline=False)
|
| 244 |
+
embed.add_field(name="💰 Bet", value=f"`{self.bet_amount:,}` on {self.bet_type}", inline=True)
|
| 245 |
+
embed.add_field(name="💵 Result", value=f"`{'+' if won else '-'}{abs(winnings):,}` coins", inline=True)
|
| 246 |
+
|
| 247 |
+
if won:
|
| 248 |
+
embed.description = f"✅ **You Won!** +`{winnings:,}` coins!"
|
| 249 |
+
embed.color = NEON_LIME
|
| 250 |
+
else:
|
| 251 |
+
embed.description = f"❌ **You Lost!** -`{abs(winnings):,}` coins."
|
| 252 |
+
embed.color = NEON_RED
|
| 253 |
+
else:
|
| 254 |
+
embed.description = "Place your bet and spin the wheel!"
|
| 255 |
+
grid = ""
|
| 256 |
+
for i in range(0, 37, 3):
|
| 257 |
+
if i == 0:
|
| 258 |
+
grid += "<:animatedarrowgreen:1477261279428087979> **0** "
|
| 259 |
+
else:
|
| 260 |
+
c1 = "🔴" if i in self.RED_NUMBERS else "��"
|
| 261 |
+
c2 = "🔴" if (i+1) in self.RED_NUMBERS else "⚫"
|
| 262 |
+
c3 = "🔴" if (i+2) in self.RED_NUMBERS else "⚫"
|
| 263 |
+
grid += f"{c1} {i} {c2} {i+1} {c3} {i+2}\n"
|
| 264 |
+
embed.add_field(name="🎡 Roulette Wheel", value=f"```\n{grid}```", inline=False)
|
| 265 |
+
|
| 266 |
+
return embed
|
| 267 |
+
|
| 268 |
+
@discord.ui.button(label="Bet Red 🔴", style=discord.ButtonStyle.danger, custom_id="roulette_red")
|
| 269 |
+
async def bet_red(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 270 |
+
await self.prompt_bet(interaction, "red")
|
| 271 |
+
|
| 272 |
+
@discord.ui.button(label="Bet Black ⚫", style=discord.ButtonStyle.gray, custom_id="roulette_black")
|
| 273 |
+
async def bet_black(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 274 |
+
await self.prompt_bet(interaction, "black")
|
| 275 |
+
|
| 276 |
+
@discord.ui.button(label="Bet Even", style=discord.ButtonStyle.primary, custom_id="roulette_even")
|
| 277 |
+
async def bet_even(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 278 |
+
await self.prompt_bet(interaction, "even")
|
| 279 |
+
|
| 280 |
+
@discord.ui.button(label="Bet Odd", style=discord.ButtonStyle.primary, custom_id="roulette_odd")
|
| 281 |
+
async def bet_odd(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 282 |
+
await self.prompt_bet(interaction, "odd")
|
| 283 |
+
|
| 284 |
+
async def prompt_bet(self, interaction: discord.Interaction, bet_type: str):
|
| 285 |
+
if self.user_id and interaction.user.id != self.user_id:
|
| 286 |
+
return
|
| 287 |
+
await interaction.response.send_modal(RouletteBetModal(self.cog, self.guild_id, self.user_id, bet_type))
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
class RouletteBetModal(discord.ui.Modal, title="🎰 Place Your Bet"):
|
| 291 |
+
bet_amount = discord.ui.TextInput(
|
| 292 |
+
label="Bet Amount",
|
| 293 |
+
placeholder="Enter amount (min 10)",
|
| 294 |
+
required=True,
|
| 295 |
+
min_length=1,
|
| 296 |
+
max_length=10,
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
def __init__(self, cog, guild_id: int, user_id: int, bet_type: str):
|
| 300 |
+
super().__init__()
|
| 301 |
+
self.cog = cog
|
| 302 |
+
self.guild_id = guild_id
|
| 303 |
+
self.user_id = user_id
|
| 304 |
+
self.bet_type = bet_type
|
| 305 |
+
|
| 306 |
+
async def on_submit(self, interaction: discord.Interaction) -> None:
|
| 307 |
+
try:
|
| 308 |
+
bet = int(self.bet_amount.value.strip())
|
| 309 |
+
except ValueError:
|
| 310 |
+
await interaction.response.send_message("❌ Invalid bet amount.", ephemeral=True)
|
| 311 |
+
return
|
| 312 |
+
|
| 313 |
+
if bet < 10:
|
| 314 |
+
await interaction.response.send_message("❌ Minimum bet is 10 coins.", ephemeral=True)
|
| 315 |
+
return
|
| 316 |
+
|
| 317 |
+
engagement_cog = interaction.client.get_cog("Engagement")
|
| 318 |
+
if not engagement_cog:
|
| 319 |
+
await interaction.response.send_message("❌ Economy not available.", ephemeral=True)
|
| 320 |
+
return
|
| 321 |
+
|
| 322 |
+
row = await interaction.client.db.fetchone(
|
| 323 |
+
"SELECT wallet FROM user_balance WHERE guild_id = ? AND user_id = ?",
|
| 324 |
+
self.guild_id, self.user_id,
|
| 325 |
+
)
|
| 326 |
+
wallet = row[0] if row else 0
|
| 327 |
+
|
| 328 |
+
if wallet < bet:
|
| 329 |
+
await interaction.response.send_message(f"❌ Insufficient funds. You have {wallet:,} coins.", ephemeral=True)
|
| 330 |
+
return
|
| 331 |
+
|
| 332 |
+
await interaction.response.defer()
|
| 333 |
+
|
| 334 |
+
number = random.randint(0, 36)
|
| 335 |
+
is_red = number in RouletteGameView.RED_NUMBERS
|
| 336 |
+
is_black = number in RouletteGameView.BLACK_NUMBERS
|
| 337 |
+
is_even = number != 0 and number % 2 == 0
|
| 338 |
+
is_odd = number != 0 and number % 2 == 1
|
| 339 |
+
|
| 340 |
+
won = False
|
| 341 |
+
multiplier = 2
|
| 342 |
+
|
| 343 |
+
if self.bet_type == 'red' and is_red:
|
| 344 |
+
won = True
|
| 345 |
+
elif self.bet_type == 'black' and is_black:
|
| 346 |
+
won = True
|
| 347 |
+
elif self.bet_type == 'even' and is_even:
|
| 348 |
+
won = True
|
| 349 |
+
elif self.bet_type == 'odd' and is_odd:
|
| 350 |
+
won = True
|
| 351 |
+
|
| 352 |
+
winnings = bet * multiplier if won else -bet
|
| 353 |
+
|
| 354 |
+
if won:
|
| 355 |
+
await engagement_cog._add_coins(self.guild_id, self.user_id, winnings)
|
| 356 |
+
else:
|
| 357 |
+
await engagement_cog._remove_coins(self.guild_id, self.user_id, bet)
|
| 358 |
+
|
| 359 |
+
view = RouletteGameView(self.cog, self.guild_id, self.user_id)
|
| 360 |
+
view.bet_amount = bet
|
| 361 |
+
view.bet_type = self.bet_type
|
| 362 |
+
embed = view.build_embed(number=number, won=won, winnings=winnings)
|
| 363 |
+
|
| 364 |
+
for child in view.children:
|
| 365 |
+
child.disabled = False
|
| 366 |
+
|
| 367 |
+
await interaction.followup.send(embed=embed, view=view)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
class RPGView(discord.ui.View):
|
| 371 |
+
def __init__(self, cog, guild_id: int, user_id: int):
|
| 372 |
+
super().__init__(timeout=None)
|
| 373 |
+
self.cog = cog
|
| 374 |
+
self.guild_id = guild_id
|
| 375 |
+
self.user_id = user_id
|
| 376 |
+
self.adventure_image = "https://images.unsplash.com/photo-1519074069444-1ba4fff66d16?w=500"
|
| 377 |
+
|
| 378 |
+
def build_embed(self, title: str, description: str, color: discord.Color) -> discord.Embed:
|
| 379 |
+
embed = discord.Embed(
|
| 380 |
+
title=title,
|
| 381 |
+
description=description,
|
| 382 |
+
color=color,
|
| 383 |
+
)
|
| 384 |
+
embed.set_image(url=self.adventure_image)
|
| 385 |
+
return embed
|
| 386 |
+
|
| 387 |
+
@discord.ui.button(label="⚔️ Fight Monster", emoji=ui("game"), style=discord.ButtonStyle.danger, custom_id="rpg_fight")
|
| 388 |
+
async def fight_monster(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 389 |
+
if self.user_id and interaction.user.id != self.user_id:
|
| 390 |
+
return
|
| 391 |
+
|
| 392 |
+
engagement_cog = interaction.client.get_cog("Engagement")
|
| 393 |
+
if not engagement_cog:
|
| 394 |
+
await interaction.response.send_message("❌ Economy not available.", ephemeral=True)
|
| 395 |
+
return
|
| 396 |
+
|
| 397 |
+
row = await interaction.client.db.fetchone(
|
| 398 |
+
"SELECT level FROM user_xp WHERE guild_id = ? AND user_id = ?",
|
| 399 |
+
self.guild_id, self.user_id,
|
| 400 |
+
)
|
| 401 |
+
level = row[0] if row else 1
|
| 402 |
+
|
| 403 |
+
monsters = [
|
| 404 |
+
("Goblin", 50, 100, 20, 40),
|
| 405 |
+
("Dragon", 200, 500, 50, 100),
|
| 406 |
+
("Skeleton", 30, 80, 15, 30),
|
| 407 |
+
("Orc", 80, 200, 25, 50),
|
| 408 |
+
("Troll", 150, 350, 40, 80),
|
| 409 |
+
("Demon", 300, 600, 60, 120),
|
| 410 |
+
]
|
| 411 |
+
|
| 412 |
+
monster_name, min_reward, max_reward, min_xp, max_xp = random.choice(monsters)
|
| 413 |
+
win_chance = min(0.8, 0.3 + (level * 0.05))
|
| 414 |
+
won = random.random() < win_chance
|
| 415 |
+
|
| 416 |
+
if won:
|
| 417 |
+
reward = random.randint(min_reward, max_reward) + (level * 10)
|
| 418 |
+
xp_gain = random.randint(min_xp, max_xp)
|
| 419 |
+
await engagement_cog._add_coins(self.guild_id, self.user_id, reward)
|
| 420 |
+
await engagement_cog.add_xp(self.guild_id, self.user_id, xp_gain)
|
| 421 |
+
|
| 422 |
+
embed = discord.Embed(
|
| 423 |
+
title="⚔️ RPG Adventure - Victory!",
|
| 424 |
+
description=f"✅ **You defeated the {monster_name}!**\n\n💰 **+{reward:,} coins**\n⭐ **+{xp_gain} XP**\n📈 Level {level} → {level + (1 if xp_gain > 40 else 0)}",
|
| 425 |
+
color=NEON_LIME,
|
| 426 |
+
)
|
| 427 |
+
embed.set_image(url="https://images.unsplash.com/photo-1535905557558-afc4877a26fc?w=500")
|
| 428 |
+
else:
|
| 429 |
+
penalty = random.randint(10, 50) + (level * 5)
|
| 430 |
+
await engagement_cog._remove_coins(self.guild_id, self.user_id, penalty)
|
| 431 |
+
|
| 432 |
+
embed = discord.Embed(
|
| 433 |
+
title="⚔️ RPG Adventure - Defeat!",
|
| 434 |
+
description=f"❌ **The {monster_name} defeated you!**\n\n💀 **-{penalty:,} coins**\n\n💡 Tip: Level up to increase win chance!",
|
| 435 |
+
color=NEON_RED,
|
| 436 |
+
)
|
| 437 |
+
embed.set_image(url="https://images.unsplash.com/photo-1519791883288-dc8bd696e667?w=500")
|
| 438 |
+
|
| 439 |
+
if interaction.guild:
|
| 440 |
+
await add_banner_to_embed(embed, interaction.guild)
|
| 441 |
+
|
| 442 |
+
await interaction.response.send_message(embed=embed, ephemeral=True)
|
| 443 |
+
|
| 444 |
+
@discord.ui.button(label="🎁 Find Treasure", emoji=ui("gift"), style=discord.ButtonStyle.success, custom_id="rpg_treasure")
|
| 445 |
+
async def find_treasure(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 446 |
+
if self.user_id and interaction.user.id != self.user_id:
|
| 447 |
+
return
|
| 448 |
+
|
| 449 |
+
engagement_cog = interaction.client.get_cog("Engagement")
|
| 450 |
+
if not engagement_cog:
|
| 451 |
+
await interaction.response.send_message("❌ Economy not available.", ephemeral=True)
|
| 452 |
+
return
|
| 453 |
+
|
| 454 |
+
found = random.random() < 0.4
|
| 455 |
+
|
| 456 |
+
if found:
|
| 457 |
+
reward = random.randint(30, 150)
|
| 458 |
+
await engagement_cog._add_coins(self.guild_id, self.user_id, reward)
|
| 459 |
+
|
| 460 |
+
embed = discord.Embed(
|
| 461 |
+
title="🎁 Treasure Hunt - Success!",
|
| 462 |
+
description=f"✅ **You found a hidden treasure!**\n\n💰 **+{reward:,} coins**\n\n🗺️ The treasure map led you to riches!",
|
| 463 |
+
color=NEON_GOLD,
|
| 464 |
+
)
|
| 465 |
+
embed.set_image(url="https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=500")
|
| 466 |
+
else:
|
| 467 |
+
embed = discord.Embed(
|
| 468 |
+
title="🎁 Treasure Hunt - Nothing Found",
|
| 469 |
+
description="❌ **No treasure found in this area...**\n\n🗺️ Try exploring a different location next time!",
|
| 470 |
+
color=NEON_RED,
|
| 471 |
+
)
|
| 472 |
+
embed.set_image(url="https://images.unsplash.com/photo-1500353391678-d7b57970d9b2?w=500")
|
| 473 |
+
|
| 474 |
+
if interaction.guild:
|
| 475 |
+
await add_banner_to_embed(embed, interaction.guild)
|
| 476 |
+
|
| 477 |
+
await interaction.response.send_message(embed=embed, ephemeral=True)
|
| 478 |
+
|
| 479 |
+
@discord.ui.button(label="🏪 Shop", emoji=ui("cart"), style=discord.ButtonStyle.blurple, custom_id="rpg_shop")
|
| 480 |
+
async def shop(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 481 |
+
if interaction.user.id != self.user_id:
|
| 482 |
+
return
|
| 483 |
+
|
| 484 |
+
embed = discord.Embed(
|
| 485 |
+
title="🏪 RPG Shop",
|
| 486 |
+
description=(
|
| 487 |
+
"Welcome to the shop! Buy items to help in your adventures.\n\n"
|
| 488 |
+
"🗡️ **Sword** - Increase win chance (Coming Soon)\n"
|
| 489 |
+
"🛡️ **Shield** - Reduce coin loss on defeat (Coming Soon)\n"
|
| 490 |
+
"🧪 **Potion** - Heal after battle (Coming Soon)\n"
|
| 491 |
+
"📜 **Map** - Better treasure finds (Coming Soon)"
|
| 492 |
+
),
|
| 493 |
+
color=NEON_CYAN,
|
| 494 |
+
)
|
| 495 |
+
embed.set_image(url="https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=500")
|
| 496 |
+
|
| 497 |
+
if interaction.guild:
|
| 498 |
+
await add_banner_to_embed(embed, interaction.guild)
|
| 499 |
+
|
| 500 |
+
await interaction.response.send_message(embed=embed, ephemeral=True)
|
| 501 |
+
|
| 502 |
+
@discord.ui.button(label="📊 Stats", emoji=ui("stats"), style=discord.ButtonStyle.secondary, custom_id="rpg_stats")
|
| 503 |
+
async def stats(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 504 |
+
if interaction.user.id != self.user_id:
|
| 505 |
+
return
|
| 506 |
+
|
| 507 |
+
engagement_cog = interaction.client.get_cog("Engagement")
|
| 508 |
+
if not engagement_cog:
|
| 509 |
+
return
|
| 510 |
+
|
| 511 |
+
row = await interaction.client.db.fetchone(
|
| 512 |
+
"SELECT wallet, bank FROM user_balance WHERE guild_id = ? AND user_id = ?",
|
| 513 |
+
self.guild_id, self.user_id,
|
| 514 |
+
)
|
| 515 |
+
wallet, bank = row if row else (0, 0)
|
| 516 |
+
|
| 517 |
+
xp_row = await interaction.client.db.fetchone(
|
| 518 |
+
"SELECT xp, level FROM user_xp WHERE guild_id = ? AND user_id = ?",
|
| 519 |
+
self.guild_id, self.user_id,
|
| 520 |
+
)
|
| 521 |
+
xp, level = xp_row if xp_row else (0, 1)
|
| 522 |
+
|
| 523 |
+
embed = discord.Embed(
|
| 524 |
+
title="📊 RPG Stats",
|
| 525 |
+
description=(
|
| 526 |
+
f"**Level:** {level}\n"
|
| 527 |
+
f"**XP:** {xp:,}\n"
|
| 528 |
+
f"**Wallet:** {wallet:,} coins\n"
|
| 529 |
+
f"**Bank:** {bank:,} coins\n"
|
| 530 |
+
f"**Total Wealth:** {wallet + bank:,} coins"
|
| 531 |
+
),
|
| 532 |
+
color=NEON_PURPLE,
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
if interaction.guild:
|
| 536 |
+
await add_banner_to_embed(embed, interaction.guild)
|
| 537 |
+
|
| 538 |
+
await interaction.response.send_message(embed=embed, ephemeral=True)
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
class GamblingPanelView(discord.ui.View):
|
| 542 |
+
def __init__(self, cog, guild_id: int, user_id: int):
|
| 543 |
+
super().__init__(timeout=None)
|
| 544 |
+
self.cog = cog
|
| 545 |
+
self.guild_id = guild_id
|
| 546 |
+
self.user_id = user_id
|
| 547 |
+
|
| 548 |
+
@discord.ui.button(label="🃏 Play Blackjack", emoji=ui("game"), style=discord.ButtonStyle.danger, custom_id="gambling_blackjack")
|
| 549 |
+
async def play_blackjack(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 550 |
+
if interaction.user.id != self.user_id:
|
| 551 |
+
return
|
| 552 |
+
await interaction.response.send_modal(BlackjackBetModal(self.cog, self.guild_id, self.user_id))
|
| 553 |
+
|
| 554 |
+
@discord.ui.button(label="🎰 Play Roulette", emoji=ui("game"), style=discord.ButtonStyle.danger, custom_id="gambling_roulette")
|
| 555 |
+
async def play_roulette(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 556 |
+
if interaction.user.id != self.user_id:
|
| 557 |
+
return
|
| 558 |
+
view = RouletteGameView(self.cog, self.guild_id, self.user_id)
|
| 559 |
+
embed = view.build_embed()
|
| 560 |
+
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
|
| 561 |
+
|
| 562 |
+
@discord.ui.button(label="⚔️ RPG Adventure", emoji=ui("game"), style=discord.ButtonStyle.success, custom_id="gambling_rpg")
|
| 563 |
+
async def play_rpg(self, interaction: discord.Interaction, button: discord.ui.Button):
|
| 564 |
+
if interaction.user.id != self.user_id:
|
| 565 |
+
return
|
| 566 |
+
view = RPGView(self.cog, self.guild_id, self.user_id)
|
| 567 |
+
embed = view.build_embed(
|
| 568 |
+
title="⚔️ RPG Adventure",
|
| 569 |
+
description="Choose your action below to begin your adventure!",
|
| 570 |
+
color=NEON_PURPLE,
|
| 571 |
+
)
|
| 572 |
+
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
class BlackjackBetModal(discord.ui.Modal, title="🃏 Place Your Bet"):
|
| 576 |
+
bet_amount = discord.ui.TextInput(
|
| 577 |
+
label="Bet Amount",
|
| 578 |
+
placeholder="Enter amount (min 10)",
|
| 579 |
+
required=True,
|
| 580 |
+
min_length=1,
|
| 581 |
+
max_length=10,
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
def __init__(self, cog, guild_id: int, user_id: int):
|
| 585 |
+
super().__init__()
|
| 586 |
+
self.cog = cog
|
| 587 |
+
self.guild_id = guild_id
|
| 588 |
+
self.user_id = user_id
|
| 589 |
+
|
| 590 |
+
async def on_submit(self, interaction: discord.Interaction) -> None:
|
| 591 |
+
try:
|
| 592 |
+
bet = int(self.bet_amount.value.strip())
|
| 593 |
+
except ValueError:
|
| 594 |
+
await interaction.response.send_message("❌ Invalid bet amount.", ephemeral=True)
|
| 595 |
+
return
|
| 596 |
+
|
| 597 |
+
if bet < 10:
|
| 598 |
+
await interaction.response.send_message("❌ Minimum bet is 10 coins.", ephemeral=True)
|
| 599 |
+
return
|
| 600 |
+
|
| 601 |
+
engagement_cog = interaction.client.get_cog("Engagement")
|
| 602 |
+
if not engagement_cog:
|
| 603 |
+
await interaction.response.send_message("❌ Economy not available.", ephemeral=True)
|
| 604 |
+
return
|
| 605 |
+
|
| 606 |
+
row = await interaction.client.db.fetchone(
|
| 607 |
+
"SELECT wallet FROM user_balance WHERE guild_id = ? AND user_id = ?",
|
| 608 |
+
self.guild_id, self.user_id,
|
| 609 |
+
)
|
| 610 |
+
wallet = row[0] if row else 0
|
| 611 |
+
|
| 612 |
+
if wallet < bet:
|
| 613 |
+
await interaction.response.send_message(f"❌ Insufficient funds. You have {wallet:,} coins.", ephemeral=True)
|
| 614 |
+
return
|
| 615 |
+
|
| 616 |
+
view = BlackjackView(self.cog, self.guild_id, self.user_id, bet)
|
| 617 |
+
embed = view.build_embed(show_dealer_card=False)
|
| 618 |
+
message = await interaction.channel.send(embed=embed, view=view)
|
| 619 |
+
view.message = message
|
| 620 |
+
await interaction.response.send_message("✅ Blackjack game started!", ephemeral=True)
|
| 621 |
+
|
| 622 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
class Gambling(commands.Cog):
|
| 624 |
+
def __init__(self, bot: commands.Bot) -> None:
|
| 625 |
+
self.bot = bot
|
| 626 |
+
|
| 627 |
+
@commands.hybrid_command(name="blackjack", description="Play interactive Blackjack")
|
| 628 |
+
async def blackjack(self, ctx: commands.Context, bet: int) -> None:
|
| 629 |
+
if bet < 10:
|
| 630 |
+
await ctx.send("❌ Minimum bet is 10 coins.", ephemeral=True)
|
| 631 |
+
return
|
| 632 |
+
|
| 633 |
+
engagement_cog = self.bot.get_cog("Engagement")
|
| 634 |
+
if not engagement_cog:
|
| 635 |
+
await ctx.send("❌ Economy not available.", ephemeral=True)
|
| 636 |
+
return
|
| 637 |
+
|
| 638 |
+
row = await self.bot.db.fetchone(
|
| 639 |
+
"SELECT wallet FROM user_balance WHERE guild_id = ? AND user_id = ?",
|
| 640 |
+
ctx.guild.id if ctx.guild else 0, ctx.author.id,
|
| 641 |
+
)
|
| 642 |
+
wallet = row[0] if row else 0
|
| 643 |
+
|
| 644 |
+
if wallet < bet:
|
| 645 |
+
await ctx.send(f"❌ Insufficient funds. You have {wallet:,} coins.", ephemeral=True)
|
| 646 |
+
return
|
| 647 |
+
|
| 648 |
+
view = BlackjackView(self, ctx.guild.id if ctx.guild else 0, ctx.author.id, bet)
|
| 649 |
+
embed = view.build_embed(show_dealer_card=False)
|
| 650 |
+
message = await ctx.send(embed=embed, view=view)
|
| 651 |
+
view.message = message
|
| 652 |
+
|
| 653 |
+
@commands.hybrid_command(name="roulette", description="Play interactive Roulette")
|
| 654 |
+
async def roulette(self, ctx: commands.Context) -> None:
|
| 655 |
+
view = RouletteGameView(self, ctx.guild.id if ctx.guild else 0, ctx.author.id)
|
| 656 |
+
embed = view.build_embed()
|
| 657 |
+
await ctx.send(embed=embed, view=view)
|
| 658 |
+
|
| 659 |
+
@commands.hybrid_command(name="rpg", description="Start an RPG adventure")
|
| 660 |
+
async def rpg(self, ctx: commands.Context) -> None:
|
| 661 |
+
view = RPGView(self, ctx.guild.id if ctx.guild else 0, ctx.author.id)
|
| 662 |
+
embed = view.build_embed(
|
| 663 |
+
title="⚔️ RPG Adventure",
|
| 664 |
+
description="Choose your action below to begin your adventure!",
|
| 665 |
+
color=NEON_PURPLE,
|
| 666 |
+
)
|
| 667 |
+
await ctx.send(embed=embed, view=view, ephemeral=True)
|
| 668 |
+
|
| 669 |
+
@commands.hybrid_command(name="gambling_panel", description="Open the gambling panel")
|
| 670 |
+
async def gambling_panel(self, ctx: commands.Context) -> None:
|
| 671 |
+
view = GamblingPanelView(self, ctx.guild.id if ctx.guild else 0, ctx.author.id)
|
| 672 |
+
embed = discord.Embed(
|
| 673 |
+
title="🎰 Casino & Gambling Panel",
|
| 674 |
+
description=(
|
| 675 |
+
"Welcome to the Casino! Choose a game to play:\n\n"
|
| 676 |
+
"🃏 **Blackjack** - Beat the dealer to 21\n"
|
| 677 |
+
"🎰 **Roulette** - Bet on numbers and colors\n"
|
| 678 |
+
"⚔️ **RPG Adventure** - Fight monsters and find treasure"
|
| 679 |
+
),
|
| 680 |
+
color=NEON_GOLD,
|
| 681 |
+
)
|
| 682 |
+
if ctx.guild:
|
| 683 |
+
from bot.theme import add_banner_to_embed
|
| 684 |
+
await add_banner_to_embed(embed, ctx.guild)
|
| 685 |
+
await ctx.send(embed=embed, view=view, ephemeral=True)
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
async def setup(bot: commands.Bot) -> None:
|
| 689 |
+
await bot.add_cog(Gambling(bot))
|
bot/cogs/language.py
CHANGED
|
@@ -23,6 +23,7 @@ LANGUAGE_META: dict[str, tuple[str, str]] = {
|
|
| 23 |
"id": ("Indonesia", "🇮🇩"),
|
| 24 |
"ja": ("日本語", "🇯🇵"),
|
| 25 |
"zh": ("中文", "🇨🇳"),
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
|
|
@@ -32,7 +33,10 @@ class LanguageSelect(discord.ui.Select):
|
|
| 32 |
self.guild_id = guild_id
|
| 33 |
|
| 34 |
options: list[discord.SelectOption] = []
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
| 36 |
label, emoji = LANGUAGE_META.get(code, (code.upper(), "🌐"))
|
| 37 |
options.append(
|
| 38 |
discord.SelectOption(
|
|
@@ -58,6 +62,17 @@ class LanguageSelect(discord.ui.Select):
|
|
| 58 |
return
|
| 59 |
|
| 60 |
selected = self.values[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
await self.cog.bot.db.execute(
|
| 62 |
"INSERT INTO guild_config(guild_id, guild_language) VALUES (?, ?) "
|
| 63 |
"ON CONFLICT(guild_id) DO UPDATE SET guild_language = excluded.guild_language",
|
|
@@ -98,14 +113,18 @@ class Language(commands.Cog):
|
|
| 98 |
|
| 99 |
async def _current_code(self, guild_id: int) -> str:
|
| 100 |
row = await self.bot.db.fetchone("SELECT guild_language FROM guild_config WHERE guild_id = ?", guild_id)
|
| 101 |
-
|
|
|
|
| 102 |
|
| 103 |
async def _language_embed(self, guild_id: int, current_code: str) -> discord.Embed:
|
| 104 |
current_name = LANGUAGE_META.get(current_code, (current_code.upper(), "🌐"))[0]
|
| 105 |
current_emoji = LANGUAGE_META.get(current_code, (current_code.upper(), "🌐"))[1]
|
| 106 |
|
| 107 |
lines = []
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
| 109 |
name, emoji = LANGUAGE_META.get(code, (code.upper(), "🌐"))
|
| 110 |
marker = "✅" if code == current_code else "▫️"
|
| 111 |
lines.append(f"{marker} {emoji} **{name}** `({code})`")
|
|
|
|
| 23 |
"id": ("Indonesia", "🇮🇩"),
|
| 24 |
"ja": ("日本語", "🇯🇵"),
|
| 25 |
"zh": ("中文", "🇨🇳"),
|
| 26 |
+
"he": ("עברית", "🇮🇱"),
|
| 27 |
}
|
| 28 |
|
| 29 |
|
|
|
|
| 33 |
self.guild_id = guild_id
|
| 34 |
|
| 35 |
options: list[discord.SelectOption] = []
|
| 36 |
+
dynamic_supported = sorted(
|
| 37 |
+
getattr(getattr(cog.bot, "translator", None), "supported_languages", set(SUPPORTED_LANGUAGES))
|
| 38 |
+
)
|
| 39 |
+
for code in dynamic_supported:
|
| 40 |
label, emoji = LANGUAGE_META.get(code, (code.upper(), "🌐"))
|
| 41 |
options.append(
|
| 42 |
discord.SelectOption(
|
|
|
|
| 62 |
return
|
| 63 |
|
| 64 |
selected = self.values[0]
|
| 65 |
+
|
| 66 |
+
# Special rule requested by owner: Hebrew is visible but cannot be set.
|
| 67 |
+
if selected == "he":
|
| 68 |
+
current_code = await self.cog._current_code(interaction.guild.id)
|
| 69 |
+
for option in self.options:
|
| 70 |
+
option.default = option.value == current_code
|
| 71 |
+
embed = await self.cog._language_embed(interaction.guild.id, current_code)
|
| 72 |
+
await interaction.response.edit_message(embed=embed, view=self.view)
|
| 73 |
+
await interaction.followup.send("only you know this language", ephemeral=True)
|
| 74 |
+
return
|
| 75 |
+
|
| 76 |
await self.cog.bot.db.execute(
|
| 77 |
"INSERT INTO guild_config(guild_id, guild_language) VALUES (?, ?) "
|
| 78 |
"ON CONFLICT(guild_id) DO UPDATE SET guild_language = excluded.guild_language",
|
|
|
|
| 113 |
|
| 114 |
async def _current_code(self, guild_id: int) -> str:
|
| 115 |
row = await self.bot.db.fetchone("SELECT guild_language FROM guild_config WHERE guild_id = ?", guild_id)
|
| 116 |
+
supported = set(getattr(getattr(self.bot, "translator", None), "supported_languages", set(SUPPORTED_LANGUAGES)))
|
| 117 |
+
return row[0] if row and row[0] in supported else "ar"
|
| 118 |
|
| 119 |
async def _language_embed(self, guild_id: int, current_code: str) -> discord.Embed:
|
| 120 |
current_name = LANGUAGE_META.get(current_code, (current_code.upper(), "🌐"))[0]
|
| 121 |
current_emoji = LANGUAGE_META.get(current_code, (current_code.upper(), "🌐"))[1]
|
| 122 |
|
| 123 |
lines = []
|
| 124 |
+
supported = sorted(
|
| 125 |
+
getattr(getattr(self.bot, "translator", None), "supported_languages", set(SUPPORTED_LANGUAGES))
|
| 126 |
+
)
|
| 127 |
+
for code in supported:
|
| 128 |
name, emoji = LANGUAGE_META.get(code, (code.upper(), "🌐"))
|
| 129 |
marker = "✅" if code == current_code else "▫️"
|
| 130 |
lines.append(f"{marker} {emoji} **{name}** `({code})`")
|
bot/cogs/media.py
CHANGED
|
@@ -87,12 +87,21 @@ if wavelink is not None:
|
|
| 87 |
else:
|
| 88 |
self._connection_event.set()
|
| 89 |
|
| 90 |
-
from bot.theme import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
from bot.emojis import resolve_emoji_value, set_emoji_bot
|
| 92 |
|
| 93 |
# Import views from separate module
|
| 94 |
from .media_helpers import (
|
| 95 |
-
MusicPanelView, QueueView, FiltersView, FiltersPanelView,
|
| 96 |
AUDIO_FILTERS, get_filter_emoji, safe_defer, safe_send, safe_edit,
|
| 97 |
safe_interaction
|
| 98 |
)
|
|
@@ -469,44 +478,6 @@ class SuggestionSelect(discord.ui.Select):
|
|
| 469 |
return
|
| 470 |
|
| 471 |
selected = self.suggestions[selected_index]
|
| 472 |
-
|
| 473 |
-
# Get guild and player
|
| 474 |
-
guild = interaction.guild
|
| 475 |
-
if not guild:
|
| 476 |
-
await interaction.followup.send("Server only.", ephemeral=True)
|
| 477 |
-
return
|
| 478 |
-
|
| 479 |
-
player = guild.voice_client
|
| 480 |
-
if not player or not isinstance(player, wavelink.Player):
|
| 481 |
-
await interaction.followup.send("Not connected to voice.", ephemeral=True)
|
| 482 |
-
return
|
| 483 |
-
|
| 484 |
-
# Add ALL suggestions to wavelink queue first (in order)
|
| 485 |
-
for idx, item in enumerate(self.suggestions):
|
| 486 |
-
try:
|
| 487 |
-
search_q = item.query if item.query else f"ytsearch1:{item.title}"
|
| 488 |
-
results = await wavelink.Playable.search(search_q)
|
| 489 |
-
if results:
|
| 490 |
-
await player.queue.put_wait(results[0])
|
| 491 |
-
except Exception:
|
| 492 |
-
continue
|
| 493 |
-
|
| 494 |
-
# Now play the selected track immediately by moving it to front
|
| 495 |
-
# Get the selected track from queue and move to front
|
| 496 |
-
queue_list = list(player.queue)
|
| 497 |
-
if selected_index < len(queue_list):
|
| 498 |
-
selected_track = queue_list.pop(selected_index)
|
| 499 |
-
# Clear queue and re-add with selected first
|
| 500 |
-
player.queue._queue.clear()
|
| 501 |
-
player.queue._queue.append(selected_track)
|
| 502 |
-
for track in queue_list:
|
| 503 |
-
player.queue._queue.append(track)
|
| 504 |
-
|
| 505 |
-
# Play the selected track
|
| 506 |
-
if player.queue:
|
| 507 |
-
track_to_play = player.queue.get()
|
| 508 |
-
await player.play(track_to_play)
|
| 509 |
-
|
| 510 |
choice = (selected.query or selected.title).strip()
|
| 511 |
result = await self.cog.play_from_query(interaction, choice)
|
| 512 |
await interaction.followup.send(
|
|
@@ -741,6 +712,9 @@ class Media(commands.Cog):
|
|
| 741 |
or os.getenv("YT_API_KEY", "").strip()
|
| 742 |
)
|
| 743 |
self._youtube_region_code = (os.getenv("YOUTUBE_REGION_CODE", "US").strip() or "US").upper()
|
|
|
|
|
|
|
|
|
|
| 744 |
|
| 745 |
if hasattr(self.bot, "logger"):
|
| 746 |
if self._ffmpeg_path:
|
|
@@ -751,6 +725,7 @@ class Media(commands.Cog):
|
|
| 751 |
async def cog_load(self) -> None:
|
| 752 |
"""Set up event listeners for wavelink."""
|
| 753 |
self.bot.add_view(MusicPanelView(self))
|
|
|
|
| 754 |
|
| 755 |
# Register wavelink event listeners
|
| 756 |
if wavelink is not None:
|
|
@@ -1182,6 +1157,7 @@ class Media(commands.Cog):
|
|
| 1182 |
|
| 1183 |
def _to_lavalink_identifier(self, query: str) -> str:
|
| 1184 |
normalized = self._sanitize_query(query)
|
|
|
|
| 1185 |
prefixes = ("ytsearch1:", "ytsearch:", "ytmsearch:")
|
| 1186 |
while True:
|
| 1187 |
lowered = normalized.casefold()
|
|
@@ -1189,19 +1165,124 @@ class Media(commands.Cog):
|
|
| 1189 |
for prefix in prefixes:
|
| 1190 |
if lowered.startswith(prefix):
|
| 1191 |
normalized = normalized[len(prefix):].strip()
|
|
|
|
| 1192 |
matched = True
|
| 1193 |
break
|
| 1194 |
if not matched:
|
| 1195 |
break
|
|
|
|
|
|
|
| 1196 |
if self._looks_like_url(normalized):
|
|
|
|
|
|
|
|
|
|
| 1197 |
return normalized
|
| 1198 |
return f"ytsearch:{normalized}"
|
| 1199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1200 |
def _guild_state(self, guild_id: int) -> GuildPlaybackState:
|
| 1201 |
if guild_id not in self.state:
|
| 1202 |
self.state[guild_id] = GuildPlaybackState()
|
| 1203 |
return self.state[guild_id]
|
| 1204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1205 |
async def _dj_permitted(self, ctx_or_interaction: commands.Context | discord.Interaction) -> bool:
|
| 1206 |
return True
|
| 1207 |
|
|
@@ -1535,7 +1616,13 @@ class Media(commands.Cog):
|
|
| 1535 |
continue
|
| 1536 |
direct = str(entry.get("url") or entry.get("webpage_url") or "").strip()
|
| 1537 |
vid = str(entry.get("id") or "").strip()
|
|
|
|
|
|
|
| 1538 |
if direct and direct.startswith("http"):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1539 |
urls.append(direct)
|
| 1540 |
continue
|
| 1541 |
if direct and not direct.startswith("http") and "youtube" in playlist_url:
|
|
@@ -1543,9 +1630,14 @@ class Media(commands.Cog):
|
|
| 1543 |
continue
|
| 1544 |
if vid:
|
| 1545 |
urls.append(f"https://www.youtube.com/watch?v={vid}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1546 |
return urls
|
| 1547 |
|
| 1548 |
-
|
|
|
|
| 1549 |
|
| 1550 |
async def _resolve_query_with_ytdlp(self, query: str) -> str | None:
|
| 1551 |
"""Resolve a text query to a direct video URL using yt-dlp search."""
|
|
@@ -1656,7 +1748,11 @@ class Media(commands.Cog):
|
|
| 1656 |
lines.append(f"*...and {len(queue) - 5} more*")
|
| 1657 |
embed.add_field(name=up_next, value="\n".join(lines), inline=False)
|
| 1658 |
else:
|
| 1659 |
-
embed.add_field(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1660 |
|
| 1661 |
status_icon = "📡"
|
| 1662 |
loop_text = "Off" if state.loop_mode == "off" else ("Track" if state.loop_mode == "track" else "Queue")
|
|
@@ -1674,6 +1770,8 @@ class Media(commands.Cog):
|
|
| 1674 |
|
| 1675 |
server_name = guild.name if guild else "Server"
|
| 1676 |
embed.set_footer(text=f"⛩️ 〣 🔄 Auto-refreshing every 10s • {server_name} 〣 🏮")
|
|
|
|
|
|
|
| 1677 |
|
| 1678 |
return embed
|
| 1679 |
|
|
@@ -1807,7 +1905,7 @@ class Media(commands.Cog):
|
|
| 1807 |
self.now_playing.pop(guild.id, None)
|
| 1808 |
if lang == "ar":
|
| 1809 |
return "⏭️ تم التخطي. الطابور فارغ."
|
| 1810 |
-
return "
|
| 1811 |
|
| 1812 |
# For non-wavelink players
|
| 1813 |
if not self._voice_is_playing(player):
|
|
@@ -1982,7 +2080,7 @@ class Media(commands.Cog):
|
|
| 1982 |
tracks = ([now] if now else []) + list(state_queue)
|
| 1983 |
uris = [t.webpage_url for t in tracks if t and t.webpage_url]
|
| 1984 |
if not uris:
|
| 1985 |
-
return "Queue is empty."
|
| 1986 |
cleaned_name = (playlist_name or "quicksave").strip()[:40]
|
| 1987 |
await self.bot.db.execute(
|
| 1988 |
"INSERT INTO saved_playlists(user_id, name, tracks_json) VALUES (?, ?, ?) "
|
|
@@ -2000,7 +2098,7 @@ class Media(commands.Cog):
|
|
| 2000 |
)
|
| 2001 |
embed = discord.Embed(title="💾 Saved Playlists", color=NEON_CYAN)
|
| 2002 |
if not rows:
|
| 2003 |
-
embed.description = "No playlists saved yet. Use the **Save Queue** button."
|
| 2004 |
return embed
|
| 2005 |
lines: list[str] = []
|
| 2006 |
for name, tracks_json, created_at in rows:
|
|
@@ -2057,16 +2155,17 @@ class Media(commands.Cog):
|
|
| 2057 |
self.queues.setdefault(guild.id, [])
|
| 2058 |
count = 0
|
| 2059 |
first_track: str | None = None
|
| 2060 |
-
for url in urls[:
|
| 2061 |
try:
|
| 2062 |
-
|
|
|
|
| 2063 |
except Exception as exc:
|
| 2064 |
await self._log_media_issue(guild, "saved_playlist_play_search", url, exc)
|
| 2065 |
continue
|
| 2066 |
if not results:
|
| 2067 |
continue
|
| 2068 |
wl_items = list(results.tracks) if (wavelink is not None and isinstance(results, wavelink.Playlist)) else list(results)
|
| 2069 |
-
for wl_track in wl_items[:
|
| 2070 |
track = self._wavelink_to_track(wl_track, actor.id)
|
| 2071 |
if count == 0 and not player.playing and not player.paused:
|
| 2072 |
await player.play(wl_track, volume=self._guild_state(guild.id).volume)
|
|
@@ -2076,6 +2175,11 @@ class Media(commands.Cog):
|
|
| 2076 |
self.queues[guild.id].append(track)
|
| 2077 |
await player.queue.put_wait(wl_track)
|
| 2078 |
count += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2079 |
|
| 2080 |
if count == 0:
|
| 2081 |
if lang == "ar":
|
|
@@ -2180,7 +2284,32 @@ class Media(commands.Cog):
|
|
| 2180 |
|
| 2181 |
urls: list[str] = []
|
| 2182 |
if self._looks_like_playlist(source):
|
| 2183 |
-
urls = await self._extract_playlist_entries(source, limit=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2184 |
else:
|
| 2185 |
chosen = source
|
| 2186 |
if not self._looks_like_url(source):
|
|
@@ -2190,7 +2319,7 @@ class Media(commands.Cog):
|
|
| 2190 |
if chosen:
|
| 2191 |
urls = [chosen]
|
| 2192 |
|
| 2193 |
-
urls = [u for u in urls if u][:
|
| 2194 |
if not urls:
|
| 2195 |
if lang == "ar":
|
| 2196 |
return "تعذر استخراج مقاطع للحفظ."
|
|
@@ -2252,7 +2381,7 @@ class Media(commands.Cog):
|
|
| 2252 |
tracks = self._get_saved_playlist(guild_id)
|
| 2253 |
embed = discord.Embed(title="📚 Saved Playlist", color=NEON_CYAN)
|
| 2254 |
if not tracks:
|
| 2255 |
-
embed.description = "No saved tracks yet. Use `/music playlist_add <query>`."
|
| 2256 |
return embed
|
| 2257 |
lines = [
|
| 2258 |
f"**{idx}.** {track.title[:70]} • `{track.format_duration()}`"
|
|
@@ -2467,20 +2596,22 @@ class Media(commands.Cog):
|
|
| 2467 |
)
|
| 2468 |
if is_supported_playlist:
|
| 2469 |
try:
|
| 2470 |
-
pre_extracted_urls = await self._extract_playlist_entries(playlist_query, limit=
|
| 2471 |
except Exception:
|
| 2472 |
pre_extracted_urls = []
|
| 2473 |
|
| 2474 |
try:
|
| 2475 |
if pre_extracted_urls:
|
| 2476 |
results = []
|
| 2477 |
-
for url in pre_extracted_urls:
|
| 2478 |
-
found = await
|
| 2479 |
if found:
|
| 2480 |
results.append(found[0])
|
|
|
|
|
|
|
| 2481 |
else:
|
| 2482 |
# Search for playlist
|
| 2483 |
-
results = await
|
| 2484 |
except Exception:
|
| 2485 |
if lang == "ar":
|
| 2486 |
return "تعذر تحميل قائمة التشغيل."
|
|
@@ -2505,7 +2636,7 @@ class Media(commands.Cog):
|
|
| 2505 |
first = True
|
| 2506 |
|
| 2507 |
# Process tracks in order
|
| 2508 |
-
for wl_track in tracks_list[:
|
| 2509 |
try:
|
| 2510 |
track = self._wavelink_to_track(wl_track, actor.id)
|
| 2511 |
|
|
@@ -2552,8 +2683,7 @@ class Media(commands.Cog):
|
|
| 2552 |
|
| 2553 |
@commands.hybrid_group(name="music", fallback="panel", description="Music controls", with_app_command=False)
|
| 2554 |
async def music_group(self, ctx: commands.Context) -> None:
|
| 2555 |
-
|
| 2556 |
-
await ctx.interaction.response.defer(ephemeral=True)
|
| 2557 |
guild_id = ctx.guild.id if ctx.guild else None
|
| 2558 |
embed = await self._music_panel_embed(guild_id)
|
| 2559 |
panel_view = MusicPanelView(self, guild_id)
|
|
@@ -2563,8 +2693,7 @@ class Media(commands.Cog):
|
|
| 2563 |
|
| 2564 |
@commands.hybrid_command(name="music_panel", description="Open interactive music panel")
|
| 2565 |
async def music_panel(self, ctx: commands.Context) -> None:
|
| 2566 |
-
|
| 2567 |
-
await ctx.interaction.response.defer(ephemeral=True)
|
| 2568 |
guild_id = ctx.guild.id if ctx.guild else None
|
| 2569 |
embed = await self._music_panel_embed(guild_id)
|
| 2570 |
panel_view = MusicPanelView(self, guild_id)
|
|
@@ -2586,14 +2715,12 @@ class Media(commands.Cog):
|
|
| 2586 |
guild_id = ctx.guild.id if ctx.guild else None
|
| 2587 |
embed = await self._music_panel_embed(guild_id)
|
| 2588 |
panel_view = MusicPanelView(self, guild_id)
|
| 2589 |
-
|
| 2590 |
-
await ctx.interaction.response.defer(ephemeral=True)
|
| 2591 |
panel_msg = await (ctx.interaction.followup.send("Use the panel below or provide a query:", embed=embed, view=panel_view, wait=True) if ctx.interaction else ctx.reply("Use the panel below or provide a query:", embed=embed, view=panel_view))
|
| 2592 |
if isinstance(panel_msg, discord.Message):
|
| 2593 |
await panel_view.start_auto_refresh(panel_msg)
|
| 2594 |
return
|
| 2595 |
-
|
| 2596 |
-
await ctx.defer()
|
| 2597 |
if not self._looks_like_url(query):
|
| 2598 |
suggestions = await self._unified_play_search(query, limit=10)
|
| 2599 |
if suggestions:
|
|
@@ -2678,22 +2805,19 @@ class Media(commands.Cog):
|
|
| 2678 |
|
| 2679 |
@music_group.command(name="playlist_add", description="Add a track to saved playlist only")
|
| 2680 |
async def music_playlist_add(self, ctx: commands.Context, *, query: str) -> None:
|
| 2681 |
-
|
| 2682 |
-
await ctx.defer()
|
| 2683 |
result = await self.add_query_to_saved_playlist(ctx, query)
|
| 2684 |
await ctx.reply(result)
|
| 2685 |
|
| 2686 |
@music_group.command(name="playlist_play", description="Play one of your saved playlists by name")
|
| 2687 |
async def music_playlist_play(self, ctx: commands.Context, *, name: str) -> None:
|
| 2688 |
-
|
| 2689 |
-
await ctx.defer()
|
| 2690 |
result = await self.play_user_saved_playlist(ctx, name)
|
| 2691 |
await ctx.reply(result)
|
| 2692 |
|
| 2693 |
@music_group.command(name="playlist_save", description="Save URL/query (track or playlist) into named playlist")
|
| 2694 |
async def music_playlist_save(self, ctx: commands.Context, name: str, *, source: str) -> None:
|
| 2695 |
-
|
| 2696 |
-
await ctx.defer(ephemeral=True)
|
| 2697 |
result = await self.save_query_to_named_playlist(ctx, name, source)
|
| 2698 |
if ctx.interaction:
|
| 2699 |
await ctx.interaction.followup.send(result, ephemeral=True)
|
|
@@ -2702,8 +2826,7 @@ class Media(commands.Cog):
|
|
| 2702 |
|
| 2703 |
@music_group.command(name="playlist_delete", description="Delete one of your saved playlists by name")
|
| 2704 |
async def music_playlist_delete(self, ctx: commands.Context, *, name: str) -> None:
|
| 2705 |
-
|
| 2706 |
-
await ctx.defer(ephemeral=True)
|
| 2707 |
result = await self.delete_user_playlist(ctx, name)
|
| 2708 |
if ctx.interaction:
|
| 2709 |
await ctx.interaction.followup.send(result, ephemeral=True)
|
|
@@ -2712,8 +2835,7 @@ class Media(commands.Cog):
|
|
| 2712 |
|
| 2713 |
@music_group.command(name="playlist_rename", description="Rename one of your saved playlists")
|
| 2714 |
async def music_playlist_rename(self, ctx: commands.Context, old_name: str, *, new_name: str) -> None:
|
| 2715 |
-
|
| 2716 |
-
await ctx.defer(ephemeral=True)
|
| 2717 |
result = await self.rename_user_playlist(ctx, old_name, new_name)
|
| 2718 |
if ctx.interaction:
|
| 2719 |
await ctx.interaction.followup.send(result, ephemeral=True)
|
|
@@ -2725,11 +2847,18 @@ class Media(commands.Cog):
|
|
| 2725 |
if not ctx.guild:
|
| 2726 |
await ctx.reply("Server only.")
|
| 2727 |
return
|
| 2728 |
-
|
| 2729 |
-
await ctx.defer()
|
| 2730 |
normalized = self._sanitize_query(query)
|
| 2731 |
if not normalized:
|
| 2732 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2733 |
return
|
| 2734 |
|
| 2735 |
used_api = bool((self._youtube_api_key or "").strip())
|
|
@@ -2749,7 +2878,15 @@ class Media(commands.Cog):
|
|
| 2749 |
)
|
| 2750 |
await ctx.reply("No direct YouTube results found. Try one of these:", embed=fallback_embed)
|
| 2751 |
return
|
| 2752 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2753 |
return
|
| 2754 |
|
| 2755 |
embed = discord.Embed(
|
|
@@ -2771,8 +2908,7 @@ class Media(commands.Cog):
|
|
| 2771 |
|
| 2772 |
@commands.hybrid_command(name="playlists", description="View your saved playlists")
|
| 2773 |
async def playlists(self, ctx: commands.Context) -> None:
|
| 2774 |
-
|
| 2775 |
-
await ctx.defer(ephemeral=True)
|
| 2776 |
embed = await self.user_playlists_embed(ctx.author.id)
|
| 2777 |
if ctx.interaction:
|
| 2778 |
await ctx.interaction.followup.send(embed=embed, ephemeral=True)
|
|
@@ -2792,7 +2928,15 @@ class Media(commands.Cog):
|
|
| 2792 |
return
|
| 2793 |
queue = self.queues.get(ctx.guild.id, [])
|
| 2794 |
if len(queue) < 2:
|
| 2795 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2796 |
return
|
| 2797 |
random.shuffle(queue)
|
| 2798 |
|
|
@@ -3007,7 +3151,15 @@ class Media(commands.Cog):
|
|
| 3007 |
if lang == "ar":
|
| 3008 |
await ctx.reply("❌ الطابور فارغ.")
|
| 3009 |
else:
|
| 3010 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3011 |
return
|
| 3012 |
|
| 3013 |
# Convert to 0-indexed
|
|
@@ -3062,7 +3214,15 @@ class Media(commands.Cog):
|
|
| 3062 |
if lang == "ar":
|
| 3063 |
await ctx.reply("❌ الطابور فارغ.")
|
| 3064 |
else:
|
| 3065 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3066 |
return
|
| 3067 |
|
| 3068 |
# Convert to 0-indexed
|
|
@@ -3115,7 +3275,15 @@ class Media(commands.Cog):
|
|
| 3115 |
if lang == "ar":
|
| 3116 |
await ctx.reply("❌ الطابور فارغ.")
|
| 3117 |
else:
|
| 3118 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3119 |
return
|
| 3120 |
|
| 3121 |
idx = position - 1
|
|
@@ -3164,7 +3332,15 @@ class Media(commands.Cog):
|
|
| 3164 |
if lang == "ar":
|
| 3165 |
await ctx.reply("❌ الطابور فارغ.")
|
| 3166 |
else:
|
| 3167 |
-
await ctx.reply(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3168 |
return
|
| 3169 |
|
| 3170 |
idx = position - 1
|
|
|
|
| 87 |
else:
|
| 88 |
self._connection_event.set()
|
| 89 |
|
| 90 |
+
from bot.theme import (
|
| 91 |
+
NEON_CYAN,
|
| 92 |
+
NEON_ORANGE,
|
| 93 |
+
NEON_LIME,
|
| 94 |
+
NEON_PURPLE,
|
| 95 |
+
panel_divider,
|
| 96 |
+
idle_embed_for_guild,
|
| 97 |
+
idle_text,
|
| 98 |
+
add_banner_to_embed,
|
| 99 |
+
)
|
| 100 |
from bot.emojis import resolve_emoji_value, set_emoji_bot
|
| 101 |
|
| 102 |
# Import views from separate module
|
| 103 |
from .media_helpers import (
|
| 104 |
+
MusicPanelView, QueueView, FiltersView, FiltersPanelView, AudioActionsView,
|
| 105 |
AUDIO_FILTERS, get_filter_emoji, safe_defer, safe_send, safe_edit,
|
| 106 |
safe_interaction
|
| 107 |
)
|
|
|
|
| 478 |
return
|
| 479 |
|
| 480 |
selected = self.suggestions[selected_index]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
choice = (selected.query or selected.title).strip()
|
| 482 |
result = await self.cog.play_from_query(interaction, choice)
|
| 483 |
await interaction.followup.send(
|
|
|
|
| 712 |
or os.getenv("YT_API_KEY", "").strip()
|
| 713 |
)
|
| 714 |
self._youtube_region_code = (os.getenv("YOUTUBE_REGION_CODE", "US").strip() or "US").upper()
|
| 715 |
+
self._playlist_track_limit = max(50, min(500, int((os.getenv("PLAYLIST_TRACK_LIMIT", "300").strip() or "300"))))
|
| 716 |
+
self._playlist_batch_sleep = max(0.05, min(1.0, float((os.getenv("PLAYLIST_BATCH_SLEEP", "0.35").strip() or "0.35"))))
|
| 717 |
+
self._resolved_url_query_cache: dict[str, str] = {}
|
| 718 |
|
| 719 |
if hasattr(self.bot, "logger"):
|
| 720 |
if self._ffmpeg_path:
|
|
|
|
| 725 |
async def cog_load(self) -> None:
|
| 726 |
"""Set up event listeners for wavelink."""
|
| 727 |
self.bot.add_view(MusicPanelView(self))
|
| 728 |
+
self.bot.add_view(AudioActionsView(self))
|
| 729 |
|
| 730 |
# Register wavelink event listeners
|
| 731 |
if wavelink is not None:
|
|
|
|
| 1157 |
|
| 1158 |
def _to_lavalink_identifier(self, query: str) -> str:
|
| 1159 |
normalized = self._sanitize_query(query)
|
| 1160 |
+
forced_search = False
|
| 1161 |
prefixes = ("ytsearch1:", "ytsearch:", "ytmsearch:")
|
| 1162 |
while True:
|
| 1163 |
lowered = normalized.casefold()
|
|
|
|
| 1165 |
for prefix in prefixes:
|
| 1166 |
if lowered.startswith(prefix):
|
| 1167 |
normalized = normalized[len(prefix):].strip()
|
| 1168 |
+
forced_search = True
|
| 1169 |
matched = True
|
| 1170 |
break
|
| 1171 |
if not matched:
|
| 1172 |
break
|
| 1173 |
+
if forced_search:
|
| 1174 |
+
return f"ytsearch:{normalized}"
|
| 1175 |
if self._looks_like_url(normalized):
|
| 1176 |
+
lowered = normalized.casefold()
|
| 1177 |
+
if "open.spotify.com/track/" in lowered or "music.apple.com/" in lowered:
|
| 1178 |
+
return f"ytsearch:{normalized}"
|
| 1179 |
return normalized
|
| 1180 |
return f"ytsearch:{normalized}"
|
| 1181 |
|
| 1182 |
+
async def _resolve_music_url_to_search_term(self, raw_url: str) -> str | None:
|
| 1183 |
+
url = (raw_url or "").strip()
|
| 1184 |
+
if not url:
|
| 1185 |
+
return None
|
| 1186 |
+
cached = self._resolved_url_query_cache.get(url)
|
| 1187 |
+
if cached:
|
| 1188 |
+
return cached
|
| 1189 |
+
|
| 1190 |
+
lowered = url.casefold()
|
| 1191 |
+
timeout = aiohttp.ClientTimeout(total=5)
|
| 1192 |
+
|
| 1193 |
+
# Spotify: lightweight oEmbed endpoint (no auth token needed for title/artist string).
|
| 1194 |
+
if "open.spotify.com/track/" in lowered:
|
| 1195 |
+
try:
|
| 1196 |
+
oembed = f"https://open.spotify.com/oembed?url={quote_plus(url)}"
|
| 1197 |
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
| 1198 |
+
async with session.get(oembed) as resp:
|
| 1199 |
+
payload = await resp.json(content_type=None) if resp.status == 200 else None
|
| 1200 |
+
if isinstance(payload, dict):
|
| 1201 |
+
title = str(payload.get("title") or "").strip()
|
| 1202 |
+
author = str(payload.get("author_name") or "").strip()
|
| 1203 |
+
term = f"{author} - {title}".strip(" -")
|
| 1204 |
+
if term:
|
| 1205 |
+
self._resolved_url_query_cache[url] = term
|
| 1206 |
+
return term
|
| 1207 |
+
except Exception:
|
| 1208 |
+
pass
|
| 1209 |
+
|
| 1210 |
+
# Apple Music track URLs usually include iTunes id-like token (/id123456789).
|
| 1211 |
+
if "music.apple.com/" in lowered:
|
| 1212 |
+
match = re.search(r"/id(\d+)", url)
|
| 1213 |
+
if match:
|
| 1214 |
+
try:
|
| 1215 |
+
lookup = f"https://itunes.apple.com/lookup?id={match.group(1)}"
|
| 1216 |
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
| 1217 |
+
async with session.get(lookup) as resp:
|
| 1218 |
+
payload = await resp.json(content_type=None) if resp.status == 200 else None
|
| 1219 |
+
if isinstance(payload, dict):
|
| 1220 |
+
rows = payload.get("results") or []
|
| 1221 |
+
if rows and isinstance(rows[0], dict):
|
| 1222 |
+
artist = str(rows[0].get("artistName") or "").strip()
|
| 1223 |
+
title = str(rows[0].get("trackName") or rows[0].get("collectionName") or "").strip()
|
| 1224 |
+
term = f"{artist} - {title}".strip(" -")
|
| 1225 |
+
if term:
|
| 1226 |
+
self._resolved_url_query_cache[url] = term
|
| 1227 |
+
return term
|
| 1228 |
+
except Exception:
|
| 1229 |
+
pass
|
| 1230 |
+
return None
|
| 1231 |
+
|
| 1232 |
+
async def _identifier_fallbacks(self, identifier: str) -> list[str]:
|
| 1233 |
+
primary = (identifier or "").strip()
|
| 1234 |
+
if not primary:
|
| 1235 |
+
return []
|
| 1236 |
+
candidates: list[str] = [primary]
|
| 1237 |
+
|
| 1238 |
+
plain = primary
|
| 1239 |
+
if plain.lower().startswith("ytsearch:"):
|
| 1240 |
+
plain = plain[len("ytsearch:") :].strip()
|
| 1241 |
+
|
| 1242 |
+
if self._looks_like_url(plain):
|
| 1243 |
+
term = await self._resolve_music_url_to_search_term(plain)
|
| 1244 |
+
if term:
|
| 1245 |
+
alt = f"ytsearch:{term}"
|
| 1246 |
+
if alt not in candidates:
|
| 1247 |
+
candidates.append(alt)
|
| 1248 |
+
|
| 1249 |
+
return candidates
|
| 1250 |
+
|
| 1251 |
+
async def _search_playable_with_retry(self, identifier: str, *, attempts: int = 3) -> list[object]:
|
| 1252 |
+
if wavelink is None:
|
| 1253 |
+
return []
|
| 1254 |
+
last_error: Exception | None = None
|
| 1255 |
+
candidates = await self._identifier_fallbacks(identifier)
|
| 1256 |
+
for candidate in candidates:
|
| 1257 |
+
for attempt in range(1, max(1, attempts) + 1):
|
| 1258 |
+
try:
|
| 1259 |
+
return await wavelink.Playable.search(candidate)
|
| 1260 |
+
except Exception as exc:
|
| 1261 |
+
last_error = exc
|
| 1262 |
+
text = str(exc).lower()
|
| 1263 |
+
if "429" not in text and "rate" not in text:
|
| 1264 |
+
break
|
| 1265 |
+
await asyncio.sleep((0.35 * attempt) + random.uniform(0.05, 0.2))
|
| 1266 |
+
if last_error is not None:
|
| 1267 |
+
raise last_error
|
| 1268 |
+
return []
|
| 1269 |
+
|
| 1270 |
def _guild_state(self, guild_id: int) -> GuildPlaybackState:
|
| 1271 |
if guild_id not in self.state:
|
| 1272 |
self.state[guild_id] = GuildPlaybackState()
|
| 1273 |
return self.state[guild_id]
|
| 1274 |
|
| 1275 |
+
async def _safe_ctx_defer(self, ctx: commands.Context, *, ephemeral: bool = False) -> None:
|
| 1276 |
+
interaction = getattr(ctx, "interaction", None)
|
| 1277 |
+
if not interaction:
|
| 1278 |
+
return
|
| 1279 |
+
try:
|
| 1280 |
+
if interaction.response.is_done():
|
| 1281 |
+
return
|
| 1282 |
+
await interaction.response.defer(ephemeral=ephemeral)
|
| 1283 |
+
except (discord.InteractionResponded, discord.NotFound, discord.HTTPException):
|
| 1284 |
+
return
|
| 1285 |
+
|
| 1286 |
async def _dj_permitted(self, ctx_or_interaction: commands.Context | discord.Interaction) -> bool:
|
| 1287 |
return True
|
| 1288 |
|
|
|
|
| 1616 |
continue
|
| 1617 |
direct = str(entry.get("url") or entry.get("webpage_url") or "").strip()
|
| 1618 |
vid = str(entry.get("id") or "").strip()
|
| 1619 |
+
title = str(entry.get("title") or "").strip()
|
| 1620 |
+
uploader = str(entry.get("uploader") or entry.get("channel") or "").strip()
|
| 1621 |
if direct and direct.startswith("http"):
|
| 1622 |
+
if "open.spotify.com/track/" in direct and title:
|
| 1623 |
+
query = f"{uploader} - {title}" if uploader else title
|
| 1624 |
+
urls.append(f"ytsearch1:{query}")
|
| 1625 |
+
continue
|
| 1626 |
urls.append(direct)
|
| 1627 |
continue
|
| 1628 |
if direct and not direct.startswith("http") and "youtube" in playlist_url:
|
|
|
|
| 1630 |
continue
|
| 1631 |
if vid:
|
| 1632 |
urls.append(f"https://www.youtube.com/watch?v={vid}")
|
| 1633 |
+
continue
|
| 1634 |
+
if title:
|
| 1635 |
+
query = f"{uploader} - {title}" if uploader else title
|
| 1636 |
+
urls.append(f"ytsearch1:{query}")
|
| 1637 |
return urls
|
| 1638 |
|
| 1639 |
+
urls = await asyncio.to_thread(_run)
|
| 1640 |
+
return [u for u in urls if u][: max(1, min(limit, self._playlist_track_limit))]
|
| 1641 |
|
| 1642 |
async def _resolve_query_with_ytdlp(self, query: str) -> str | None:
|
| 1643 |
"""Resolve a text query to a direct video URL using yt-dlp search."""
|
|
|
|
| 1748 |
lines.append(f"*...and {len(queue) - 5} more*")
|
| 1749 |
embed.add_field(name=up_next, value="\n".join(lines), inline=False)
|
| 1750 |
else:
|
| 1751 |
+
embed.add_field(
|
| 1752 |
+
name=up_next,
|
| 1753 |
+
value=idle_text("Queue is empty.", "Add tracks with `/music play` or the music panel."),
|
| 1754 |
+
inline=False,
|
| 1755 |
+
)
|
| 1756 |
|
| 1757 |
status_icon = "📡"
|
| 1758 |
loop_text = "Off" if state.loop_mode == "off" else ("Track" if state.loop_mode == "track" else "Queue")
|
|
|
|
| 1770 |
|
| 1771 |
server_name = guild.name if guild else "Server"
|
| 1772 |
embed.set_footer(text=f"⛩️ 〣 🔄 Auto-refreshing every 10s • {server_name} 〣 🏮")
|
| 1773 |
+
if guild:
|
| 1774 |
+
await add_banner_to_embed(embed, guild, self.bot)
|
| 1775 |
|
| 1776 |
return embed
|
| 1777 |
|
|
|
|
| 1905 |
self.now_playing.pop(guild.id, None)
|
| 1906 |
if lang == "ar":
|
| 1907 |
return "⏭️ تم التخطي. الطابور فارغ."
|
| 1908 |
+
return idle_text("Queue is empty.", "Skipped current track. Nothing is queued.")
|
| 1909 |
|
| 1910 |
# For non-wavelink players
|
| 1911 |
if not self._voice_is_playing(player):
|
|
|
|
| 2080 |
tracks = ([now] if now else []) + list(state_queue)
|
| 2081 |
uris = [t.webpage_url for t in tracks if t and t.webpage_url]
|
| 2082 |
if not uris:
|
| 2083 |
+
return idle_text("Queue is empty.", "Play songs first, then try saving again.")
|
| 2084 |
cleaned_name = (playlist_name or "quicksave").strip()[:40]
|
| 2085 |
await self.bot.db.execute(
|
| 2086 |
"INSERT INTO saved_playlists(user_id, name, tracks_json) VALUES (?, ?, ?) "
|
|
|
|
| 2098 |
)
|
| 2099 |
embed = discord.Embed(title="💾 Saved Playlists", color=NEON_CYAN)
|
| 2100 |
if not rows:
|
| 2101 |
+
embed.description = idle_text("No playlists saved yet.", "Use the **Save Queue** button.")
|
| 2102 |
return embed
|
| 2103 |
lines: list[str] = []
|
| 2104 |
for name, tracks_json, created_at in rows:
|
|
|
|
| 2155 |
self.queues.setdefault(guild.id, [])
|
| 2156 |
count = 0
|
| 2157 |
first_track: str | None = None
|
| 2158 |
+
for idx, url in enumerate(urls[: self._playlist_track_limit], start=1):
|
| 2159 |
try:
|
| 2160 |
+
search_identifier = self._to_lavalink_identifier(url)
|
| 2161 |
+
results = await self._search_playable_with_retry(search_identifier, attempts=3)
|
| 2162 |
except Exception as exc:
|
| 2163 |
await self._log_media_issue(guild, "saved_playlist_play_search", url, exc)
|
| 2164 |
continue
|
| 2165 |
if not results:
|
| 2166 |
continue
|
| 2167 |
wl_items = list(results.tracks) if (wavelink is not None and isinstance(results, wavelink.Playlist)) else list(results)
|
| 2168 |
+
for wl_track in wl_items[: self._playlist_track_limit]:
|
| 2169 |
track = self._wavelink_to_track(wl_track, actor.id)
|
| 2170 |
if count == 0 and not player.playing and not player.paused:
|
| 2171 |
await player.play(wl_track, volume=self._guild_state(guild.id).volume)
|
|
|
|
| 2175 |
self.queues[guild.id].append(track)
|
| 2176 |
await player.queue.put_wait(wl_track)
|
| 2177 |
count += 1
|
| 2178 |
+
# Smooth out provider/API bursts for large playlist loads.
|
| 2179 |
+
if count % 5 == 0:
|
| 2180 |
+
await asyncio.sleep(self._playlist_batch_sleep)
|
| 2181 |
+
if idx % 3 == 0:
|
| 2182 |
+
await asyncio.sleep(self._playlist_batch_sleep)
|
| 2183 |
|
| 2184 |
if count == 0:
|
| 2185 |
if lang == "ar":
|
|
|
|
| 2284 |
|
| 2285 |
urls: list[str] = []
|
| 2286 |
if self._looks_like_playlist(source):
|
| 2287 |
+
urls = await self._extract_playlist_entries(source, limit=self._playlist_track_limit)
|
| 2288 |
+
if not urls:
|
| 2289 |
+
# Secondary fallback: try Lavalink playlist parsing and store track URIs.
|
| 2290 |
+
try:
|
| 2291 |
+
lavalink_results = await self._search_playable_with_retry(
|
| 2292 |
+
self._to_lavalink_identifier(source),
|
| 2293 |
+
attempts=2,
|
| 2294 |
+
)
|
| 2295 |
+
except Exception:
|
| 2296 |
+
lavalink_results = []
|
| 2297 |
+
if lavalink_results:
|
| 2298 |
+
items = (
|
| 2299 |
+
list(lavalink_results.tracks)
|
| 2300 |
+
if (wavelink is not None and isinstance(lavalink_results, wavelink.Playlist))
|
| 2301 |
+
else list(lavalink_results)
|
| 2302 |
+
)
|
| 2303 |
+
for item in items[: self._playlist_track_limit]:
|
| 2304 |
+
uri = str(getattr(item, "uri", "") or getattr(item, "url", "") or "").strip()
|
| 2305 |
+
if uri:
|
| 2306 |
+
urls.append(uri)
|
| 2307 |
+
else:
|
| 2308 |
+
title = str(getattr(item, "title", "") or "").strip()
|
| 2309 |
+
author = str(getattr(item, "author", "") or "").strip()
|
| 2310 |
+
if title:
|
| 2311 |
+
term = f"{author} - {title}".strip(" -")
|
| 2312 |
+
urls.append(f"ytsearch:{term}")
|
| 2313 |
else:
|
| 2314 |
chosen = source
|
| 2315 |
if not self._looks_like_url(source):
|
|
|
|
| 2319 |
if chosen:
|
| 2320 |
urls = [chosen]
|
| 2321 |
|
| 2322 |
+
urls = [u for u in urls if u][: self._playlist_track_limit]
|
| 2323 |
if not urls:
|
| 2324 |
if lang == "ar":
|
| 2325 |
return "تعذر استخراج مقاطع للحفظ."
|
|
|
|
| 2381 |
tracks = self._get_saved_playlist(guild_id)
|
| 2382 |
embed = discord.Embed(title="📚 Saved Playlist", color=NEON_CYAN)
|
| 2383 |
if not tracks:
|
| 2384 |
+
embed.description = idle_text("No saved tracks yet.", "Use `/music playlist_add <query>`.")
|
| 2385 |
return embed
|
| 2386 |
lines = [
|
| 2387 |
f"**{idx}.** {track.title[:70]} • `{track.format_duration()}`"
|
|
|
|
| 2596 |
)
|
| 2597 |
if is_supported_playlist:
|
| 2598 |
try:
|
| 2599 |
+
pre_extracted_urls = await self._extract_playlist_entries(playlist_query, limit=self._playlist_track_limit)
|
| 2600 |
except Exception:
|
| 2601 |
pre_extracted_urls = []
|
| 2602 |
|
| 2603 |
try:
|
| 2604 |
if pre_extracted_urls:
|
| 2605 |
results = []
|
| 2606 |
+
for index, url in enumerate(pre_extracted_urls, start=1):
|
| 2607 |
+
found = await self._search_playable_with_retry(self._to_lavalink_identifier(url), attempts=3)
|
| 2608 |
if found:
|
| 2609 |
results.append(found[0])
|
| 2610 |
+
if index % 3 == 0:
|
| 2611 |
+
await asyncio.sleep(self._playlist_batch_sleep)
|
| 2612 |
else:
|
| 2613 |
# Search for playlist
|
| 2614 |
+
results = await self._search_playable_with_retry(self._to_lavalink_identifier(playlist_query), attempts=3)
|
| 2615 |
except Exception:
|
| 2616 |
if lang == "ar":
|
| 2617 |
return "تعذر تحميل قائمة التشغيل."
|
|
|
|
| 2636 |
first = True
|
| 2637 |
|
| 2638 |
# Process tracks in order
|
| 2639 |
+
for wl_track in tracks_list[: self._playlist_track_limit]:
|
| 2640 |
try:
|
| 2641 |
track = self._wavelink_to_track(wl_track, actor.id)
|
| 2642 |
|
|
|
|
| 2683 |
|
| 2684 |
@commands.hybrid_group(name="music", fallback="panel", description="Music controls", with_app_command=False)
|
| 2685 |
async def music_group(self, ctx: commands.Context) -> None:
|
| 2686 |
+
await self._safe_ctx_defer(ctx, ephemeral=True)
|
|
|
|
| 2687 |
guild_id = ctx.guild.id if ctx.guild else None
|
| 2688 |
embed = await self._music_panel_embed(guild_id)
|
| 2689 |
panel_view = MusicPanelView(self, guild_id)
|
|
|
|
| 2693 |
|
| 2694 |
@commands.hybrid_command(name="music_panel", description="Open interactive music panel")
|
| 2695 |
async def music_panel(self, ctx: commands.Context) -> None:
|
| 2696 |
+
await self._safe_ctx_defer(ctx, ephemeral=True)
|
|
|
|
| 2697 |
guild_id = ctx.guild.id if ctx.guild else None
|
| 2698 |
embed = await self._music_panel_embed(guild_id)
|
| 2699 |
panel_view = MusicPanelView(self, guild_id)
|
|
|
|
| 2715 |
guild_id = ctx.guild.id if ctx.guild else None
|
| 2716 |
embed = await self._music_panel_embed(guild_id)
|
| 2717 |
panel_view = MusicPanelView(self, guild_id)
|
| 2718 |
+
await self._safe_ctx_defer(ctx, ephemeral=True)
|
|
|
|
| 2719 |
panel_msg = await (ctx.interaction.followup.send("Use the panel below or provide a query:", embed=embed, view=panel_view, wait=True) if ctx.interaction else ctx.reply("Use the panel below or provide a query:", embed=embed, view=panel_view))
|
| 2720 |
if isinstance(panel_msg, discord.Message):
|
| 2721 |
await panel_view.start_auto_refresh(panel_msg)
|
| 2722 |
return
|
| 2723 |
+
await self._safe_ctx_defer(ctx)
|
|
|
|
| 2724 |
if not self._looks_like_url(query):
|
| 2725 |
suggestions = await self._unified_play_search(query, limit=10)
|
| 2726 |
if suggestions:
|
|
|
|
| 2805 |
|
| 2806 |
@music_group.command(name="playlist_add", description="Add a track to saved playlist only")
|
| 2807 |
async def music_playlist_add(self, ctx: commands.Context, *, query: str) -> None:
|
| 2808 |
+
await self._safe_ctx_defer(ctx)
|
|
|
|
| 2809 |
result = await self.add_query_to_saved_playlist(ctx, query)
|
| 2810 |
await ctx.reply(result)
|
| 2811 |
|
| 2812 |
@music_group.command(name="playlist_play", description="Play one of your saved playlists by name")
|
| 2813 |
async def music_playlist_play(self, ctx: commands.Context, *, name: str) -> None:
|
| 2814 |
+
await self._safe_ctx_defer(ctx)
|
|
|
|
| 2815 |
result = await self.play_user_saved_playlist(ctx, name)
|
| 2816 |
await ctx.reply(result)
|
| 2817 |
|
| 2818 |
@music_group.command(name="playlist_save", description="Save URL/query (track or playlist) into named playlist")
|
| 2819 |
async def music_playlist_save(self, ctx: commands.Context, name: str, *, source: str) -> None:
|
| 2820 |
+
await self._safe_ctx_defer(ctx, ephemeral=True)
|
|
|
|
| 2821 |
result = await self.save_query_to_named_playlist(ctx, name, source)
|
| 2822 |
if ctx.interaction:
|
| 2823 |
await ctx.interaction.followup.send(result, ephemeral=True)
|
|
|
|
| 2826 |
|
| 2827 |
@music_group.command(name="playlist_delete", description="Delete one of your saved playlists by name")
|
| 2828 |
async def music_playlist_delete(self, ctx: commands.Context, *, name: str) -> None:
|
| 2829 |
+
await self._safe_ctx_defer(ctx, ephemeral=True)
|
|
|
|
| 2830 |
result = await self.delete_user_playlist(ctx, name)
|
| 2831 |
if ctx.interaction:
|
| 2832 |
await ctx.interaction.followup.send(result, ephemeral=True)
|
|
|
|
| 2835 |
|
| 2836 |
@music_group.command(name="playlist_rename", description="Rename one of your saved playlists")
|
| 2837 |
async def music_playlist_rename(self, ctx: commands.Context, old_name: str, *, new_name: str) -> None:
|
| 2838 |
+
await self._safe_ctx_defer(ctx, ephemeral=True)
|
|
|
|
| 2839 |
result = await self.rename_user_playlist(ctx, old_name, new_name)
|
| 2840 |
if ctx.interaction:
|
| 2841 |
await ctx.interaction.followup.send(result, ephemeral=True)
|
|
|
|
| 2847 |
if not ctx.guild:
|
| 2848 |
await ctx.reply("Server only.")
|
| 2849 |
return
|
| 2850 |
+
await self._safe_ctx_defer(ctx)
|
|
|
|
| 2851 |
normalized = self._sanitize_query(query)
|
| 2852 |
if not normalized:
|
| 2853 |
+
await ctx.reply(
|
| 2854 |
+
embed=await idle_embed_for_guild(
|
| 2855 |
+
"YouTube Search Idle",
|
| 2856 |
+
"Type a search query first.",
|
| 2857 |
+
"Example: /music yt_search weeknd blinding lights",
|
| 2858 |
+
guild=ctx.guild,
|
| 2859 |
+
bot=self.bot,
|
| 2860 |
+
)
|
| 2861 |
+
)
|
| 2862 |
return
|
| 2863 |
|
| 2864 |
used_api = bool((self._youtube_api_key or "").strip())
|
|
|
|
| 2878 |
)
|
| 2879 |
await ctx.reply("No direct YouTube results found. Try one of these:", embed=fallback_embed)
|
| 2880 |
return
|
| 2881 |
+
await ctx.reply(
|
| 2882 |
+
embed=await idle_embed_for_guild(
|
| 2883 |
+
"No Results",
|
| 2884 |
+
"No YouTube results were found for this query.",
|
| 2885 |
+
"Try a different title, artist name, or direct URL.",
|
| 2886 |
+
guild=ctx.guild,
|
| 2887 |
+
bot=self.bot,
|
| 2888 |
+
)
|
| 2889 |
+
)
|
| 2890 |
return
|
| 2891 |
|
| 2892 |
embed = discord.Embed(
|
|
|
|
| 2908 |
|
| 2909 |
@commands.hybrid_command(name="playlists", description="View your saved playlists")
|
| 2910 |
async def playlists(self, ctx: commands.Context) -> None:
|
| 2911 |
+
await self._safe_ctx_defer(ctx, ephemeral=True)
|
|
|
|
| 2912 |
embed = await self.user_playlists_embed(ctx.author.id)
|
| 2913 |
if ctx.interaction:
|
| 2914 |
await ctx.interaction.followup.send(embed=embed, ephemeral=True)
|
|
|
|
| 2928 |
return
|
| 2929 |
queue = self.queues.get(ctx.guild.id, [])
|
| 2930 |
if len(queue) < 2:
|
| 2931 |
+
await ctx.reply(
|
| 2932 |
+
embed=await idle_embed_for_guild(
|
| 2933 |
+
"Shuffle Idle",
|
| 2934 |
+
"Need at least 2 tracks in queue to shuffle.",
|
| 2935 |
+
"Add more tracks first, then run /music shuffle.",
|
| 2936 |
+
guild=ctx.guild,
|
| 2937 |
+
bot=self.bot,
|
| 2938 |
+
)
|
| 2939 |
+
)
|
| 2940 |
return
|
| 2941 |
random.shuffle(queue)
|
| 2942 |
|
|
|
|
| 3151 |
if lang == "ar":
|
| 3152 |
await ctx.reply("❌ الطابور فارغ.")
|
| 3153 |
else:
|
| 3154 |
+
await ctx.reply(
|
| 3155 |
+
embed=await idle_embed_for_guild(
|
| 3156 |
+
"Queue Idle",
|
| 3157 |
+
"Queue is empty. Nothing to move.",
|
| 3158 |
+
"Use /music play to add tracks.",
|
| 3159 |
+
guild=ctx.guild,
|
| 3160 |
+
bot=self.bot,
|
| 3161 |
+
)
|
| 3162 |
+
)
|
| 3163 |
return
|
| 3164 |
|
| 3165 |
# Convert to 0-indexed
|
|
|
|
| 3214 |
if lang == "ar":
|
| 3215 |
await ctx.reply("❌ الطابور فارغ.")
|
| 3216 |
else:
|
| 3217 |
+
await ctx.reply(
|
| 3218 |
+
embed=await idle_embed_for_guild(
|
| 3219 |
+
"Queue Idle",
|
| 3220 |
+
"Queue is empty. Nothing to swap.",
|
| 3221 |
+
"Use /music play to add tracks.",
|
| 3222 |
+
guild=ctx.guild,
|
| 3223 |
+
bot=self.bot,
|
| 3224 |
+
)
|
| 3225 |
+
)
|
| 3226 |
return
|
| 3227 |
|
| 3228 |
# Convert to 0-indexed
|
|
|
|
| 3275 |
if lang == "ar":
|
| 3276 |
await ctx.reply("❌ الطابور فارغ.")
|
| 3277 |
else:
|
| 3278 |
+
await ctx.reply(
|
| 3279 |
+
embed=await idle_embed_for_guild(
|
| 3280 |
+
"Queue Idle",
|
| 3281 |
+
"Queue is empty. Nothing to remove.",
|
| 3282 |
+
"Use /music play to add tracks.",
|
| 3283 |
+
guild=ctx.guild,
|
| 3284 |
+
bot=self.bot,
|
| 3285 |
+
)
|
| 3286 |
+
)
|
| 3287 |
return
|
| 3288 |
|
| 3289 |
idx = position - 1
|
|
|
|
| 3332 |
if lang == "ar":
|
| 3333 |
await ctx.reply("❌ الطابور فارغ.")
|
| 3334 |
else:
|
| 3335 |
+
await ctx.reply(
|
| 3336 |
+
embed=await idle_embed_for_guild(
|
| 3337 |
+
"Queue Idle",
|
| 3338 |
+
"Queue is empty. Nothing to jump to.",
|
| 3339 |
+
"Use /music play to add tracks.",
|
| 3340 |
+
guild=ctx.guild,
|
| 3341 |
+
bot=self.bot,
|
| 3342 |
+
)
|
| 3343 |
+
)
|
| 3344 |
return
|
| 3345 |
|
| 3346 |
idx = position - 1
|
bot/cogs/media_helpers.py
CHANGED
|
@@ -22,7 +22,7 @@ except Exception:
|
|
| 22 |
from bot.theme import (
|
| 23 |
NEON_CYAN, NEON_PURPLE, NEON_ORANGE, NEON_LIME, NEON_PINK,
|
| 24 |
panel_divider, fancy_divider, fancy_header, progress_bar,
|
| 25 |
-
music_embed, music_now_playing, music_status, beautiful_list
|
| 26 |
)
|
| 27 |
|
| 28 |
def _emoji(key: str, default: str) -> str:
|
|
@@ -896,7 +896,13 @@ class MusicPanelView(discord.ui.View, AutoRefreshMixin):
|
|
| 896 |
return
|
| 897 |
queue = self.cog.queues.get(interaction.guild.id, [])
|
| 898 |
if len(queue) < 2:
|
| 899 |
-
await safe_send(interaction,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 900 |
return
|
| 901 |
random.shuffle(queue)
|
| 902 |
player = interaction.guild.voice_client
|
|
@@ -1175,9 +1181,12 @@ class QueueView(discord.ui.View, AutoRefreshMixin):
|
|
| 1175 |
else:
|
| 1176 |
embed.add_field(
|
| 1177 |
name="📋 Up Next",
|
| 1178 |
-
value="Add tracks with `/play`
|
| 1179 |
inline=False
|
| 1180 |
)
|
|
|
|
|
|
|
|
|
|
| 1181 |
|
| 1182 |
return embed
|
| 1183 |
|
|
|
|
| 22 |
from bot.theme import (
|
| 23 |
NEON_CYAN, NEON_PURPLE, NEON_ORANGE, NEON_LIME, NEON_PINK,
|
| 24 |
panel_divider, fancy_divider, fancy_header, progress_bar,
|
| 25 |
+
music_embed, music_now_playing, music_status, beautiful_list, idle_embed_for_guild, idle_text, add_banner_to_embed
|
| 26 |
)
|
| 27 |
|
| 28 |
def _emoji(key: str, default: str) -> str:
|
|
|
|
| 896 |
return
|
| 897 |
queue = self.cog.queues.get(interaction.guild.id, [])
|
| 898 |
if len(queue) < 2:
|
| 899 |
+
await safe_send(interaction, embed=await idle_embed_for_guild(
|
| 900 |
+
"Shuffle Idle",
|
| 901 |
+
"Need at least 2 tracks in queue to shuffle.",
|
| 902 |
+
"Add tracks from Play/Search, then retry.",
|
| 903 |
+
guild=interaction.guild,
|
| 904 |
+
bot=self.cog.bot,
|
| 905 |
+
))
|
| 906 |
return
|
| 907 |
random.shuffle(queue)
|
| 908 |
player = interaction.guild.voice_client
|
|
|
|
| 1181 |
else:
|
| 1182 |
embed.add_field(
|
| 1183 |
name="📋 Up Next",
|
| 1184 |
+
value=idle_text("Queue is empty.", "Add tracks with `/music play`.\n" + fancy_divider('music')),
|
| 1185 |
inline=False
|
| 1186 |
)
|
| 1187 |
+
guild = self.cog.bot.get_guild(self.guild_id) if hasattr(self.cog, "bot") else None
|
| 1188 |
+
if guild:
|
| 1189 |
+
await add_banner_to_embed(embed, guild, self.cog.bot)
|
| 1190 |
|
| 1191 |
return embed
|
| 1192 |
|
bot/cogs/menu.py
CHANGED
|
@@ -1,768 +1,819 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Menu cog: Interactive command explorer with beautiful panels.
|
| 3 |
-
Enhanced with stunning decorations, rich formatting, and multi-language support.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
import random
|
| 7 |
-
|
| 8 |
-
import discord
|
| 9 |
-
from discord.ext import commands
|
| 10 |
-
|
| 11 |
-
from bot.cogs.ai_suite import ImperialMotaz
|
| 12 |
-
from bot.emojis import ui, E_DIAMOND, E_STAR, E_FIRE, E_SPARKLE, E_GEM, E_CROWN
|
| 13 |
-
from bot.theme import (
|
| 14 |
-
fancy_header, pick_neon_color, progress_bar, shimmer, panel_divider,
|
| 15 |
-
NEON_CYAN, NEON_PINK, NEON_PURPLE, NEON_LIME, NEON_YELLOW, NEON_BLUE,
|
| 16 |
-
NEON_GOLD, NEON_MAGENTA,
|
| 17 |
-
double_line, triple_line, mega_title, fancy_divider, animated_header,
|
| 18 |
-
gradient_header, panel_title, section_header, quick_stats_grid
|
| 19 |
-
)
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
"
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
-
"
|
| 34 |
-
"
|
| 35 |
-
"
|
| 36 |
-
"
|
| 37 |
-
"
|
| 38 |
-
"
|
| 39 |
-
"
|
| 40 |
-
"
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
"
|
| 50 |
-
"
|
| 51 |
-
"
|
| 52 |
-
"
|
| 53 |
-
"
|
| 54 |
-
"
|
| 55 |
-
"
|
| 56 |
-
"
|
| 57 |
-
"
|
| 58 |
-
"
|
| 59 |
-
"
|
| 60 |
-
"
|
| 61 |
-
"
|
| 62 |
-
"
|
| 63 |
-
"
|
| 64 |
-
"
|
| 65 |
-
"
|
| 66 |
-
"
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
"
|
| 72 |
-
"
|
| 73 |
-
"
|
| 74 |
-
"
|
| 75 |
-
"
|
| 76 |
-
"
|
| 77 |
-
"
|
| 78 |
-
"
|
| 79 |
-
"
|
| 80 |
-
"
|
| 81 |
-
"
|
| 82 |
-
"
|
| 83 |
-
"
|
| 84 |
-
"
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
await
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
self.
|
| 167 |
-
self.
|
| 168 |
-
self.
|
| 169 |
-
self.
|
| 170 |
-
self.
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
self.
|
| 179 |
-
self.
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
self.
|
| 190 |
-
self.add_item(
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
"
|
| 227 |
-
"
|
| 228 |
-
"
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
"
|
| 232 |
-
"
|
| 233 |
-
"
|
| 234 |
-
"
|
| 235 |
-
"
|
| 236 |
-
"
|
| 237 |
-
"
|
| 238 |
-
"
|
| 239 |
-
"
|
| 240 |
-
"
|
| 241 |
-
"
|
| 242 |
-
"
|
| 243 |
-
"
|
| 244 |
-
"
|
| 245 |
-
"
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
"
|
| 249 |
-
"
|
| 250 |
-
"
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
"
|
| 254 |
-
"
|
| 255 |
-
"
|
| 256 |
-
"
|
| 257 |
-
"
|
| 258 |
-
"
|
| 259 |
-
"
|
| 260 |
-
"
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
"
|
| 264 |
-
"
|
| 265 |
-
"
|
| 266 |
-
"
|
| 267 |
-
"
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
"
|
| 271 |
-
"
|
| 272 |
-
"
|
| 273 |
-
"
|
| 274 |
-
"
|
| 275 |
-
"
|
| 276 |
-
"
|
| 277 |
-
"
|
| 278 |
-
"
|
| 279 |
-
"
|
| 280 |
-
"
|
| 281 |
-
"
|
| 282 |
-
"
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
"
|
| 286 |
-
"
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
"
|
| 290 |
-
"
|
| 291 |
-
"
|
| 292 |
-
"
|
| 293 |
-
"
|
| 294 |
-
"
|
| 295 |
-
"
|
| 296 |
-
"
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
"
|
| 300 |
-
"
|
| 301 |
-
"
|
| 302 |
-
"
|
| 303 |
-
"
|
| 304 |
-
"
|
| 305 |
-
"
|
| 306 |
-
"
|
| 307 |
-
"
|
| 308 |
-
"
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
"
|
| 312 |
-
"
|
| 313 |
-
"
|
| 314 |
-
"
|
| 315 |
-
"
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
"
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
"
|
| 323 |
-
"
|
| 324 |
-
"
|
| 325 |
-
"
|
| 326 |
-
"
|
| 327 |
-
"
|
| 328 |
-
"
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
"
|
| 332 |
-
"
|
| 333 |
-
"
|
| 334 |
-
"
|
| 335 |
-
"
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
"
|
| 341 |
-
"
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
"
|
| 345 |
-
"
|
| 346 |
-
"
|
| 347 |
-
"
|
| 348 |
-
"
|
| 349 |
-
"
|
| 350 |
-
"
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
"
|
| 356 |
-
"
|
| 357 |
-
"
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
"
|
| 361 |
-
"
|
| 362 |
-
"
|
| 363 |
-
"
|
| 364 |
-
"
|
| 365 |
-
"
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
"
|
| 369 |
-
"
|
| 370 |
-
"
|
| 371 |
-
"
|
| 372 |
-
"
|
| 373 |
-
"
|
| 374 |
-
"
|
| 375 |
-
|
| 376 |
-
#
|
| 377 |
-
"
|
| 378 |
-
"
|
| 379 |
-
"
|
| 380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
# AI Admin commands
|
| 382 |
"ai_admin": "menu.cmd.ai_admin",
|
| 383 |
"ai_help": "menu.cmd.ai_help",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
}
|
| 385 |
-
return key_mapping.get(norm, f"menu.cmd.{norm}")
|
| 386 |
-
|
| 387 |
-
async def _format_commands(self, guild_id: int | None, cmds: list[commands.Command], max_lines: int = 20, max_chars: int = 980) -> str:
|
| 388 |
-
"""Format commands list with beautiful decorations."""
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
translated_desc =
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
line = f"
|
| 414 |
-
projected = used + len(line) + (1 if lines else 0)
|
| 415 |
-
|
| 416 |
-
if projected > max_chars:
|
| 417 |
-
break
|
| 418 |
-
lines.append(line)
|
| 419 |
-
used = projected
|
| 420 |
-
|
| 421 |
-
if len(lines) >= max_lines:
|
| 422 |
-
break
|
| 423 |
-
|
| 424 |
if len(lines) < len([c for c in cmds if not c.hidden]):
|
| 425 |
-
suffix = "\n
|
| 426 |
if used + len(suffix) <= max_chars:
|
| 427 |
-
lines.append("
|
| 428 |
-
|
| 429 |
-
return "\n".join(lines)
|
| 430 |
-
|
| 431 |
-
def _visible_commands_page(self, selected_cog: str) -> tuple[list[commands.Command], int]:
|
| 432 |
-
commands_list = sorted(self._selected_commands(selected_cog), key=lambda c: c.qualified_name)
|
| 433 |
-
if not commands_list:
|
| 434 |
-
return [], 1
|
| 435 |
-
total_pages = max(1, (len(commands_list) + self.page_size - 1) // self.page_size)
|
| 436 |
-
if self.page >= total_pages:
|
| 437 |
-
self.page = total_pages - 1
|
| 438 |
-
start = self.page * self.page_size
|
| 439 |
-
return commands_list[start:start + self.page_size], total_pages
|
| 440 |
-
|
| 441 |
-
def _has_next_page(self, selected_cog: str) -> bool:
|
| 442 |
-
commands_list = self._selected_commands(selected_cog)
|
| 443 |
-
total_pages = max(1, (len(commands_list) + self.page_size - 1) // self.page_size)
|
| 444 |
-
return self.page + 1 < total_pages
|
| 445 |
-
|
| 446 |
-
def _category_stats(self) -> list[tuple[str, int]]:
|
| 447 |
-
"""Get category statistics."""
|
| 448 |
-
stats: list[tuple[str, int]] = []
|
| 449 |
-
for name, cog in self.bot.cogs.items():
|
| 450 |
-
cog_commands = [c for c in (cog.get_commands() if cog else []) if not c.hidden]
|
| 451 |
-
if cog_commands:
|
| 452 |
-
stats.append((name, len(cog_commands)))
|
| 453 |
-
return sorted(stats, key=lambda pair: (-pair[1], pair[0]))
|
| 454 |
|
| 455 |
-
async def _category_value(self, stats: list[tuple[str, int]], guild_id: int | None) -> str:
|
| 456 |
-
"""Format category statistics with beautiful decorations."""
|
| 457 |
-
lang = await self.bot.get_guild_language(guild_id)
|
| 458 |
-
if not stats:
|
| 459 |
-
return translate(lang, "menu.none")
|
| 460 |
-
|
| 461 |
-
lines: list[str] = []
|
| 462 |
-
for index, (name, count) in enumerate(stats[:8], start=1):
|
| 463 |
-
badge = {1: "🥇", 2: "🥈", 3: "🥉"}.get(index, f"#{index}")
|
| 464 |
-
emoji = _CATEGORY_EMOJIS.get(name, "📁")
|
| 465 |
-
|
| 466 |
-
if lang == "ar":
|
| 467 |
-
lines.append(f"{badge} {emoji} **{name}** — `{count}` أمر")
|
| 468 |
-
else:
|
| 469 |
-
lines.append(f"{badge} {emoji} **{name}** — `{count}` cmds")
|
| 470 |
-
|
| 471 |
-
if len(stats) > 8:
|
| 472 |
-
lines.append("✦ ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈")
|
| 473 |
-
|
| 474 |
return "\n".join(lines)
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
self,
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
"""
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
quick = (
|
| 560 |
-
f"
|
| 561 |
-
f"
|
| 562 |
-
f"
|
| 563 |
-
f"
|
| 564 |
)
|
|
|
|
| 565 |
updates = (
|
| 566 |
-
f"
|
| 567 |
-
f"
|
| 568 |
-
f"
|
| 569 |
-
f"
|
| 570 |
-
f"
|
| 571 |
-
)
|
| 572 |
-
footer = "🏮 Powered by BOT- AI Suite 🏮"
|
| 573 |
-
|
| 574 |
-
top_divider = panel_divider("cyan")
|
| 575 |
-
mid_divider = panel_divider("purple")
|
| 576 |
-
embed = ImperialMotaz.craft_embed(
|
| 577 |
-
title=title,
|
| 578 |
-
description=f"{top_divider}\n{desc}\n{mid_divider}",
|
| 579 |
-
color=color,
|
| 580 |
-
footer=footer,
|
| 581 |
-
)
|
| 582 |
-
|
| 583 |
-
if self.bot.user:
|
| 584 |
-
embed.set_thumbnail(url=self.bot.user.display_avatar.url)
|
| 585 |
-
|
| 586 |
-
all_commands = self._selected_commands("__all__")
|
| 587 |
-
category_stats = self._category_stats()
|
| 588 |
-
selected_label = await self._selected_label(guild_id, selected_cog)
|
| 589 |
-
selected_cmds = self._selected_commands(selected_cog)
|
| 590 |
-
visible_cmds, total_pages = self._visible_commands_page(selected_cog)
|
| 591 |
-
max_lines = 18
|
| 592 |
-
content = await self._format_commands(guild_id, visible_cmds, max_lines=max_lines)
|
| 593 |
-
|
| 594 |
-
# Add fields with beautiful decorations
|
| 595 |
-
total_guilds = len(self.bot.guilds)
|
| 596 |
-
total_members = sum((g.member_count or 0) for g in self.bot.guilds)
|
| 597 |
-
latency_ms = f"{round(self.bot.latency * 1000)}ms"
|
| 598 |
-
slash_used = self._top_level_slash_count()
|
| 599 |
-
slash_budget = f"{slash_used}/100"
|
| 600 |
-
stats_grid = quick_stats_grid(
|
| 601 |
-
[
|
| 602 |
-
("Guilds", str(total_guilds), "🌐"),
|
| 603 |
-
("Members", f"{total_members:,}", "👥"),
|
| 604 |
-
("Latency", latency_ms, "⚡"),
|
| 605 |
-
("Slash", slash_budget, "🧮"),
|
| 606 |
-
],
|
| 607 |
-
columns=2,
|
| 608 |
-
)
|
| 609 |
-
embed.add_field(name=stats_title, value=stats_grid, inline=False)
|
| 610 |
-
|
| 611 |
-
embed.add_field(
|
| 612 |
-
name=categories_title,
|
| 613 |
-
value=await self._category_value(category_stats, guild_id),
|
| 614 |
-
inline=True,
|
| 615 |
-
)
|
| 616 |
-
|
| 617 |
-
emoji = _CATEGORY_EMOJIS.get(selected_cog, "📁")
|
| 618 |
-
embed.add_field(
|
| 619 |
-
name=f"{emoji} {selected_label}",
|
| 620 |
-
value=content or translate(lang, "menu.none"),
|
| 621 |
-
inline=False,
|
| 622 |
-
)
|
| 623 |
-
|
| 624 |
-
embed.add_field(
|
| 625 |
-
name=quick_title,
|
| 626 |
-
value=quick,
|
| 627 |
-
inline=True,
|
| 628 |
-
)
|
| 629 |
-
|
| 630 |
-
embed.add_field(
|
| 631 |
-
name=tips_title,
|
| 632 |
-
value=tips,
|
| 633 |
-
inline=True,
|
| 634 |
-
)
|
| 635 |
-
|
| 636 |
-
embed.add_field(
|
| 637 |
-
name=updates_title,
|
| 638 |
-
value=updates,
|
| 639 |
-
inline=False,
|
| 640 |
)
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
)
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
guild_id = interaction.guild.id if interaction.guild else None
|
| 678 |
-
|
| 679 |
-
self.
|
| 680 |
-
await self.parent_view.
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
self.
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
self.parent_view.
|
| 700 |
-
self.parent_view.
|
| 701 |
-
await self.parent_view.
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
self.
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
await self.parent_view.
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
async def cog_load(self) -> None:
|
| 733 |
-
self.bot.add_view(CommandsMenuView(self))
|
| 734 |
-
|
| 735 |
-
@commands.hybrid_command(name="menu", description="Bot menu |
|
| 736 |
-
async def menu(self, ctx: commands.Context) -> None:
|
| 737 |
-
"""Display the beautiful command menu."""
|
| 738 |
-
if ctx.interaction and not ctx.interaction.response.is_done():
|
| 739 |
-
await ctx.interaction.response.defer()
|
| 740 |
-
guild_id = ctx.guild.id if ctx.guild else None
|
| 741 |
-
view = CommandsMenuView(self.bot, guild_id)
|
| 742 |
-
await view.setup_items()
|
| 743 |
-
embed = await view.build_embed(guild_id, "__all__")
|
| 744 |
-
if ctx.interaction:
|
| 745 |
-
await ctx.interaction.followup.send(embed=embed, view=view)
|
| 746 |
-
else:
|
| 747 |
-
await ctx.reply(embed=embed, view=view)
|
| 748 |
-
|
| 749 |
-
@commands.hybrid_command(name="start", description="Start menu |
|
| 750 |
-
async def start_menu(self, ctx: commands.Context) -> None:
|
| 751 |
-
await self.menu(ctx)
|
| 752 |
-
|
| 753 |
-
async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None:
|
| 754 |
-
embed = ImperialMotaz.craft_embed(
|
| 755 |
-
title="
|
| 756 |
-
description=f"
|
| 757 |
-
color=discord.Color(0x2B2D31),
|
| 758 |
-
footer="
|
| 759 |
-
)
|
| 760 |
-
try:
|
| 761 |
-
await ctx.reply(embed=embed)
|
| 762 |
-
except Exception:
|
| 763 |
-
if ctx.channel:
|
| 764 |
-
await ctx.channel.send(embed=embed)
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
async def setup(bot: commands.Bot) -> None:
|
| 768 |
-
await bot.add_cog(Menu(bot))
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Menu cog: Interactive command explorer with beautiful panels.
|
| 3 |
+
Enhanced with stunning decorations, rich formatting, and multi-language support.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import random
|
| 7 |
+
|
| 8 |
+
import discord
|
| 9 |
+
from discord.ext import commands
|
| 10 |
+
|
| 11 |
+
from bot.cogs.ai_suite import ImperialMotaz
|
| 12 |
+
from bot.emojis import ui, E_DIAMOND, E_STAR, E_FIRE, E_SPARKLE, E_GEM, E_CROWN
|
| 13 |
+
from bot.theme import (
|
| 14 |
+
fancy_header, pick_neon_color, progress_bar, shimmer, panel_divider,
|
| 15 |
+
NEON_CYAN, NEON_PINK, NEON_PURPLE, NEON_LIME, NEON_YELLOW, NEON_BLUE,
|
| 16 |
+
NEON_GOLD, NEON_MAGENTA,
|
| 17 |
+
double_line, triple_line, mega_title, fancy_divider, animated_header,
|
| 18 |
+
gradient_header, panel_title, section_header, quick_stats_grid
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# Beautiful unicode emojis for select menu categories
|
| 23 |
+
_CATEGORY_EMOJIS = {
|
| 24 |
+
"Music": "🎵",
|
| 25 |
+
"Admin": "🛡ï¸",
|
| 26 |
+
"Fun": "🎮",
|
| 27 |
+
"AI": "🤖",
|
| 28 |
+
"Utility": "🔧",
|
| 29 |
+
"Config": "âš™ï¸",
|
| 30 |
+
"Economy": "💰",
|
| 31 |
+
"Moderation": "âš”ï¸",
|
| 32 |
+
"Tickets": "🎫",
|
| 33 |
+
"Welcome": "👋",
|
| 34 |
+
"Giveaway": "ðŸŽ",
|
| 35 |
+
"Verification": "✅",
|
| 36 |
+
"Tournament": "ðŸ†",
|
| 37 |
+
"Games": "🎲",
|
| 38 |
+
"Level": "✅",
|
| 39 |
+
"AutoMod": "🤖",
|
| 40 |
+
"Logs": "📋",
|
| 41 |
+
"DJ": "🎧",
|
| 42 |
+
"Developer": "💻",
|
| 43 |
+
"default": "ðŸ“",
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
# Category descriptions with emojis
|
| 47 |
+
_CATEGORY_DESCRIPTIONS = {
|
| 48 |
+
"Music": "🎵 Music commands for playback control",
|
| 49 |
+
"Admin": "ðŸ›¡ï¸ Server administration tools",
|
| 50 |
+
"Fun": "🎮 Fun games and entertainment",
|
| 51 |
+
"AI": "🤖 AI-powered features",
|
| 52 |
+
"Utility": "🔧 Useful utility commands",
|
| 53 |
+
"Config": "âš™ï¸ Bot configuration settings",
|
| 54 |
+
"Economy": "💰 Economy and currency system",
|
| 55 |
+
"Moderation": "âš”ï¸ Moderation commands",
|
| 56 |
+
"Tickets": "🎫 Ticket support system",
|
| 57 |
+
"Welcome": "👋 Welcome and goodbye messages",
|
| 58 |
+
"Giveaway": "🎠Giveaway management",
|
| 59 |
+
"Verification": "✅ Member verification",
|
| 60 |
+
"Tournament": "🆠Tournament brackets",
|
| 61 |
+
"Games": "🎲 Mini games and fun",
|
| 62 |
+
"Level": "✅ XP and leveling system",
|
| 63 |
+
"AutoMod": "🤖 Auto-moderation features",
|
| 64 |
+
"Logs": "📋 Logging configuration",
|
| 65 |
+
"DJ": "🎧 DJ music controls",
|
| 66 |
+
"Developer": "💻 Developer tools",
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
_CATEGORY_BILINGUAL = {
|
| 70 |
+
"__all__": "📚 All Commands | جميع الأوامر",
|
| 71 |
+
"__ai__": "🤖 AI | الذكاء الاصطناعي",
|
| 72 |
+
"Music": "🎵 Music | الموسيقى",
|
| 73 |
+
"Admin": "ðŸ›¡ï¸ Admin | الإدارة",
|
| 74 |
+
"Fun": "🎮 Games | الألعاب",
|
| 75 |
+
"AI": "🤖 AI | الذكاء الاصطناعي",
|
| 76 |
+
"Utility": "🔧 Utility | الأدوات",
|
| 77 |
+
"Config": "âš™ï¸ Config | الإعدادات",
|
| 78 |
+
"Economy": "💰 Economy | الاقتصاد",
|
| 79 |
+
"Moderation": "âš”ï¸ Moderation | الإشراÙ",
|
| 80 |
+
"Community": "💡 Community | المجتمع",
|
| 81 |
+
"AISuite": "🤖 AI Suite | الذكاء",
|
| 82 |
+
"Configuration": "âš™ï¸ Configuration | الإعدادات",
|
| 83 |
+
"Events": "📋 Events | Ø§Ù„Ø£ØØ¯Ø§Ø«",
|
| 84 |
+
"Verification": "✅ Verification | التØÙ‚Ù‚",
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _bilingual_category(name: str) -> str:
|
| 89 |
+
return _CATEGORY_BILINGUAL.get(name, f"📠{name} | {name}")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class CommandsMenuSelect(discord.ui.Select):
|
| 93 |
+
"""Beautiful dropdown menu for selecting command categories."""
|
| 94 |
+
|
| 95 |
+
def __init__(
|
| 96 |
+
self,
|
| 97 |
+
cog_names: list[str],
|
| 98 |
+
all_label: str,
|
| 99 |
+
ai_label: str,
|
| 100 |
+
*,
|
| 101 |
+
placeholder: str,
|
| 102 |
+
all_desc: str,
|
| 103 |
+
ai_desc: str,
|
| 104 |
+
cog_desc_map: dict[str, str],
|
| 105 |
+
selected_cog: str = "__all__",
|
| 106 |
+
) -> None:
|
| 107 |
+
|
| 108 |
+
options = [
|
| 109 |
+
discord.SelectOption(
|
| 110 |
+
label=_bilingual_category("__all__"),
|
| 111 |
+
description=all_desc,
|
| 112 |
+
emoji=ui("book"),
|
| 113 |
+
value="__all__",
|
| 114 |
+
default=selected_cog == "__all__",
|
| 115 |
+
),
|
| 116 |
+
discord.SelectOption(
|
| 117 |
+
label=_bilingual_category("__ai__"),
|
| 118 |
+
description=ai_desc,
|
| 119 |
+
emoji=ui("robot"),
|
| 120 |
+
value="__ai__",
|
| 121 |
+
default=selected_cog == "__ai__",
|
| 122 |
+
),
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
# Add cog options with beautiful emojis
|
| 126 |
+
for name in sorted(cog_names):
|
| 127 |
+
emoji = _CATEGORY_EMOJIS.get(name, _CATEGORY_EMOJIS["default"])
|
| 128 |
+
desc = cog_desc_map.get(name, _CATEGORY_DESCRIPTIONS.get(name, f"{name} commands"))
|
| 129 |
+
# Truncate description to fit Discord's limits
|
| 130 |
+
desc = desc[:100]
|
| 131 |
+
options.append(
|
| 132 |
+
discord.SelectOption(
|
| 133 |
+
label=_bilingual_category(name)[:100],
|
| 134 |
+
description=desc,
|
| 135 |
+
emoji=emoji,
|
| 136 |
+
value=name,
|
| 137 |
+
default=selected_cog == name,
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
super().__init__(
|
| 142 |
+
placeholder=placeholder,
|
| 143 |
+
min_values=1,
|
| 144 |
+
max_values=1,
|
| 145 |
+
options=options
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
async def callback(self, interaction: discord.Interaction) -> None:
|
| 149 |
+
await interaction.response.defer()
|
| 150 |
+
view = self.view
|
| 151 |
+
if not isinstance(view, CommandsMenuView):
|
| 152 |
+
return
|
| 153 |
+
selected = self.values[0]
|
| 154 |
+
view.selected_cog = selected
|
| 155 |
+
view.page = 0
|
| 156 |
+
await view.setup_items()
|
| 157 |
+
embed = await view.build_embed(interaction.guild.id if interaction.guild else None, selected)
|
| 158 |
+
await interaction.followup.edit_message(interaction.message.id, embed=embed, view=view)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
class CommandsMenuView(discord.ui.View):
|
| 162 |
+
"""Beautiful command menu view with rich decorations."""
|
| 163 |
+
|
| 164 |
+
def __init__(self, bot: commands.Bot, guild_id: int | None = None) -> None:
|
| 165 |
+
super().__init__(timeout=None)
|
| 166 |
+
self.bot = bot
|
| 167 |
+
self.guild_id = guild_id
|
| 168 |
+
self.selected_cog = "__all__"
|
| 169 |
+
self.page = 0
|
| 170 |
+
self.page_size = 18
|
| 171 |
+
self._accent_seed = random.randrange(0, 1024)
|
| 172 |
+
self._color_palette = [NEON_CYAN, NEON_PINK, NEON_PURPLE, NEON_LIME, NEON_GOLD, NEON_MAGENTA]
|
| 173 |
+
|
| 174 |
+
async def setup_items(self) -> None:
|
| 175 |
+
"""Setup the menu items."""
|
| 176 |
+
cog_names = list(self.bot.cogs.keys())
|
| 177 |
+
all_label = await self.bot.get_text(self.guild_id, "menu.all")
|
| 178 |
+
ai_label = await self.bot.get_text(self.guild_id, "menu.ai")
|
| 179 |
+
placeholder = await self.bot.get_text(self.guild_id, "menu.select_placeholder")
|
| 180 |
+
all_desc = await self.bot.get_text(self.guild_id, "menu.select_all_desc")
|
| 181 |
+
ai_desc = await self.bot.get_text(self.guild_id, "menu.select_ai_desc")
|
| 182 |
+
cog_desc_map: dict[str, str] = {}
|
| 183 |
+
for name in cog_names:
|
| 184 |
+
key = f"menu.category_desc.{name.lower()}"
|
| 185 |
+
desc = await self.bot.get_text(self.guild_id, key)
|
| 186 |
+
if desc == key:
|
| 187 |
+
desc = _CATEGORY_DESCRIPTIONS.get(name, f"📠{name} commands")
|
| 188 |
+
cog_desc_map[name] = desc
|
| 189 |
+
self.clear_items()
|
| 190 |
+
self.add_item(
|
| 191 |
+
CommandsMenuSelect(
|
| 192 |
+
cog_names,
|
| 193 |
+
all_label,
|
| 194 |
+
ai_label,
|
| 195 |
+
placeholder=placeholder,
|
| 196 |
+
all_desc=all_desc,
|
| 197 |
+
ai_desc=ai_desc,
|
| 198 |
+
cog_desc_map=cog_desc_map,
|
| 199 |
+
selected_cog=self.selected_cog,
|
| 200 |
+
)
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
refresh_label = await self.bot.get_text(self.guild_id, "menu.refresh")
|
| 204 |
+
invite_label = await self.bot.get_text(self.guild_id, "menu.invite_button")
|
| 205 |
+
self.add_item(RefreshButton(self, refresh_label))
|
| 206 |
+
self.add_item(QuickCategoryButton(self, "Economy | الاقتصاد", "Economy", "💰", row=1))
|
| 207 |
+
self.add_item(QuickCategoryButton(self, "Music | الموسيقى", "Music", "🎵", row=1))
|
| 208 |
+
self.add_item(QuickCategoryButton(self, "Admin | الإدارة", "Admin", "🛡ï¸", row=1))
|
| 209 |
+
self.add_item(QuickCategoryButton(self, "Utility | الأدوات", "Utility", "ℹï¸", row=1))
|
| 210 |
+
self.add_item(QuickCategoryButton(self, "Community | المجتمع", "Community", "💡", row=2))
|
| 211 |
+
self.add_item(QuickCategoryButton(self, "AI | الذكاء", "AISuite", "🤖", row=2))
|
| 212 |
+
self.add_item(QuickCategoryButton(self, "Config | الإعداد", "Configuration", "âš™ï¸", row=2))
|
| 213 |
+
if self.page > 0:
|
| 214 |
+
self.add_item(PageButton(self, "prev"))
|
| 215 |
+
if self._has_next_page(self.selected_cog):
|
| 216 |
+
self.add_item(PageButton(self, "next"))
|
| 217 |
+
|
| 218 |
+
if self.bot.user:
|
| 219 |
+
self.add_item(InviteButton(invite_label, self.bot.user.id))
|
| 220 |
+
|
| 221 |
+
def _desc_key(self, qualified_name: str) -> str:
|
| 222 |
+
"""Get the translation key for a command description."""
|
| 223 |
+
norm = qualified_name.strip().lower().replace(" ", "_")
|
| 224 |
+
key_mapping = {
|
| 225 |
+
# Music commands
|
| 226 |
+
"play": "menu.cmd.play",
|
| 227 |
+
"music_play": "menu.cmd.music_play",
|
| 228 |
+
"music_panel": "menu.cmd.music_panel",
|
| 229 |
+
"music_skip": "menu.cmd.music_skip",
|
| 230 |
+
"music_stop": "menu.cmd.music_stop",
|
| 231 |
+
"music_pause": "menu.cmd.music_pause",
|
| 232 |
+
"music_resume": "menu.cmd.music_resume",
|
| 233 |
+
"music_queue": "menu.cmd.music_queue",
|
| 234 |
+
"music_playlist": "menu.cmd.music_playlist",
|
| 235 |
+
"music_playlist_save": "menu.cmd.music_playlist_save",
|
| 236 |
+
"music_playlist_rename": "menu.cmd.music_playlist_rename",
|
| 237 |
+
"music_playlist_delete": "menu.cmd.music_playlist_delete",
|
| 238 |
+
"music_volume": "menu.cmd.music_volume",
|
| 239 |
+
"music_nowplaying": "menu.cmd.music_nowplaying",
|
| 240 |
+
"music_filter": "menu.cmd.music_filter",
|
| 241 |
+
"music_loop": "menu.cmd.music_loop",
|
| 242 |
+
"music_shuffle": "menu.cmd.music_shuffle",
|
| 243 |
+
"music_247": "menu.cmd.music_247",
|
| 244 |
+
"music_previous": "menu.cmd.music_previous",
|
| 245 |
+
"music_seek": "menu.cmd.music_seek",
|
| 246 |
+
"music_clear": "menu.cmd.music_clear",
|
| 247 |
+
"music_remove": "menu.cmd.music_remove",
|
| 248 |
+
"music_move": "menu.cmd.music_move",
|
| 249 |
+
"music_jump": "menu.cmd.music_jump",
|
| 250 |
+
"music_lyrics": "menu.cmd.music_lyrics",
|
| 251 |
+
|
| 252 |
+
# Admin commands
|
| 253 |
+
"purge": "menu.cmd.purge",
|
| 254 |
+
"ban": "menu.cmd.ban",
|
| 255 |
+
"unban": "menu.cmd.unban",
|
| 256 |
+
"kick": "menu.cmd.kick",
|
| 257 |
+
"mute": "menu.cmd.mute",
|
| 258 |
+
"unmute": "menu.cmd.unmute",
|
| 259 |
+
"warn": "menu.cmd.warn",
|
| 260 |
+
"warnings": "menu.cmd.warnings",
|
| 261 |
+
"clearwarn": "menu.cmd.clearwarn",
|
| 262 |
+
"slowmode": "menu.cmd.slowmode",
|
| 263 |
+
"lock": "menu.cmd.lock",
|
| 264 |
+
"unlock": "menu.cmd.unlock",
|
| 265 |
+
"cloneemoji": "menu.cmd.cloneemoji",
|
| 266 |
+
"awesomeroles": "menu.cmd.awesomeroles",
|
| 267 |
+
"backupserver": "menu.cmd.backupserver",
|
| 268 |
+
|
| 269 |
+
# Fun commands
|
| 270 |
+
"8ball": "menu.cmd.8ball",
|
| 271 |
+
"meme": "menu.cmd.meme",
|
| 272 |
+
"trivia": "menu.cmd.trivia",
|
| 273 |
+
"gaming_news": "menu.cmd.gaming_news",
|
| 274 |
+
"free_games": "menu.cmd.free_games",
|
| 275 |
+
"gamehub": "menu.cmd.gamehub",
|
| 276 |
+
"xo": "menu.cmd.xo",
|
| 277 |
+
"choose": "menu.cmd.choose",
|
| 278 |
+
"mario": "menu.cmd.mario",
|
| 279 |
+
"dice": "menu.cmd.dice",
|
| 280 |
+
"slots": "menu.cmd.slots",
|
| 281 |
+
"coinflip": "menu.cmd.coinflip",
|
| 282 |
+
"roll": "menu.cmd.roll",
|
| 283 |
+
|
| 284 |
+
# AI commands
|
| 285 |
+
"chat": "menu.cmd.chat",
|
| 286 |
+
"ask_image": "menu.cmd.ask_image",
|
| 287 |
+
"imagine": "menu.cmd.imagine",
|
| 288 |
+
"image_gen": "menu.cmd.image_gen",
|
| 289 |
+
"upscale": "menu.cmd.upscale",
|
| 290 |
+
"summarize": "menu.cmd.summarize",
|
| 291 |
+
"ai": "menu.cmd.ai",
|
| 292 |
+
"ai_chat": "menu.cmd.chat",
|
| 293 |
+
"ai_ask_image": "menu.cmd.ask_image",
|
| 294 |
+
"ai_imagine": "menu.cmd.imagine",
|
| 295 |
+
"ai_image_gen": "menu.cmd.image_gen",
|
| 296 |
+
"ai_upscale": "menu.cmd.upscale",
|
| 297 |
+
"ai_summarize": "menu.cmd.summarize",
|
| 298 |
+
"ai_debug": "menu.cmd.debug",
|
| 299 |
+
"ai_code_gen": "menu.cmd.code_gen",
|
| 300 |
+
"ai_voice": "menu.cmd.speak",
|
| 301 |
+
"ai_speak": "menu.cmd.speak",
|
| 302 |
+
"ai_tts": "menu.cmd.tts",
|
| 303 |
+
"ai_translate_voice": "menu.cmd.translate_voice",
|
| 304 |
+
"ai_model": "menu.cmd.ai_model",
|
| 305 |
+
"ai_channel": "menu.cmd.ai_channel",
|
| 306 |
+
"ai_auto": "menu.cmd.ai_auto",
|
| 307 |
+
"ai_setup": "menu.cmd.ai",
|
| 308 |
+
"ai_execute": "menu.cmd.ai_execute",
|
| 309 |
+
|
| 310 |
+
# Utility commands
|
| 311 |
+
"serverinfo": "menu.cmd.serverinfo",
|
| 312 |
+
"userinfo": "menu.cmd.userinfo",
|
| 313 |
+
"botstats": "menu.cmd.botstats",
|
| 314 |
+
"poll": "menu.cmd.poll",
|
| 315 |
+
"remind": "menu.cmd.remind",
|
| 316 |
+
"avatar": "menu.cmd.avatar",
|
| 317 |
+
"banner": "menu.cmd.banner",
|
| 318 |
+
"translate": "menu.cmd.translate",
|
| 319 |
+
|
| 320 |
+
# Config commands
|
| 321 |
+
"set": "menu.cmd.set",
|
| 322 |
+
"set_log": "menu.cmd.set_log",
|
| 323 |
+
"set_welcome": "menu.cmd.set_welcome",
|
| 324 |
+
"set_suggestions": "menu.cmd.set_suggestions",
|
| 325 |
+
"set_automod": "menu.cmd.set_automod",
|
| 326 |
+
"set_dailymessage": "menu.cmd.set_dailymessage",
|
| 327 |
+
"set_dailychannel": "menu.cmd.set_dailychannel",
|
| 328 |
+
"set_dailytime": "menu.cmd.set_dailytime",
|
| 329 |
+
"set_dailytitle": "menu.cmd.set_dailytitle",
|
| 330 |
+
"set_dailyimage": "menu.cmd.set_dailyimage",
|
| 331 |
+
"set_dailybutton": "menu.cmd.set_dailybutton",
|
| 332 |
+
"set_dailytoggle": "menu.cmd.set_dailytoggle",
|
| 333 |
+
"set_pollchannel": "menu.cmd.set_pollchannel",
|
| 334 |
+
"set_freegames": "menu.cmd.set_freegames",
|
| 335 |
+
"set_gamenews": "menu.cmd.set_gamenews",
|
| 336 |
+
"set_supportai": "menu.cmd.set_supportai",
|
| 337 |
+
"set_wisdom": "menu.cmd.set_wisdom",
|
| 338 |
+
|
| 339 |
+
# Language commands
|
| 340 |
+
"language": "menu.cmd.language",
|
| 341 |
+
"languages": "menu.cmd.languages",
|
| 342 |
+
|
| 343 |
+
# Menu commands
|
| 344 |
+
"menu": "menu.cmd.menu",
|
| 345 |
+
"ping": "menu.cmd.ping",
|
| 346 |
+
"load": "menu.cmd.load",
|
| 347 |
+
"unload": "menu.cmd.unload",
|
| 348 |
+
"reload": "menu.cmd.reload",
|
| 349 |
+
"sync": "menu.cmd.sync",
|
| 350 |
+
"shutdown": "menu.cmd.shutdown",
|
| 351 |
+
|
| 352 |
+
# Verification commands
|
| 353 |
+
"verify": "menu.cmd.verify",
|
| 354 |
+
"verifysetup": "menu.cmd.verifysetup",
|
| 355 |
+
"verify_config": "menu.cmd.verify",
|
| 356 |
+
"verify_config_panel": "menu.cmd.verify",
|
| 357 |
+
"verify_config_setup": "menu.cmd.verifysetup",
|
| 358 |
+
|
| 359 |
+
# Economy commands
|
| 360 |
+
"economy": "menu.cmd.economy",
|
| 361 |
+
"economy_deposit": "menu.cmd.economy_deposit",
|
| 362 |
+
"economy_withdraw": "menu.cmd.economy_withdraw",
|
| 363 |
+
"economy_gamble": "menu.cmd.economy_gamble",
|
| 364 |
+
"economy_rob": "menu.cmd.economy_rob",
|
| 365 |
+
"balance": "menu.cmd.balance",
|
| 366 |
+
"daily": "menu.cmd.daily",
|
| 367 |
+
"work": "menu.cmd.work",
|
| 368 |
+
"gamble": "menu.cmd.gamble",
|
| 369 |
+
"rob": "menu.cmd.rob",
|
| 370 |
+
"leaderboard": "menu.cmd.leaderboard",
|
| 371 |
+
"shop": "menu.cmd.shop",
|
| 372 |
+
"buy": "menu.cmd.buy",
|
| 373 |
+
"inventory": "menu.cmd.inventory",
|
| 374 |
+
"transfer": "menu.cmd.transfer",
|
| 375 |
+
|
| 376 |
+
# Engagement commands
|
| 377 |
+
"xp": "menu.cmd.xp",
|
| 378 |
+
"level": "menu.cmd.level",
|
| 379 |
+
"rank": "menu.cmd.rank",
|
| 380 |
+
"suggest": "menu.cmd.suggest",
|
| 381 |
+
"suggestion": "menu.cmd.suggestion",
|
| 382 |
+
"suggestion_show": "menu.cmd.suggestion_show",
|
| 383 |
+
"suggestion_panel": "menu.cmd.suggestion_panel",
|
| 384 |
+
"suggestion_voters": "menu.cmd.suggestion_voters",
|
| 385 |
+
"giveaway_create": "menu.cmd.giveaway_create",
|
| 386 |
+
"giveaway_end": "menu.cmd.giveaway_end",
|
| 387 |
+
"ticket_panel": "menu.cmd.ticket_panel",
|
| 388 |
+
|
| 389 |
+
# Tournament commands
|
| 390 |
+
"tournament": "menu.cmd.tournament",
|
| 391 |
+
"tournament_panel": "menu.cmd.tournament_panel",
|
| 392 |
+
"tournament_create": "menu.cmd.tournament_create",
|
| 393 |
+
"tournament_join": "menu.cmd.tournament_join",
|
| 394 |
+
"tournament_start": "menu.cmd.tournament_start",
|
| 395 |
+
"tournament_end": "menu.cmd.tournament_end",
|
| 396 |
+
"tournament_gamehub": "menu.cmd.tournament_gamehub",
|
| 397 |
+
|
| 398 |
+
# Gambling commands
|
| 399 |
+
"blackjack": "menu.cmd.blackjack",
|
| 400 |
+
"roulette": "menu.cmd.roulette",
|
| 401 |
+
"rpg": "menu.cmd.rpg",
|
| 402 |
+
|
| 403 |
# AI Admin commands
|
| 404 |
"ai_admin": "menu.cmd.ai_admin",
|
| 405 |
"ai_help": "menu.cmd.ai_help",
|
| 406 |
+
# Additional commands/groups to keep menu fully mapped
|
| 407 |
+
"admin": "menu.cmd.admin_panel",
|
| 408 |
+
"admin_panel": "menu.cmd.admin_panel",
|
| 409 |
+
"shield_level": "menu.cmd.shield_level",
|
| 410 |
+
"shield_state": "menu.cmd.shield_state",
|
| 411 |
+
"econ_admin": "menu.cmd.econ_admin",
|
| 412 |
+
"econadmin": "menu.cmd.econ_admin",
|
| 413 |
+
"economy_admin": "menu.cmd.econ_admin",
|
| 414 |
+
"giveaway": "menu.cmd.giveaway",
|
| 415 |
+
"ticket": "menu.cmd.ticket",
|
| 416 |
+
"poll_legacy": "menu.cmd.poll",
|
| 417 |
+
"setpollchannel": "menu.cmd.set_pollchannel",
|
| 418 |
+
"setsuggestionchannel": "menu.cmd.set_suggestions",
|
| 419 |
+
"set_freegame": "menu.cmd.set_freegames",
|
| 420 |
+
"setupserver": "menu.cmd.setupserver",
|
| 421 |
+
"organizechannels": "menu.cmd.organizechannels",
|
| 422 |
+
"backup_panel": "menu.cmd.backup_panel",
|
| 423 |
+
"system_audit": "menu.cmd.system_audit",
|
| 424 |
+
"wisdom_today": "menu.cmd.wisdom_today",
|
| 425 |
+
"freegames": "menu.cmd.free_games",
|
| 426 |
+
"playlists": "menu.cmd.music_playlist",
|
| 427 |
+
"music": "menu.cmd.music_panel",
|
| 428 |
+
"profile": "menu.cmd.profile",
|
| 429 |
+
"rps": "menu.cmd.rps",
|
| 430 |
+
"guess": "menu.cmd.guess",
|
| 431 |
+
"make_event": "menu.cmd.make_event",
|
| 432 |
+
"gambling_panel": "menu.cmd.gambling_panel",
|
| 433 |
+
"add_scam_image": "menu.cmd.add_scam_image",
|
| 434 |
+
"set_banner": "menu.cmd.set_banner",
|
| 435 |
+
"view_banner": "menu.cmd.view_banner",
|
| 436 |
+
"remove_banner": "menu.cmd.remove_banner",
|
| 437 |
+
"banner_help": "menu.cmd.banner_help",
|
| 438 |
+
"boardgames": "menu.cmd.boardgames",
|
| 439 |
+
"board_start": "menu.cmd.board_start",
|
| 440 |
+
"board_move": "menu.cmd.board_move",
|
| 441 |
+
"board_forfeit": "menu.cmd.board_forfeit",
|
| 442 |
+
"games_panel": "menu.cmd.games_panel",
|
| 443 |
+
"chess": "menu.cmd.chess",
|
| 444 |
+
"checkers": "menu.cmd.checkers",
|
| 445 |
+
"connect4": "menu.cmd.connect4",
|
| 446 |
+
"othello": "menu.cmd.othello",
|
| 447 |
+
"start": "menu.cmd.menu",
|
| 448 |
+
"command_fill": "menu.cmd.menu",
|
| 449 |
+
"tournament_lb": "menu.cmd.tournament_lb",
|
| 450 |
+
"aisetup": "menu.cmd.ai",
|
| 451 |
}
|
| 452 |
+
return key_mapping.get(norm, f"menu.cmd.{norm}")
|
| 453 |
+
|
| 454 |
+
async def _format_commands(self, guild_id: int | None, cmds: list[commands.Command], max_lines: int = 20, max_chars: int = 980) -> str:
|
| 455 |
+
"""Format commands list with beautiful decorations."""
|
| 456 |
+
lines: list[str] = []
|
| 457 |
+
used = 0
|
| 458 |
+
|
| 459 |
+
for idx, cmd in enumerate(sorted(cmds, key=lambda c: c.qualified_name), start=1):
|
| 460 |
+
if cmd.hidden:
|
| 461 |
+
continue
|
| 462 |
+
|
| 463 |
+
desc_key = self._desc_key(cmd.qualified_name)
|
| 464 |
+
translated_desc = await self.bot.get_text(guild_id, desc_key)
|
| 465 |
+
json_key = ""
|
| 466 |
+
if translated_desc == desc_key:
|
| 467 |
+
cog_key = (cmd.cog_name or "").lower()
|
| 468 |
+
json_key = f"commands.{cog_key}.{cmd.name}_desc" if cog_key else ""
|
| 469 |
+
if json_key:
|
| 470 |
+
translated_desc = await self.bot.get_text(guild_id, json_key)
|
| 471 |
+
|
| 472 |
+
if translated_desc == desc_key or (json_key and translated_desc == json_key):
|
| 473 |
+
fallback_desc = (cmd.description or cmd.help or "").strip()
|
| 474 |
+
desc = fallback_desc if fallback_desc else "-"
|
| 475 |
+
else:
|
| 476 |
+
desc = translated_desc
|
| 477 |
+
|
| 478 |
+
is_hybrid = isinstance(cmd, commands.HybridCommand)
|
| 479 |
+
invoke = f"`/{cmd.qualified_name}`" if is_hybrid else f"`!{cmd.qualified_name}`"
|
| 480 |
+
line = f"- {invoke} - {desc[:78]}"
|
| 481 |
+
projected = used + len(line) + (1 if lines else 0)
|
| 482 |
+
|
| 483 |
+
if projected > max_chars:
|
| 484 |
+
break
|
| 485 |
+
lines.append(line)
|
| 486 |
+
used = projected
|
| 487 |
+
|
| 488 |
+
if len(lines) >= max_lines:
|
| 489 |
+
break
|
| 490 |
+
|
| 491 |
if len(lines) < len([c for c in cmds if not c.hidden]):
|
| 492 |
+
suffix = "\n- ------------"
|
| 493 |
if used + len(suffix) <= max_chars:
|
| 494 |
+
lines.append("- ------------")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
return "\n".join(lines)
|
| 497 |
+
|
| 498 |
+
def _visible_commands_page(self, selected_cog: str) -> tuple[list[commands.Command], int]:
|
| 499 |
+
commands_list = sorted(self._selected_commands(selected_cog), key=lambda c: c.qualified_name)
|
| 500 |
+
if not commands_list:
|
| 501 |
+
return [], 1
|
| 502 |
+
total_pages = max(1, (len(commands_list) + self.page_size - 1) // self.page_size)
|
| 503 |
+
if self.page >= total_pages:
|
| 504 |
+
self.page = total_pages - 1
|
| 505 |
+
start = self.page * self.page_size
|
| 506 |
+
return commands_list[start:start + self.page_size], total_pages
|
| 507 |
+
|
| 508 |
+
def _has_next_page(self, selected_cog: str) -> bool:
|
| 509 |
+
commands_list = self._selected_commands(selected_cog)
|
| 510 |
+
total_pages = max(1, (len(commands_list) + self.page_size - 1) // self.page_size)
|
| 511 |
+
return self.page + 1 < total_pages
|
| 512 |
+
|
| 513 |
+
def _category_stats(self) -> list[tuple[str, int]]:
|
| 514 |
+
"""Get category statistics."""
|
| 515 |
+
stats: list[tuple[str, int]] = []
|
| 516 |
+
for name, cog in self.bot.cogs.items():
|
| 517 |
+
cog_commands = [c for c in (cog.get_commands() if cog else []) if not c.hidden]
|
| 518 |
+
if cog_commands:
|
| 519 |
+
stats.append((name, len(cog_commands)))
|
| 520 |
+
return sorted(stats, key=lambda pair: (-pair[1], pair[0]))
|
| 521 |
+
|
| 522 |
+
async def _category_value(self, stats: list[tuple[str, int]], guild_id: int | None) -> str:
|
| 523 |
+
"""Format category statistics with beautiful decorations."""
|
| 524 |
+
lang = await self.bot.get_guild_language(guild_id)
|
| 525 |
+
if not stats:
|
| 526 |
+
return await self.bot.get_text(guild_id, "menu.none")
|
| 527 |
+
|
| 528 |
+
lines: list[str] = []
|
| 529 |
+
for index, (name, count) in enumerate(stats[:8], start=1):
|
| 530 |
+
badge = {1: "🥇", 2: "🥈", 3: "🥉"}.get(index, f"#{index}")
|
| 531 |
+
emoji = _CATEGORY_EMOJIS.get(name, "ðŸ“")
|
| 532 |
+
|
| 533 |
+
if lang == "ar":
|
| 534 |
+
lines.append(f"{badge} {emoji} **{name}** — `{count}` أمر")
|
| 535 |
+
else:
|
| 536 |
+
lines.append(f"{badge} {emoji} **{name}** — `{count}` cmds")
|
| 537 |
+
|
| 538 |
+
if len(stats) > 8:
|
| 539 |
+
lines.append("✦ ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈")
|
| 540 |
+
|
| 541 |
+
return "\n".join(lines)
|
| 542 |
+
|
| 543 |
+
async def _stats_text(
|
| 544 |
+
self,
|
| 545 |
+
guild_id: int | None,
|
| 546 |
+
total_commands: int,
|
| 547 |
+
category_count: int,
|
| 548 |
+
selection_label: str,
|
| 549 |
+
visible_commands: int,
|
| 550 |
+
) -> str:
|
| 551 |
+
"""Format statistics with beautiful decorations."""
|
| 552 |
+
lang = await self.bot.get_guild_language(guild_id)
|
| 553 |
+
|
| 554 |
+
if lang == "ar":
|
| 555 |
+
pieces = [
|
| 556 |
+
f"📊 **إجمالي الأوامر:** `{total_commands}`",
|
| 557 |
+
f"📠**Ø§Ù„ÙØ¦Ø§Øª:** `{category_count}`",
|
| 558 |
+
f"✨ **Ø§Ù„Ù…ØØ¯Ø¯:** {selection_label} (`{visible_commands}` أمر)",
|
| 559 |
+
f"📈 {progress_bar(visible_commands, total_commands, size=14)}",
|
| 560 |
+
]
|
| 561 |
+
else:
|
| 562 |
+
pieces = [
|
| 563 |
+
f"📊 **Total Commands:** `{total_commands}`",
|
| 564 |
+
f"📠**Categories:** `{category_count}`",
|
| 565 |
+
f"✨ **Selected:** {selection_label} (`{visible_commands}` cmds)",
|
| 566 |
+
f"📈 {progress_bar(visible_commands, total_commands, size=14)}",
|
| 567 |
+
]
|
| 568 |
+
return "\n".join(pieces)
|
| 569 |
+
|
| 570 |
+
async def _selected_label(self, guild_id: int | None, selected_cog: str) -> str:
|
| 571 |
+
"""Get the label for the selected category."""
|
| 572 |
+
return _bilingual_category(selected_cog)
|
| 573 |
+
|
| 574 |
+
def _selected_commands(self, selected_cog: str) -> list[commands.Command]:
|
| 575 |
+
"""Get commands for the selected category."""
|
| 576 |
+
def _flatten(cmds: list[commands.Command]) -> list[commands.Command]:
|
| 577 |
+
collected: list[commands.Command] = []
|
| 578 |
+
for command in cmds:
|
| 579 |
+
if command.hidden:
|
| 580 |
+
continue
|
| 581 |
+
collected.append(command)
|
| 582 |
+
if isinstance(command, commands.Group):
|
| 583 |
+
collected.extend([sub for sub in command.walk_commands() if not sub.hidden])
|
| 584 |
+
unique: dict[str, commands.Command] = {c.qualified_name: c for c in collected}
|
| 585 |
+
return list(unique.values())
|
| 586 |
+
|
| 587 |
+
if selected_cog == "__all__":
|
| 588 |
+
return _flatten(list(self.bot.commands))
|
| 589 |
+
if selected_cog == "__ai__":
|
| 590 |
+
ai_cog = self.bot.get_cog("AISuite")
|
| 591 |
+
return _flatten(list(ai_cog.get_commands() if ai_cog else []))
|
| 592 |
+
cog = self.bot.get_cog(selected_cog)
|
| 593 |
+
return _flatten(list(cog.get_commands() if cog else []))
|
| 594 |
+
|
| 595 |
+
def _top_level_slash_count(self) -> int:
|
| 596 |
+
"""Count top-level slash-registered hybrid commands/groups (Discord limit: 100)."""
|
| 597 |
+
total = 0
|
| 598 |
+
for cmd in self.bot.commands:
|
| 599 |
+
if not isinstance(cmd, commands.HybridCommand):
|
| 600 |
+
continue
|
| 601 |
+
if getattr(cmd, "with_app_command", True):
|
| 602 |
+
total += 1
|
| 603 |
+
return total
|
| 604 |
+
|
| 605 |
+
async def build_embed(self, guild_id: int | None, selected_cog: str) -> discord.Embed:
|
| 606 |
+
"""Build the beautiful embed for the menu."""
|
| 607 |
+
color = discord.Color(0x2B2D31)
|
| 608 |
+
|
| 609 |
+
hub_welcome = await self.bot.get_text(guild_id, "menu.hub_welcome")
|
| 610 |
+
hub_explore = await self.bot.get_text(guild_id, "menu.hub_explore")
|
| 611 |
+
hub_usage = await self.bot.get_text(guild_id, "menu.hub_usage")
|
| 612 |
+
stats_title = await self.bot.get_text(guild_id, "menu.stats_heading")
|
| 613 |
+
categories_title = await self.bot.get_text(guild_id, "menu.categories_heading")
|
| 614 |
+
tips_title = await self.bot.get_text(guild_id, "menu.tips_heading")
|
| 615 |
+
quick_title = await self.bot.get_text(guild_id, "menu.quick_heading")
|
| 616 |
+
updates_title = await self.bot.get_text(guild_id, "menu.updates_heading")
|
| 617 |
+
|
| 618 |
+
title = "BOT- AI System"
|
| 619 |
+
desc = f"{hub_welcome}\n{hub_explore}\n{hub_usage}"
|
| 620 |
+
tips = (
|
| 621 |
+
f"? {await self.bot.get_text(guild_id, 'menu.tip_line_1')}\n"
|
| 622 |
+
f"? {await self.bot.get_text(guild_id, 'menu.tip_line_2')}\n"
|
| 623 |
+
f"? {await self.bot.get_text(guild_id, 'menu.tip_line_3')}"
|
| 624 |
+
)
|
| 625 |
quick = (
|
| 626 |
+
f"- `/music_panel` - {await self.bot.get_text(guild_id, 'menu.quick_music')}\n"
|
| 627 |
+
f"- `/gamehub` - {await self.bot.get_text(guild_id, 'menu.quick_gamehub')}\n"
|
| 628 |
+
f"- `/tournament panel` - {await self.bot.get_text(guild_id, 'menu.quick_tournament')}\n"
|
| 629 |
+
f"- `/economy` - {await self.bot.get_text(guild_id, 'menu.quick_economy')}"
|
| 630 |
)
|
| 631 |
+
quick_ai = await self.bot.get_text(guild_id, 'menu.quick_ai')
|
| 632 |
updates = (
|
| 633 |
+
f"- `/admin emoji clone` - {await self.bot.get_text(guild_id, 'menu.update_cloneemoji')}\n"
|
| 634 |
+
f"- `/admin shield add_image` - {await self.bot.get_text(guild_id, 'menu.update_shield_image')}\n"
|
| 635 |
+
f"- `/poll create` - {await self.bot.get_text(guild_id, 'menu.update_poll_group')}\n"
|
| 636 |
+
f"- `/economy deposit` - {await self.bot.get_text(guild_id, 'menu.update_economy_group')}\n"
|
| 637 |
+
f"- `/ai execute` - {quick_ai if quick_ai != 'menu.quick_ai' else 'AI admin request'}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
)
|
| 639 |
+
footer = "Powered by BOT- AI Suite"
|
| 640 |
+
|
| 641 |
+
top_divider = panel_divider("cyan")
|
| 642 |
+
mid_divider = panel_divider("purple")
|
| 643 |
+
embed = ImperialMotaz.craft_embed(
|
| 644 |
+
title=title,
|
| 645 |
+
description=f"{top_divider}\n{desc}\n{mid_divider}",
|
| 646 |
+
color=color,
|
| 647 |
+
footer=footer,
|
| 648 |
+
)
|
| 649 |
+
|
| 650 |
+
if self.bot.user:
|
| 651 |
+
embed.set_thumbnail(url=self.bot.user.display_avatar.url)
|
| 652 |
+
|
| 653 |
+
category_stats = self._category_stats()
|
| 654 |
+
selected_label = await self._selected_label(guild_id, selected_cog)
|
| 655 |
+
visible_cmds, total_pages = self._visible_commands_page(selected_cog)
|
| 656 |
+
content = await self._format_commands(guild_id, visible_cmds, max_lines=18)
|
| 657 |
+
|
| 658 |
+
total_guilds = len(self.bot.guilds)
|
| 659 |
+
total_members = sum((g.member_count or 0) for g in self.bot.guilds)
|
| 660 |
+
latency_ms = f"{round(self.bot.latency * 1000)}ms"
|
| 661 |
+
slash_used = self._top_level_slash_count()
|
| 662 |
+
slash_budget = f"{slash_used}/100"
|
| 663 |
+
stats_grid = quick_stats_grid(
|
| 664 |
+
[
|
| 665 |
+
("Guilds", str(total_guilds), "G"),
|
| 666 |
+
("Members", f"{total_members:,}", "M"),
|
| 667 |
+
("Latency", latency_ms, "L"),
|
| 668 |
+
("Slash", slash_budget, "S"),
|
| 669 |
+
],
|
| 670 |
+
columns=2,
|
| 671 |
+
)
|
| 672 |
+
embed.add_field(name=stats_title, value=stats_grid, inline=False)
|
| 673 |
+
|
| 674 |
+
embed.add_field(
|
| 675 |
+
name=categories_title,
|
| 676 |
+
value=await self._category_value(category_stats, guild_id),
|
| 677 |
+
inline=True,
|
| 678 |
+
)
|
| 679 |
+
|
| 680 |
+
emoji = _CATEGORY_EMOJIS.get(selected_cog, "C")
|
| 681 |
+
none_text = await self.bot.get_text(guild_id, "menu.none")
|
| 682 |
+
embed.add_field(
|
| 683 |
+
name=f"{emoji} {selected_label}",
|
| 684 |
+
value=content or none_text,
|
| 685 |
+
inline=False,
|
| 686 |
+
)
|
| 687 |
+
|
| 688 |
+
embed.add_field(name=quick_title, value=quick, inline=True)
|
| 689 |
+
embed.add_field(name=tips_title, value=tips, inline=True)
|
| 690 |
+
embed.add_field(name=updates_title, value=updates, inline=False)
|
| 691 |
+
|
| 692 |
+
embed.set_footer(text=footer)
|
| 693 |
+
embed.description = f"{embed.description}\n\nPage {self.page + 1}/{total_pages}"
|
| 694 |
+
return embed
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
class MainMenuView(CommandsMenuView):
|
| 698 |
+
"""Named alias for persistent registration."""
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
class InviteButton(discord.ui.Button):
|
| 702 |
+
"""Beautiful invite button."""
|
| 703 |
+
|
| 704 |
+
def __init__(self, label: str, client_id: int) -> None:
|
| 705 |
+
url = f"https://discord.com/api/oauth2/authorize?client_id={client_id}&permissions=0&scope=applications.commands%20bot"
|
| 706 |
+
super().__init__(
|
| 707 |
+
label=label,
|
| 708 |
+
style=discord.ButtonStyle.link,
|
| 709 |
+
url=url,
|
| 710 |
+
emoji=ui("link")
|
| 711 |
+
)
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
class RefreshButton(discord.ui.Button):
|
| 715 |
+
"""Beautiful refresh button."""
|
| 716 |
+
|
| 717 |
+
def __init__(self, parent: CommandsMenuView, label: str) -> None:
|
| 718 |
+
super().__init__(
|
| 719 |
+
label=label,
|
| 720 |
+
emoji=ui("refresh"),
|
| 721 |
+
style=discord.ButtonStyle.secondary
|
| 722 |
+
)
|
| 723 |
+
self.parent_view = parent
|
| 724 |
+
|
| 725 |
+
async def callback(self, interaction: discord.Interaction) -> None:
|
| 726 |
+
await interaction.response.defer()
|
| 727 |
guild_id = interaction.guild.id if interaction.guild else None
|
| 728 |
+
self.label = await self.parent_view.bot.get_text(guild_id, "menu.refresh")
|
| 729 |
+
await self.parent_view.setup_items()
|
| 730 |
+
embed = await self.parent_view.build_embed(guild_id, "__all__")
|
| 731 |
+
await interaction.followup.edit_message(interaction.message.id, embed=embed, view=self.parent_view)
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
class QuickCategoryButton(discord.ui.Button):
|
| 735 |
+
def __init__(self, parent: CommandsMenuView, label: str, category: str, emoji: str, *, row: int = 1) -> None:
|
| 736 |
+
super().__init__(
|
| 737 |
+
label=label,
|
| 738 |
+
emoji=emoji,
|
| 739 |
+
style=discord.ButtonStyle.secondary,
|
| 740 |
+
custom_id=f"menu:quick:{label.lower()}",
|
| 741 |
+
row=row,
|
| 742 |
+
)
|
| 743 |
+
self.parent_view = parent
|
| 744 |
+
self.category = category
|
| 745 |
+
|
| 746 |
+
async def callback(self, interaction: discord.Interaction) -> None:
|
| 747 |
+
await interaction.response.defer()
|
| 748 |
+
self.parent_view.selected_cog = self.category
|
| 749 |
+
self.parent_view.page = 0
|
| 750 |
+
await self.parent_view.setup_items()
|
| 751 |
+
embed = await self.parent_view.build_embed(interaction.guild.id if interaction.guild else None, self.category)
|
| 752 |
+
await interaction.followup.edit_message(interaction.message.id, embed=embed, view=self.parent_view)
|
| 753 |
+
|
| 754 |
+
|
| 755 |
+
class PageButton(discord.ui.Button):
|
| 756 |
+
def __init__(self, parent: CommandsMenuView, direction: str) -> None:
|
| 757 |
+
self.parent_view = parent
|
| 758 |
+
self.direction = direction
|
| 759 |
+
label = "Previous" if direction == "prev" else "Next"
|
| 760 |
+
emoji = "⬅ï¸" if direction == "prev" else "âž¡ï¸"
|
| 761 |
+
super().__init__(label=label, emoji=emoji, style=discord.ButtonStyle.secondary)
|
| 762 |
+
|
| 763 |
+
async def callback(self, interaction: discord.Interaction) -> None:
|
| 764 |
+
await interaction.response.defer()
|
| 765 |
+
if self.direction == "prev":
|
| 766 |
+
self.parent_view.page = max(0, self.parent_view.page - 1)
|
| 767 |
+
else:
|
| 768 |
+
if self.parent_view._has_next_page(self.parent_view.selected_cog):
|
| 769 |
+
self.parent_view.page += 1
|
| 770 |
+
await self.parent_view.setup_items()
|
| 771 |
+
embed = await self.parent_view.build_embed(interaction.guild.id if interaction.guild else None, self.parent_view.selected_cog)
|
| 772 |
+
await interaction.followup.edit_message(interaction.message.id, embed=embed, view=self.parent_view)
|
| 773 |
+
|
| 774 |
+
|
| 775 |
+
class Menu(commands.Cog):
|
| 776 |
+
"""Interactive command menu with beautiful panels and multi-language support."""
|
| 777 |
+
|
| 778 |
+
def __init__(self, bot: commands.Bot) -> None:
|
| 779 |
+
self.bot = bot
|
| 780 |
+
|
|
|
|
| 781 |
async def cog_load(self) -> None:
|
| 782 |
+
self.bot.add_view(CommandsMenuView(self.bot))
|
| 783 |
+
|
| 784 |
+
@commands.hybrid_command(name="menu", description="Bot menu | قائمة أوامر ÙˆØ§Ø¶ØØ©")
|
| 785 |
+
async def menu(self, ctx: commands.Context) -> None:
|
| 786 |
+
"""Display the beautiful command menu."""
|
| 787 |
+
if ctx.interaction and not ctx.interaction.response.is_done():
|
| 788 |
+
await ctx.interaction.response.defer()
|
| 789 |
+
guild_id = ctx.guild.id if ctx.guild else None
|
| 790 |
+
view = CommandsMenuView(self.bot, guild_id)
|
| 791 |
+
await view.setup_items()
|
| 792 |
+
embed = await view.build_embed(guild_id, "__all__")
|
| 793 |
+
if ctx.interaction:
|
| 794 |
+
await ctx.interaction.followup.send(embed=embed, view=view)
|
| 795 |
+
else:
|
| 796 |
+
await ctx.reply(embed=embed, view=view)
|
| 797 |
+
|
| 798 |
+
@commands.hybrid_command(name="start", description="Start menu | القائمة الرئيسية", with_app_command=False)
|
| 799 |
+
async def start_menu(self, ctx: commands.Context) -> None:
|
| 800 |
+
await self.menu(ctx)
|
| 801 |
+
|
| 802 |
+
async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None:
|
| 803 |
+
embed = ImperialMotaz.craft_embed(
|
| 804 |
+
title="âš ï¸ Command Error | خطأ ÙÙŠ الأمر",
|
| 805 |
+
description=f"「 {str(error)[:1000]} ã€",
|
| 806 |
+
color=discord.Color(0x2B2D31),
|
| 807 |
+
footer="🮠Powered by BOT- AI Suite ðŸ®",
|
| 808 |
+
)
|
| 809 |
+
try:
|
| 810 |
+
await ctx.reply(embed=embed)
|
| 811 |
+
except Exception:
|
| 812 |
+
if ctx.channel:
|
| 813 |
+
await ctx.channel.send(embed=embed)
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
async def setup(bot: commands.Bot) -> None:
|
| 817 |
+
await bot.add_cog(Menu(bot))
|
| 818 |
+
|
| 819 |
+
|
bot/cogs/observability.py
CHANGED
|
@@ -1,14 +1,16 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import difflib
|
| 4 |
-
import
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
import discord
|
| 8 |
from discord.ext import commands
|
| 9 |
|
| 10 |
|
| 11 |
-
class Observability(commands.Cog):
|
| 12 |
def __init__(self, bot: commands.Bot) -> None:
|
| 13 |
self.bot = bot
|
| 14 |
|
|
@@ -122,8 +124,8 @@ class Observability(commands.Cog):
|
|
| 122 |
embed.add_field(name="Error", value=f"```py\n{tb[-900:]}\n```", inline=False)
|
| 123 |
await channel.send(embed=embed)
|
| 124 |
|
| 125 |
-
@commands.hybrid_command(name="command_fill", description="Show how to fill command arguments")
|
| 126 |
-
async def command_fill(self, ctx: commands.Context, *, command_name: str) -> None:
|
| 127 |
command = self.bot.get_command(command_name.strip())
|
| 128 |
if not command:
|
| 129 |
close = self._closest_commands(command_name.strip())
|
|
@@ -141,8 +143,70 @@ class Observability(commands.Cog):
|
|
| 141 |
usage = f"{ctx.prefix}{command.qualified_name} " + " ".join(pieces)
|
| 142 |
embed = discord.Embed(title="🧩 Command Fill Helper", color=discord.Color.blurple())
|
| 143 |
embed.add_field(name="Usage", value=f"`{usage.strip()}`", inline=False)
|
| 144 |
-
embed.add_field(name="Argument hints", value="\n".join(details) if details else "No arguments", inline=False)
|
| 145 |
-
await ctx.reply(embed=embed)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
|
| 148 |
async def setup(bot: commands.Bot) -> None:
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import difflib
|
| 4 |
+
import json
|
| 5 |
+
import traceback
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import get_args, get_origin
|
| 8 |
|
| 9 |
import discord
|
| 10 |
from discord.ext import commands
|
| 11 |
|
| 12 |
|
| 13 |
+
class Observability(commands.Cog):
|
| 14 |
def __init__(self, bot: commands.Bot) -> None:
|
| 15 |
self.bot = bot
|
| 16 |
|
|
|
|
| 124 |
embed.add_field(name="Error", value=f"```py\n{tb[-900:]}\n```", inline=False)
|
| 125 |
await channel.send(embed=embed)
|
| 126 |
|
| 127 |
+
@commands.hybrid_command(name="command_fill", description="Show how to fill command arguments")
|
| 128 |
+
async def command_fill(self, ctx: commands.Context, *, command_name: str) -> None:
|
| 129 |
command = self.bot.get_command(command_name.strip())
|
| 130 |
if not command:
|
| 131 |
close = self._closest_commands(command_name.strip())
|
|
|
|
| 143 |
usage = f"{ctx.prefix}{command.qualified_name} " + " ".join(pieces)
|
| 144 |
embed = discord.Embed(title="🧩 Command Fill Helper", color=discord.Color.blurple())
|
| 145 |
embed.add_field(name="Usage", value=f"`{usage.strip()}`", inline=False)
|
| 146 |
+
embed.add_field(name="Argument hints", value="\n".join(details) if details else "No arguments", inline=False)
|
| 147 |
+
await ctx.reply(embed=embed)
|
| 148 |
+
|
| 149 |
+
@commands.hybrid_command(name="system_audit", description="Run a quick runtime health audit")
|
| 150 |
+
@commands.has_permissions(manage_guild=True)
|
| 151 |
+
async def system_audit(self, ctx: commands.Context) -> None:
|
| 152 |
+
await self._safe_reply(ctx, "Running system audit...")
|
| 153 |
+
|
| 154 |
+
total_commands = len(self.bot.commands)
|
| 155 |
+
loaded_cogs = len(self.bot.cogs)
|
| 156 |
+
|
| 157 |
+
row = await self.bot.db.fetchone("PRAGMA table_info(guild_config)")
|
| 158 |
+
has_guild_config = bool(row)
|
| 159 |
+
cols = await self.bot.db.fetchall("PRAGMA table_info(guild_config)")
|
| 160 |
+
col_names = {str(r[1]) for r in cols} if cols else set()
|
| 161 |
+
has_banner_col = "custom_banner_url" in col_names
|
| 162 |
+
has_lang_col = "guild_language" in col_names
|
| 163 |
+
|
| 164 |
+
locale_dir = Path("bot/locales")
|
| 165 |
+
locale_files = sorted(p.name for p in locale_dir.glob("*.json")) if locale_dir.exists() else []
|
| 166 |
+
locale_issues = 0
|
| 167 |
+
if locale_files:
|
| 168 |
+
try:
|
| 169 |
+
base = json.loads((locale_dir / "en.json").read_text(encoding="utf-8"))
|
| 170 |
+
def flatten(obj, prefix=""):
|
| 171 |
+
out = {}
|
| 172 |
+
if isinstance(obj, dict):
|
| 173 |
+
for k, v in obj.items():
|
| 174 |
+
key = f"{prefix}.{k}" if prefix else k
|
| 175 |
+
out.update(flatten(v, key))
|
| 176 |
+
else:
|
| 177 |
+
out[prefix] = obj
|
| 178 |
+
return out
|
| 179 |
+
base_keys = set(flatten(base).keys())
|
| 180 |
+
for name in locale_files:
|
| 181 |
+
data = json.loads((locale_dir / name).read_text(encoding="utf-8"))
|
| 182 |
+
keys = set(flatten(data).keys())
|
| 183 |
+
if base_keys - keys:
|
| 184 |
+
locale_issues += 1
|
| 185 |
+
except Exception:
|
| 186 |
+
locale_issues += 1
|
| 187 |
+
|
| 188 |
+
checks = [
|
| 189 |
+
("Commands registered", total_commands >= 50, str(total_commands)),
|
| 190 |
+
("Cogs loaded", loaded_cogs >= 8, str(loaded_cogs)),
|
| 191 |
+
("guild_config table", has_guild_config, "ok" if has_guild_config else "missing"),
|
| 192 |
+
("guild_language column", has_lang_col, "ok" if has_lang_col else "missing"),
|
| 193 |
+
("custom_banner_url column", has_banner_col, "ok" if has_banner_col else "missing"),
|
| 194 |
+
("Locale files", bool(locale_files), f"{len(locale_files)} files"),
|
| 195 |
+
("Locale consistency", locale_issues == 0, "ok" if locale_issues == 0 else f"{locale_issues} issue(s)"),
|
| 196 |
+
]
|
| 197 |
+
|
| 198 |
+
passed = sum(1 for _, ok, _ in checks if ok)
|
| 199 |
+
status = "HEALTHY" if passed == len(checks) else "NEEDS ATTENTION"
|
| 200 |
+
color = discord.Color.green() if passed == len(checks) else discord.Color.orange()
|
| 201 |
+
lines = [f"{'✅' if ok else '⚠️'} {name}: `{detail}`" for name, ok, detail in checks]
|
| 202 |
+
|
| 203 |
+
embed = discord.Embed(
|
| 204 |
+
title=f"System Audit: {status}",
|
| 205 |
+
description="\n".join(lines),
|
| 206 |
+
color=color,
|
| 207 |
+
)
|
| 208 |
+
embed.set_footer(text=f"Passed {passed}/{len(checks)} checks")
|
| 209 |
+
await ctx.reply(embed=embed)
|
| 210 |
|
| 211 |
|
| 212 |
async def setup(bot: commands.Bot) -> None:
|
bot/database.py
CHANGED
|
@@ -59,6 +59,8 @@ class Database:
|
|
| 59 |
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
| 60 |
await asyncio.to_thread(shutil.copy2, local_file, self.path)
|
| 61 |
except Exception:
|
|
|
|
|
|
|
| 62 |
return
|
| 63 |
|
| 64 |
async def _push_remote_db(self) -> None:
|
|
@@ -75,6 +77,8 @@ class Database:
|
|
| 75 |
commit_message="Bot DB sync",
|
| 76 |
)
|
| 77 |
except Exception:
|
|
|
|
|
|
|
| 78 |
return
|
| 79 |
|
| 80 |
async def _ensure_column(
|
|
@@ -337,6 +341,15 @@ class Database:
|
|
| 337 |
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
| 338 |
PRIMARY KEY (guild_id, image_hash)
|
| 339 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
"""
|
| 341 |
)
|
| 342 |
|
|
@@ -375,6 +388,7 @@ class Database:
|
|
| 375 |
await self._ensure_column(db, "guild_config", "game_news_channel_id", "INTEGER")
|
| 376 |
await self._ensure_column(db, "guild_config", "game_news_role_id", "INTEGER")
|
| 377 |
await self._ensure_column(db, "guild_config", "game_news_last_ids", "TEXT")
|
|
|
|
| 378 |
|
| 379 |
await db.commit()
|
| 380 |
await self._push_remote_db()
|
|
@@ -405,15 +419,16 @@ class Database:
|
|
| 405 |
"""Clean up all data for a guild (for when bot leaves a server)."""
|
| 406 |
async with self._lock:
|
| 407 |
db = await self._get_connection()
|
| 408 |
-
|
| 409 |
-
await db.execute("DELETE FROM
|
| 410 |
-
await db.execute("DELETE FROM
|
| 411 |
-
await db.execute("DELETE FROM
|
| 412 |
-
await db.execute("DELETE FROM
|
| 413 |
-
await db.execute("DELETE FROM
|
| 414 |
-
await db.execute("DELETE FROM
|
| 415 |
-
await db.execute("DELETE FROM
|
| 416 |
-
await db.execute("DELETE FROM
|
| 417 |
-
await db.execute("DELETE FROM
|
|
|
|
| 418 |
await db.commit()
|
| 419 |
await self._push_remote_db()
|
|
|
|
| 59 |
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
| 60 |
await asyncio.to_thread(shutil.copy2, local_file, self.path)
|
| 61 |
except Exception:
|
| 62 |
+
# Disable further sync attempts to avoid repeated noisy auth/network failures.
|
| 63 |
+
self._hf_sync_enabled = False
|
| 64 |
return
|
| 65 |
|
| 66 |
async def _push_remote_db(self) -> None:
|
|
|
|
| 77 |
commit_message="Bot DB sync",
|
| 78 |
)
|
| 79 |
except Exception:
|
| 80 |
+
# Disable further sync attempts to avoid repeated noisy auth/network failures.
|
| 81 |
+
self._hf_sync_enabled = False
|
| 82 |
return
|
| 83 |
|
| 84 |
async def _ensure_column(
|
|
|
|
| 341 |
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
| 342 |
PRIMARY KEY (guild_id, image_hash)
|
| 343 |
);
|
| 344 |
+
|
| 345 |
+
CREATE TABLE IF NOT EXISTS bot_presence_config (
|
| 346 |
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
| 347 |
+
status TEXT DEFAULT 'online',
|
| 348 |
+
activity_type TEXT DEFAULT 'playing',
|
| 349 |
+
activity_text TEXT DEFAULT 'CYBER // GRID',
|
| 350 |
+
updated_by INTEGER,
|
| 351 |
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
| 352 |
+
);
|
| 353 |
"""
|
| 354 |
)
|
| 355 |
|
|
|
|
| 388 |
await self._ensure_column(db, "guild_config", "game_news_channel_id", "INTEGER")
|
| 389 |
await self._ensure_column(db, "guild_config", "game_news_role_id", "INTEGER")
|
| 390 |
await self._ensure_column(db, "guild_config", "game_news_last_ids", "TEXT")
|
| 391 |
+
await self._ensure_column(db, "guild_config", "custom_banner_url", "TEXT")
|
| 392 |
|
| 393 |
await db.commit()
|
| 394 |
await self._push_remote_db()
|
|
|
|
| 419 |
"""Clean up all data for a guild (for when bot leaves a server)."""
|
| 420 |
async with self._lock:
|
| 421 |
db = await self._get_connection()
|
| 422 |
+
params = (guild_id,)
|
| 423 |
+
await db.execute("DELETE FROM guild_config WHERE guild_id = ?", params)
|
| 424 |
+
await db.execute("DELETE FROM user_xp WHERE guild_id = ?", params)
|
| 425 |
+
await db.execute("DELETE FROM user_balance WHERE guild_id = ?", params)
|
| 426 |
+
await db.execute("DELETE FROM user_daily_claim WHERE guild_id = ?", params)
|
| 427 |
+
await db.execute("DELETE FROM user_work_cooldown WHERE guild_id = ?", params)
|
| 428 |
+
await db.execute("DELETE FROM warns WHERE guild_id = ?", params)
|
| 429 |
+
await db.execute("DELETE FROM tournaments WHERE guild_id = ?", params)
|
| 430 |
+
await db.execute("DELETE FROM tournament_participants WHERE guild_id = ?", params)
|
| 431 |
+
await db.execute("DELETE FROM tournament_games WHERE guild_id = ?", params)
|
| 432 |
+
await db.execute("DELETE FROM voice_sessions WHERE guild_id = ?", params)
|
| 433 |
await db.commit()
|
| 434 |
await self._push_remote_db()
|
bot/emojis.py
CHANGED
|
@@ -33,8 +33,8 @@ FALLBACK_EMOJIS: dict[str, str] = {
|
|
| 33 |
"catjam": "😺", "djpeepo": "🎧", "spotify": "🎵", "partytime": "🎉", "letsgo": "🚀",
|
| 34 |
|
| 35 |
# Arrows & decorations
|
| 36 |
-
"arrow_green": "
|
| 37 |
-
"arrow_orange": "🟠", "arrow_yellow": "
|
| 38 |
|
| 39 |
# UI elements
|
| 40 |
"notebook": "📓", "ticket": "🎫", "verified": "✅", "error": "❌", "loading": "⏳",
|
|
@@ -44,8 +44,8 @@ FALLBACK_EMOJIS: dict[str, str] = {
|
|
| 44 |
"num_6": "6️⃣", "num_7": "7️⃣", "num_8": "8️⃣", "num_9": "9️⃣", "num_10": "🔟",
|
| 45 |
|
| 46 |
# Enhanced decorations
|
| 47 |
-
"sparkle": "✨", "fire": "🔥", "heart": "✅", "purple_heart": "
|
| 48 |
-
"green_heart": "
|
| 49 |
"crown": "👑", "trophy": "🏆", "medal": "🎖️", "ribbon": "🎀", "gem": "💎",
|
| 50 |
"crystal_ball": "🔮", "comet": "☄️", "star2": "✅", "star3": "✅", "dizzy": "💫",
|
| 51 |
"boom": "💥", "zap": "⚡", "warning": "⚠️", "information": "ℹ️", "question": "❓",
|
|
@@ -67,7 +67,7 @@ FALLBACK_EMOJIS: dict[str, str] = {
|
|
| 67 |
"credit_card": "💳", "chart": "📈", "bank": "🏦", "atm": "🏧", "receipt": "🧾",
|
| 68 |
|
| 69 |
# Decorative borders
|
| 70 |
-
"line": "▬", "dash": "—", "dot": "•", "bullet": "•", "circle": "
|
| 71 |
"square": "■", "diamond_shape": "◆", "triangle": "▲", "star_border": "✅",
|
| 72 |
}
|
| 73 |
|
|
@@ -96,6 +96,16 @@ def _normalize_key(value: str) -> str:
|
|
| 96 |
return (value or "").strip().lower().replace(" ", "_")
|
| 97 |
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
def set_emoji_bot(bot: discord.Client) -> None:
|
| 100 |
"""Register bot/client instance for dynamic custom emoji lookup."""
|
| 101 |
global _EMOJI_BOT
|
|
@@ -198,11 +208,17 @@ def _parse_emoji_file() -> dict[str, str]:
|
|
| 198 |
if key and value:
|
| 199 |
# Check if value is already in full format
|
| 200 |
if (value.startswith("<:") or value.startswith("<a:")) and value.endswith(">"):
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
elif value.isdigit():
|
| 204 |
# Just the ID, create full format
|
| 205 |
-
parsed[key] = f"<:{
|
| 206 |
else:
|
| 207 |
# Some other format, try to use as-is
|
| 208 |
parsed[key] = value
|
|
@@ -663,7 +679,7 @@ E_DIZZY = "💫"
|
|
| 663 |
E_BOOM = "💥"
|
| 664 |
E_ZAP = "⚡"
|
| 665 |
E_HEART = "✅"
|
| 666 |
-
E_PURPLE_HEART = "
|
| 667 |
E_BLUE_HEART = "💙"
|
| 668 |
-
E_GREEN_HEART = "
|
| 669 |
E_YELLOW_HEART = "💛"
|
|
|
|
| 33 |
"catjam": "😺", "djpeepo": "🎧", "spotify": "🎵", "partytime": "🎉", "letsgo": "🚀",
|
| 34 |
|
| 35 |
# Arrows & decorations
|
| 36 |
+
"arrow_green": "<:animatedarrowgreen:1477261279428087979>", "arrow_pink": "✅", "arrow_blue": "🔵", "arrow_purple": "<:animatedarrowgreen:1477261279428087979>",
|
| 37 |
+
"arrow_orange": "🟠", "arrow_yellow": "<:animatedarrowyellow:1477261257592668271>", "shield": "🛡️", "gift": "🎁", "hype": "🔥",
|
| 38 |
|
| 39 |
# UI elements
|
| 40 |
"notebook": "📓", "ticket": "🎫", "verified": "✅", "error": "❌", "loading": "⏳",
|
|
|
|
| 44 |
"num_6": "6️⃣", "num_7": "7️⃣", "num_8": "8️⃣", "num_9": "9️⃣", "num_10": "🔟",
|
| 45 |
|
| 46 |
# Enhanced decorations
|
| 47 |
+
"sparkle": "✨", "fire": "🔥", "heart": "✅", "purple_heart": "<:animatedarrowgreen:1477261279428087979>", "blue_heart": "🔵",
|
| 48 |
+
"green_heart": "<:animatedarrowgreen:1477261279428087979>", "yellow_heart": "<:animatedarrowyellow:1477261257592668271>", "orange_heart": "🟠", "pink_heart": "✅",
|
| 49 |
"crown": "👑", "trophy": "🏆", "medal": "🎖️", "ribbon": "🎀", "gem": "💎",
|
| 50 |
"crystal_ball": "🔮", "comet": "☄️", "star2": "✅", "star3": "✅", "dizzy": "💫",
|
| 51 |
"boom": "💥", "zap": "⚡", "warning": "⚠️", "information": "ℹ️", "question": "❓",
|
|
|
|
| 67 |
"credit_card": "💳", "chart": "📈", "bank": "🏦", "atm": "🏧", "receipt": "🧾",
|
| 68 |
|
| 69 |
# Decorative borders
|
| 70 |
+
"line": "▬", "dash": "—", "dot": "•", "bullet": "•", "circle": "<:animatedarrowgreen:1477261279428087979>",
|
| 71 |
"square": "■", "diamond_shape": "◆", "triangle": "▲", "star_border": "✅",
|
| 72 |
}
|
| 73 |
|
|
|
|
| 96 |
return (value or "").strip().lower().replace(" ", "_")
|
| 97 |
|
| 98 |
|
| 99 |
+
def _sanitize_emoji_name(name: str) -> str:
|
| 100 |
+
cleaned = re.sub(r"[^A-Za-z0-9_]", "_", (name or "").strip())
|
| 101 |
+
cleaned = re.sub(r"_+", "_", cleaned).strip("_")
|
| 102 |
+
if not cleaned:
|
| 103 |
+
return "emoji"
|
| 104 |
+
if len(cleaned) < 2:
|
| 105 |
+
return f"{cleaned}_x"
|
| 106 |
+
return cleaned[:32]
|
| 107 |
+
|
| 108 |
+
|
| 109 |
def set_emoji_bot(bot: discord.Client) -> None:
|
| 110 |
"""Register bot/client instance for dynamic custom emoji lookup."""
|
| 111 |
global _EMOJI_BOT
|
|
|
|
| 208 |
if key and value:
|
| 209 |
# Check if value is already in full format
|
| 210 |
if (value.startswith("<:") or value.startswith("<a:")) and value.endswith(">"):
|
| 211 |
+
cfg = _parse_custom_emoji_config(value)
|
| 212 |
+
if cfg is not None:
|
| 213 |
+
cfg_name, emoji_id, animated = cfg
|
| 214 |
+
parsed[key] = _build_custom_emoji_code(
|
| 215 |
+
_sanitize_emoji_name(cfg_name), emoji_id, animated
|
| 216 |
+
)
|
| 217 |
+
else:
|
| 218 |
+
parsed[key] = value
|
| 219 |
elif value.isdigit():
|
| 220 |
# Just the ID, create full format
|
| 221 |
+
parsed[key] = f"<:{_sanitize_emoji_name(name)}:{value}>"
|
| 222 |
else:
|
| 223 |
# Some other format, try to use as-is
|
| 224 |
parsed[key] = value
|
|
|
|
| 679 |
E_BOOM = "💥"
|
| 680 |
E_ZAP = "⚡"
|
| 681 |
E_HEART = "✅"
|
| 682 |
+
E_PURPLE_HEART = "<:animatedarrowgreen:1477261279428087979>"
|
| 683 |
E_BLUE_HEART = "💙"
|
| 684 |
+
E_GREEN_HEART = "<:animatedarrowgreen:1477261279428087979>"
|
| 685 |
E_YELLOW_HEART = "💛"
|
bot/i18n.py
CHANGED
|
@@ -20,7 +20,7 @@ TRANSLATIONS["ar"] = {
|
|
| 20 |
# === Language Commands ===
|
| 21 |
"lang.current": "{globe} لغة السيرفر الحالية: **العربية**.",
|
| 22 |
"lang.updated": "{ok} تم تغيير لغة البوت إلى **العربية**. استخدم `/menu` لرؤية الأوامر باللغة المحددة.",
|
| 23 |
-
"lang.invalid": "{no} لغة غير مدعومة. استخدم: `ar`, `en`, `es`, `fr`, `
|
| 24 |
|
| 25 |
# === Menu System ===
|
| 26 |
"menu.title": "{menu} قائمة أوامر البوت",
|
|
@@ -629,7 +629,7 @@ TRANSLATIONS["en"] = {
|
|
| 629 |
# === Language Commands ===
|
| 630 |
"lang.current": "{globe} Current server language: **English**.",
|
| 631 |
"lang.updated": "{ok} Bot language changed to **English**. Use `/menu` to view commands in the selected language.",
|
| 632 |
-
"lang.invalid": "{no} Unsupported language. Use: `ar`, `en`, `es`, `fr`, `
|
| 633 |
|
| 634 |
# === Menu System ===
|
| 635 |
"menu.title": "{menu} Bot Command Menu",
|
|
@@ -1445,6 +1445,19 @@ TRANSLATIONS["he"] = {
|
|
| 1445 |
"ping.desc": "זמן תגובה: **{latency}ms**",
|
| 1446 |
}
|
| 1447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1448 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 1449 |
# HELPER FUNCTIONS
|
| 1450 |
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
| 20 |
# === Language Commands ===
|
| 21 |
"lang.current": "{globe} لغة السيرفر الحالية: **العربية**.",
|
| 22 |
"lang.updated": "{ok} تم تغيير لغة البوت إلى **العربية**. استخدم `/menu` لرؤية الأوامر باللغة المحددة.",
|
| 23 |
+
"lang.invalid": "{no} لغة غير مدعومة. استخدم: `ar`, `de`, `en`, `es`, `fr`, `he`, `hi`, `id`, `it`, `ja`, `pt`, `ru`, `tr`, `zh`.",
|
| 24 |
|
| 25 |
# === Menu System ===
|
| 26 |
"menu.title": "{menu} قائمة أوامر البوت",
|
|
|
|
| 629 |
# === Language Commands ===
|
| 630 |
"lang.current": "{globe} Current server language: **English**.",
|
| 631 |
"lang.updated": "{ok} Bot language changed to **English**. Use `/menu` to view commands in the selected language.",
|
| 632 |
+
"lang.invalid": "{no} Unsupported language. Use: `ar`, `de`, `en`, `es`, `fr`, `he`, `hi`, `id`, `it`, `ja`, `pt`, `ru`, `tr`, `zh`.",
|
| 633 |
|
| 634 |
# === Menu System ===
|
| 635 |
"menu.title": "{menu} Bot Command Menu",
|
|
|
|
| 1445 |
"ping.desc": "זמן תגובה: **{latency}ms**",
|
| 1446 |
}
|
| 1447 |
|
| 1448 |
+
# Chinese (fallback pack; missing keys are filled from English below)
|
| 1449 |
+
TRANSLATIONS["zh"] = {
|
| 1450 |
+
"lang.current": "{globe} 当前服务器语言: **中文**。",
|
| 1451 |
+
"lang.updated": "{ok} 机器人语言已切换为 **中文**。",
|
| 1452 |
+
"lang.invalid": "{no} 不支持的语言。",
|
| 1453 |
+
"menu.title": "{menu} 命令菜单",
|
| 1454 |
+
"menu.all": "全部",
|
| 1455 |
+
"menu.ai": "AI 面板",
|
| 1456 |
+
"menu.refresh": "刷新",
|
| 1457 |
+
"ping.title": "{ping} Pong",
|
| 1458 |
+
"ping.desc": "延迟: **{latency}ms**",
|
| 1459 |
+
}
|
| 1460 |
+
|
| 1461 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 1462 |
# HELPER FUNCTIONS
|
| 1463 |
# ═══════════════════════════════════════════════════════════════════════════════
|
bot/locales/ar.json
CHANGED
|
@@ -89,6 +89,12 @@
|
|
| 89 |
},
|
| 90 |
"ai": {
|
| 91 |
"header": "『 <:birdmusic:1476278789251268824> 𝔸𝕀 𝕆𝕣𝕒𝕔𝕝𝕖 〣 』"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
}
|
| 93 |
},
|
| 94 |
"errors": {
|
|
@@ -267,8 +273,163 @@
|
|
| 267 |
"set_banner": "تعيين بنر مخصص لهذا السيرفر",
|
| 268 |
"remove_banner": "إزالة البنر المخصص من هذا السيرفر",
|
| 269 |
"view_banner": "عرض البنر المخصص الحالي لهذا السيرفر",
|
| 270 |
-
"banner_help": "تعلم كيفية تعيين بنر مخصص"
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
},
|
| 273 |
"gambling": {
|
| 274 |
"blackjack": {
|
|
@@ -396,5 +557,19 @@
|
|
| 396 |
"trivia_title": "سؤال مسابقة",
|
| 397 |
"trivia_correct": "إجابة صحيحة!",
|
| 398 |
"trivia_wrong": "إجابة خاطئة!"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
}
|
| 400 |
-
}
|
|
|
|
| 89 |
},
|
| 90 |
"ai": {
|
| 91 |
"header": "『 <:birdmusic:1476278789251268824> 𝔸𝕀 𝕆𝕣𝕒𝕔𝕝𝕖 〣 』"
|
| 92 |
+
},
|
| 93 |
+
"ai_architect": {
|
| 94 |
+
"title": "?? ????? ????? ????? ?????? ?????????",
|
| 95 |
+
"proposal": "??????? ???????? ???????: {sections}",
|
| 96 |
+
"analyzing": "???? ????? ??????? ???????...",
|
| 97 |
+
"success": "????? ????? ????? ?????? ????????? ?????."
|
| 98 |
}
|
| 99 |
},
|
| 100 |
"errors": {
|
|
|
|
| 273 |
"set_banner": "تعيين بنر مخصص لهذا السيرفر",
|
| 274 |
"remove_banner": "إزالة البنر المخصص من هذا السيرفر",
|
| 275 |
"view_banner": "عرض البنر المخصص الحالي لهذا السيرفر",
|
| 276 |
+
"banner_help": "تعلم كيفية تعيين بنر مخصص",
|
| 277 |
+
"8ball": "8ball",
|
| 278 |
+
"add_scam_image": "Save scam image signature",
|
| 279 |
+
"admin_panel": "Open admin control panel",
|
| 280 |
+
"ai": "Ai",
|
| 281 |
+
"ai_auto": "Ai auto",
|
| 282 |
+
"ai_channel": "Ai channel",
|
| 283 |
+
"ai_model": "Ai model",
|
| 284 |
+
"ask_image": "Ask image",
|
| 285 |
+
"avatar": "Avatar",
|
| 286 |
+
"awesomeroles": "Awesomeroles",
|
| 287 |
+
"backup_panel": "Open backup management panel",
|
| 288 |
+
"backupserver": "Backupserver",
|
| 289 |
+
"balance": "Balance",
|
| 290 |
+
"ban": "Ban",
|
| 291 |
+
"banner": "Banner",
|
| 292 |
+
"board_forfeit": "Board forfeit",
|
| 293 |
+
"board_move": "Board move",
|
| 294 |
+
"board_start": "Board start",
|
| 295 |
+
"boardgames": "Boardgames",
|
| 296 |
+
"botstats": "Botstats",
|
| 297 |
+
"buy": "Buy",
|
| 298 |
+
"chat": "Chat",
|
| 299 |
+
"checkers": "Checkers",
|
| 300 |
+
"chess": "Chess",
|
| 301 |
+
"choose": "Choose",
|
| 302 |
+
"clearwarn": "Clearwarn",
|
| 303 |
+
"cloneemoji": "Cloneemoji",
|
| 304 |
+
"code_gen": "Code gen",
|
| 305 |
+
"coinflip": "Coinflip",
|
| 306 |
+
"connect4": "Connect4",
|
| 307 |
+
"daily": "Daily",
|
| 308 |
+
"debug": "Debug",
|
| 309 |
+
"dice": "Dice",
|
| 310 |
+
"econ_admin": "Manage user economy balances",
|
| 311 |
+
"economy": "Economy",
|
| 312 |
+
"economy_deposit": "Economy deposit",
|
| 313 |
+
"economy_gamble": "Economy gamble",
|
| 314 |
+
"economy_rob": "Economy rob",
|
| 315 |
+
"economy_withdraw": "Economy withdraw",
|
| 316 |
+
"free_games": "Free games",
|
| 317 |
+
"gamble": "Gamble",
|
| 318 |
+
"gamehub": "Gamehub",
|
| 319 |
+
"games_panel": "Games panel",
|
| 320 |
+
"gaming_news": "Gaming news",
|
| 321 |
+
"giveaway": "Giveaway command group",
|
| 322 |
+
"guess": "Guess",
|
| 323 |
+
"image_gen": "Image gen",
|
| 324 |
+
"imagine": "Imagine",
|
| 325 |
+
"inventory": "Inventory",
|
| 326 |
+
"kick": "Kick",
|
| 327 |
+
"language": "Language",
|
| 328 |
+
"languages": "Languages",
|
| 329 |
+
"leaderboard": "Leaderboard",
|
| 330 |
+
"level": "Level",
|
| 331 |
+
"load": "Load",
|
| 332 |
+
"lock": "Lock",
|
| 333 |
+
"make_event": "Make event",
|
| 334 |
+
"mario": "Mario",
|
| 335 |
+
"meme": "Meme",
|
| 336 |
+
"menu": "Menu",
|
| 337 |
+
"music_247": "Music 247",
|
| 338 |
+
"music_clear": "Music clear",
|
| 339 |
+
"music_filter": "Music filter",
|
| 340 |
+
"music_jump": "Music jump",
|
| 341 |
+
"music_loop": "Music loop",
|
| 342 |
+
"music_lyrics": "Music lyrics",
|
| 343 |
+
"music_move": "Music move",
|
| 344 |
+
"music_nowplaying": "Music nowplaying",
|
| 345 |
+
"music_panel": "Music panel",
|
| 346 |
+
"music_pause": "Music pause",
|
| 347 |
+
"music_play": "Music play",
|
| 348 |
+
"music_playlist_delete": "Music playlist delete",
|
| 349 |
+
"music_playlist_rename": "Music playlist rename",
|
| 350 |
+
"music_playlist_save": "Music playlist save",
|
| 351 |
+
"music_previous": "Music previous",
|
| 352 |
+
"music_queue": "Music queue",
|
| 353 |
+
"music_remove": "Music remove",
|
| 354 |
+
"music_resume": "Music resume",
|
| 355 |
+
"music_seek": "Music seek",
|
| 356 |
+
"music_shuffle": "Music shuffle",
|
| 357 |
+
"music_skip": "Music skip",
|
| 358 |
+
"music_stop": "Music stop",
|
| 359 |
+
"music_volume": "Music volume",
|
| 360 |
+
"mute": "Mute",
|
| 361 |
+
"organizechannels": "Organizechannels",
|
| 362 |
+
"othello": "Othello",
|
| 363 |
+
"ping": "Ping",
|
| 364 |
+
"play": "Play",
|
| 365 |
+
"poll": "Poll",
|
| 366 |
+
"profile": "Profile",
|
| 367 |
+
"purge": "Purge",
|
| 368 |
+
"rank": "Rank",
|
| 369 |
+
"reload": "Reload",
|
| 370 |
+
"remind": "Remind",
|
| 371 |
+
"rob": "Rob",
|
| 372 |
+
"roll": "Roll",
|
| 373 |
+
"rps": "Rps",
|
| 374 |
+
"serverinfo": "Serverinfo",
|
| 375 |
+
"set": "Set",
|
| 376 |
+
"set_automod": "Set automod",
|
| 377 |
+
"set_dailybutton": "Set dailybutton",
|
| 378 |
+
"set_dailychannel": "Set dailychannel",
|
| 379 |
+
"set_dailyimage": "Set dailyimage",
|
| 380 |
+
"set_dailymessage": "Set dailymessage",
|
| 381 |
+
"set_dailytime": "Set dailytime",
|
| 382 |
+
"set_dailytitle": "Set dailytitle",
|
| 383 |
+
"set_dailytoggle": "Set dailytoggle",
|
| 384 |
+
"set_freegames": "Set freegames",
|
| 385 |
+
"set_gamenews": "Set gamenews",
|
| 386 |
+
"set_log": "Set log",
|
| 387 |
+
"set_pollchannel": "Set pollchannel",
|
| 388 |
+
"set_suggestions": "Set suggestions",
|
| 389 |
+
"set_supportai": "Set supportai",
|
| 390 |
+
"set_welcome": "Set welcome",
|
| 391 |
+
"set_wisdom": "Set wisdom",
|
| 392 |
+
"setupserver": "Setupserver",
|
| 393 |
+
"shield_level": "Set shield sensitivity level",
|
| 394 |
+
"shield_state": "Show current shield state",
|
| 395 |
+
"shop": "Shop",
|
| 396 |
+
"shutdown": "Shutdown",
|
| 397 |
+
"slots": "Slots",
|
| 398 |
+
"slowmode": "Slowmode",
|
| 399 |
+
"speak": "Speak",
|
| 400 |
+
"summarize": "Summarize",
|
| 401 |
+
"sync": "Sync",
|
| 402 |
+
"system_audit": "Show system audit and diagnostics",
|
| 403 |
+
"ticket": "Ticket command group",
|
| 404 |
+
"tournament": "Tournament",
|
| 405 |
+
"tournament_create": "Tournament create",
|
| 406 |
+
"tournament_end": "Tournament end",
|
| 407 |
+
"tournament_gamehub": "Tournament gamehub",
|
| 408 |
+
"tournament_join": "Tournament join",
|
| 409 |
+
"tournament_lb": "Tournament lb",
|
| 410 |
+
"tournament_panel": "Tournament panel",
|
| 411 |
+
"tournament_start": "Tournament start",
|
| 412 |
+
"transfer": "Transfer",
|
| 413 |
+
"translate": "Translate",
|
| 414 |
+
"translate_voice": "Translate voice",
|
| 415 |
+
"trivia": "Trivia",
|
| 416 |
+
"tts": "Tts",
|
| 417 |
+
"unban": "Unban",
|
| 418 |
+
"unload": "Unload",
|
| 419 |
+
"unlock": "Unlock",
|
| 420 |
+
"unmute": "Unmute",
|
| 421 |
+
"upscale": "Upscale",
|
| 422 |
+
"userinfo": "Userinfo",
|
| 423 |
+
"verify": "Verify",
|
| 424 |
+
"verifysetup": "Verifysetup",
|
| 425 |
+
"warn": "Warn",
|
| 426 |
+
"warnings": "Warnings",
|
| 427 |
+
"wisdom_today": "Wisdom today",
|
| 428 |
+
"work": "Work",
|
| 429 |
+
"xo": "Xo",
|
| 430 |
+
"xp": "Xp"
|
| 431 |
+
},
|
| 432 |
+
"quick_ai": "??? ????? ??????? ?????????"
|
| 433 |
},
|
| 434 |
"gambling": {
|
| 435 |
"blackjack": {
|
|
|
|
| 557 |
"trivia_title": "سؤال مسابقة",
|
| 558 |
"trivia_correct": "إجابة صحيحة!",
|
| 559 |
"trivia_wrong": "إجابة خاطئة!"
|
| 560 |
+
},
|
| 561 |
+
"config": {
|
| 562 |
+
"visuals": {
|
| 563 |
+
"divider": "<:editprofilewhite:1476278814894981200> ??? ???????? ????????? ??? <:editprofilewhite:1476278814894981200>"
|
| 564 |
+
}
|
| 565 |
+
},
|
| 566 |
+
"ping": {
|
| 567 |
+
"title": "?? ????",
|
| 568 |
+
"desc": "??? ?????????: **{latency}ms**"
|
| 569 |
+
},
|
| 570 |
+
"roll": "{user} ??? ????? **{value}** (1-{limit}).",
|
| 571 |
+
"lang": {
|
| 572 |
+
"current": "Current server language: **English**.",
|
| 573 |
+
"updated": "Bot language changed successfully."
|
| 574 |
}
|
| 575 |
+
}
|