mtaaz commited on
Commit
91c7f83
·
verified ·
1 Parent(s): 374baa8

Upload 94 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. .gitignore +3 -0
  3. README.md +174 -39
  4. bot/__pycache__/config.cpython-311.pyc +0 -0
  5. bot/__pycache__/database.cpython-311.pyc +0 -0
  6. bot/__pycache__/emojis.cpython-311.pyc +0 -0
  7. bot/__pycache__/i18n.cpython-311.pyc +0 -0
  8. bot/__pycache__/server.cpython-311.pyc +0 -0
  9. bot/cogs/__pycache__/__init__.cpython-311.pyc +0 -0
  10. bot/cogs/__pycache__/admin.cpython-311.pyc +2 -2
  11. bot/cogs/__pycache__/ai_admin.cpython-311.pyc +0 -0
  12. bot/cogs/__pycache__/ai_suite.cpython-311.pyc +2 -2
  13. bot/cogs/__pycache__/board_games.cpython-311.pyc +0 -0
  14. bot/cogs/__pycache__/community.cpython-311.pyc +2 -2
  15. bot/cogs/__pycache__/configuration.cpython-311.pyc +0 -0
  16. bot/cogs/__pycache__/developer.cpython-311.pyc +0 -0
  17. bot/cogs/__pycache__/engagement.cpython-311.pyc +2 -2
  18. bot/cogs/__pycache__/events.cpython-311.pyc +2 -2
  19. bot/cogs/__pycache__/fun.cpython-311.pyc +0 -0
  20. bot/cogs/__pycache__/gambling.cpython-311.pyc +0 -0
  21. bot/cogs/__pycache__/language.cpython-311.pyc +0 -0
  22. bot/cogs/__pycache__/media.cpython-311.pyc +2 -2
  23. bot/cogs/__pycache__/media_helpers.cpython-311.pyc +0 -0
  24. bot/cogs/__pycache__/menu.cpython-311.pyc +0 -0
  25. bot/cogs/__pycache__/observability.cpython-311.pyc +0 -0
  26. bot/cogs/__pycache__/server_manager.cpython-311.pyc +0 -0
  27. bot/cogs/__pycache__/utility.cpython-311.pyc +0 -0
  28. bot/cogs/__pycache__/verification.cpython-311.pyc +0 -0
  29. bot/cogs/admin.py +249 -241
  30. bot/cogs/ai_admin.py +863 -616
  31. bot/cogs/ai_suite.py +0 -0
  32. bot/cogs/board_games.py +825 -824
  33. bot/cogs/community.py +0 -0
  34. bot/cogs/configuration.py +637 -636
  35. bot/cogs/developer.py +124 -21
  36. bot/cogs/engagement.py +0 -0
  37. bot/cogs/events.py +0 -0
  38. bot/cogs/fun.py +981 -980
  39. bot/cogs/gambling.py +9 -8
  40. bot/cogs/language.py +188 -187
  41. bot/cogs/media.py +0 -0
  42. bot/cogs/media_helpers.py +10 -5
  43. bot/cogs/menu.py +192 -172
  44. bot/cogs/observability.py +86 -85
  45. bot/cogs/server_manager.py +7 -5
  46. bot/cogs/utility.py +556 -358
  47. bot/cogs/verification.py +178 -177
  48. bot/config.py +37 -5
  49. bot/database.py +23 -0
  50. bot/emojis.py +12 -4
.gitattributes CHANGED
@@ -40,3 +40,5 @@ 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
 
 
 
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
43
+ codex[[:space:]]resume[[:space:]]019d6e48-2f09-7771-b4ab-f2399bb70a8d filter=lfs diff=lfs merge=lfs -text
44
+ database.db-wal filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -178,3 +178,6 @@ cookies.txt
178
  youtube.token.json
179
  *.token.json
180
 
 
 
 
 
178
  youtube.token.json
179
  *.token.json
180
 
181
+
182
+ .qwen/
183
+ gha-creds-*.json
README.md CHANGED
@@ -6,68 +6,203 @@ colorTo: purple
6
  sdk: docker
7
  sdk_version: "20.10.25"
8
  python_version: "3.11"
9
- app_file: main.py
10
  pinned: false
11
  ---
12
- # fitness
13
 
14
- ## نظرة سريعة على التطبيق
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- المشروع يحتوي على جزأين رئيسيين:
 
 
 
17
 
18
- - `fitness_app`: باك-إند Python باستخدام `FastAPI` مسؤول عن منطق التطبيق، قاعدة البيانات، والتكامل مع الذكاء الاصطناعي.
19
- - `FitAI_WPF`: واجهة مستخدم سطح مكتب (`C# WPF`) تتصل بواجهة الـ API المحلية.
 
 
20
 
21
- ## تشغيل الباك-إند (Python)
 
 
 
 
 
 
 
22
 
 
23
  ```bash
24
- cd fitness_app
25
- pip install -r requirements.txt
26
- python main.py
 
 
 
 
 
27
  ```
28
 
29
- عند التشغيل، الخدمة تعمل على:
30
 
31
- - `http://127.0.0.1:8000`
32
- - فحص الصحة: `GET /health`
 
 
33
 
34
- ## إعداد مزود الذكاء الاصطناعي عبر `.env`
 
 
 
35
 
36
- داخل مجلد `fitness_app` انسخ المثال:
37
 
38
- ```bash
39
- cp .env.example .env
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  ```
41
 
42
- ثم عدّل القيم (المزود + API key + الموديل):
43
 
44
- - `FITAI_AI_PROVIDER=google` لتشغيل Gemini مباشرة.
45
- - `FITAI_AI_PROVIDER=openai_compatible` لأي مزود يدعم OpenAI Chat API (مثل OpenAI/OpenRouter/Groq...).
46
- - `FITAI_AI_BASE_URL` لعنوان المزود عند استخدام `openai_compatible`.
47
- - `FITAI_AI_API_KEY` أو مفاتيح منفصلة `FITAI_COACH_API_KEY` / `FITAI_DEV_API_KEY`.
48
- - `FITAI_AI_MODEL_FAST/SMART/FALLBACK` أو تخصيص لكل role عبر `FITAI_COACH_MODEL_*` و`FITAI_DEV_MODEL_*`.
49
 
50
- ## أهم الـ API Endpoints
 
 
 
 
 
 
51
 
52
- - الملف الشخصي: `GET/POST /user/profile`
53
- - توليد البرنامج: `POST /program/generate` ثم `GET /program/status`
54
- - الإحصائيات: `POST /stats/body` و `GET /stats/body`
55
- - الجلسات: `POST /session/start` و `POST /session/{id}/complete`
56
- - ماء اليوم: `GET /water/today` و `POST /water/log`
57
- - الداشبورد الموحد: `GET /dashboard`
58
 
59
- ## ملاحظات
 
 
 
60
 
61
- - الاتصال مهيأ محليًا بين WPF والباك-إند عبر `localhost:8000`.
62
- - يوجد ملف قاعدة بيانات SQLite داخل `fitness_app/fitness.db`.
63
 
 
 
 
 
64
 
65
- ### حل مشكلة عدم اكتشاف backend في نسخة Windows
66
 
67
- إذا ظهر خطأ `تعذّر تشغيل Python backend` رغم تثبيت Python، حدّد مسار الـ backend يدوياً عبر متغير البيئة:
68
 
69
- ```powershell
70
- setx FITAI_PYTHON_DIR "D:\path\to\fitness_app"
71
- ```
 
 
 
 
72
 
73
- ثم أغلق التطبيق وافتحه من جديد.
 
6
  sdk: docker
7
  sdk_version: "20.10.25"
8
  python_version: "3.11"
9
+ app_file: bot/main.py
10
  pinned: false
11
  ---
 
12
 
13
+ # 🤖 Ultimate AI & Music Discord Bot
14
+
15
+ A feature-rich, multi-purpose Discord bot with cyberpunk aesthetics, AI integration, music playback, economy system, and more.
16
+
17
+ ## ✨ Features
18
+
19
+ ### 🎵 Music System
20
+ - Full music playback with Wavelink/Lavalink
21
+ - Queue management, filters, 24/7 mode
22
+ - Playlist support and lyrics display
23
+ - Rich music control panels with buttons
24
+
25
+ ### 🤖 AI Suite
26
+ - OpenRouter & Gemini AI integration
27
+ - Auto-chat mode in dedicated channels
28
+ - Image analysis and prompt generation
29
+ - Multiple AI personalities (Wise, Sarcastic, Technical, Funny)
30
+
31
+ ### 💰 Economy & Engagement
32
+ - Wallet, bank, daily rewards
33
+ - Work, gamble, rob, shop system
34
+ - XP/leveling with rank cards
35
+ - Leaderboards and inventory
36
+
37
+ ### 🎮 Games & Fun
38
+ - Trivia (Gaming, Movies, Series)
39
+ - Arcade games (Mario challenge)
40
+ - Board games (Chess, Checkers, Connect 4, Othello)
41
+ - Classic games (XO, 8ball, Slots, Dice, Coinflip)
42
+
43
+ ### 🛡️ Moderation & Admin
44
+ - Ban, kick, mute, warn, purge
45
+ - Slowmode, lock/unlock channels
46
+ - Server backup and restore
47
+ - Auto-moderation features
48
+
49
+ ### 🌐 Multi-Language Support
50
+ - 14+ languages: Arabic, English, French, Spanish, German, etc.
51
+ - Full i18n with emoji placeholders
52
+ - Per-server language configuration
53
+
54
+ ### 🎫 Community Features
55
+ - Ticket system for support
56
+ - Polls and suggestions
57
+ - Giveaways with auto-winner selection
58
+ - Verification system
59
+
60
+ ### 📊 Configuration
61
+ - Per-server settings panel
62
+ - Custom channels for logs, welcome, daily messages
63
+ - DJ role, premium tiers, music autoplay
64
+ - Game news and free games alerts
65
+
66
+ ## 🚀 Quick Start
67
+
68
+ ### Prerequisites
69
+ - Python 3.11+
70
+ - Discord Bot Token ([Discord Developer Portal](https://discord.com/developers/applications))
71
+ - Optional: OpenRouter API key, Gemini API key
72
+
73
+ ### Installation
74
+
75
+ 1. **Clone the repository**
76
+ ```bash
77
+ git clone <your-repo-url>
78
+ cd BOT-
79
+ ```
80
 
81
+ 2. **Install dependencies**
82
+ ```bash
83
+ pip install -r requirements.txt
84
+ ```
85
 
86
+ 3. **Set up environment variables**
87
+ ```bash
88
+ cp .env.example .env
89
+ ```
90
 
91
+ Edit `.env` with your configuration:
92
+ ```env
93
+ DISCORD_TOKEN=your_bot_token_here
94
+ PREFIX=!
95
+ OWNER_IDS=your_discord_user_id
96
+ OPENROUTER_API_KEY=your_openrouter_key
97
+ GEMINI_API_KEY=your_gemini_key
98
+ ```
99
 
100
+ 4. **Run the bot**
101
  ```bash
102
+ python -m bot.main
103
+ ```
104
+
105
+ ## 🐳 Docker Deployment
106
+
107
+ ```bash
108
+ docker build -t discord-bot .
109
+ docker run -d --env-file .env discord-bot
110
  ```
111
 
112
+ ## 🌐 Cloud Deployment
113
 
114
+ ### HuggingFace Spaces
115
+ - Push your code to a HF Space
116
+ - Set environment variables in Space settings
117
+ - The bot will auto-start on deployment
118
 
119
+ ### Render
120
+ - Connect your GitHub repository
121
+ - Use the `render.yaml` configuration
122
+ - Add environment variables in Render dashboard
123
 
124
+ ## ⚙️ Configuration
125
 
126
+ ### Required Environment Variables
127
+ | Variable | Description | Example |
128
+ |----------|-------------|---------|
129
+ | `DISCORD_TOKEN` | Your Discord bot token | `MTIz...` |
130
+ | `PREFIX` | Command prefix | `!` or `/` |
131
+ | `OWNER_IDS` | Comma-separated owner user IDs | `123456789,987654321` |
132
+
133
+ ### Optional Environment Variables
134
+ | Variable | Description | Default |
135
+ |----------|-------------|---------|
136
+ | `OPENROUTER_API_KEY` | OpenRouter API key for AI | - |
137
+ | `GEMINI_API_KEY` | Google Gemini API key | - |
138
+ | `OPENROUTER_MODEL` | Default AI model | `meta-llama/llama-3.3-70b-instruct:free` |
139
+ | `DB_PATH` | Custom database path | `database.db` |
140
+ | `HF_TOKEN` | HuggingFace token for DB sync | - |
141
+ | `HF_DB_REPO_ID` | HuggingFace repo for cloud sync | - |
142
+ | `PORT` | Keep-alive server port | `10000` |
143
+
144
+ ## 📁 Project Structure
145
+
146
+ ```
147
+ BOT-/
148
+ ├── bot/
149
+ │ ├── cogs/ # Bot modules (cogs)
150
+ │ │ ├── admin.py
151
+ │ │ ├── ai_suite.py
152
+ │ │ ├── media.py
153
+ │ │ ├── community.py
154
+ │ │ └── ...
155
+ │ ├── utils/ # Utility modules
156
+ │ ├── locales/ # Translation files
157
+ │ ├── main.py # Bot entry point
158
+ │ ├── config.py # Configuration loader
159
+ │ ├── database.py # Database layer
160
+ │ ├── emojis.py # Emoji system
161
+ │ ├── i18n.py # Translations
162
+ │ └── theme.py # Styling & colors
163
+ ├── requirements.txt
164
+ ├── Dockerfile
165
+ ├── render.yaml
166
+ ├── .env.example
167
+ └── README.md
168
  ```
169
 
170
+ ## 🎮 Available Commands
171
 
172
+ Once the bot is running, use `<prefix>menu` or `/menu` to see all available commands organized by category:
 
 
 
 
173
 
174
+ - **Music**: play, pause, skip, queue, volume, filters
175
+ - **Admin**: ban, kick, mute, warn, purge, backup
176
+ - **Fun**: 8ball, trivia, meme, roll, mario
177
+ - **AI**: chat, ask_image, imagine, summarize
178
+ - **Economy**: balance, daily, work, gamble, shop
179
+ - **Utility**: serverinfo, userinfo, ping, remind
180
+ - **Configuration**: set, language, setup
181
 
182
+ ## 🛡️ Security Notes
 
 
 
 
 
183
 
184
+ - Never share your `DISCORD_TOKEN` or API keys
185
+ - Use `.env` file for sensitive configuration
186
+ - Set `OWNER_IDS` to restrict admin commands
187
+ - The bot uses SQLite with WAL mode for data integrity
188
 
189
+ ## 🤝 Contributing
 
190
 
191
+ 1. Fork the repository
192
+ 2. Create a feature branch
193
+ 3. Make your changes
194
+ 4. Submit a pull request
195
 
196
+ ## 📝 License
197
 
198
+ This project is provided as-is for educational and community use.
199
 
200
+ ## 🆘 Support
201
+
202
+ - Open an issue on GitHub for bug reports
203
+ - Check the wiki for common problems
204
+ - Use the bot's `/help` command for usage info
205
+
206
+ ---
207
 
208
+ **Developed with ❤️ by Motaz**
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__/server.cpython-311.pyc CHANGED
Binary files a/bot/__pycache__/server.cpython-311.pyc and b/bot/__pycache__/server.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
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:4ac3a481b82aa0aff762d68dd8f262478a5a3f489e08f7b8425530f0fd9298e9
3
- size 123269
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4e65407e3a0ee2ebbaee76d78967fa971bfa4460c8922ad8f4b67abac8670f4e
3
+ size 125088
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:e3d87a3a72d573124f17d8e63596d2f74a90fe1a464d7c3e483a0a7a1b97fae8
3
- size 142795
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a59d6c99f1b5da223c1cd0cc22d7e3d6200ea5f6c00df433181311389f2b584c
3
+ size 142874
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:87a05d115d6c14cd3607f3ce9d415d52e370582ddf9817c707a8002eb0f39d66
3
- size 131193
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:201e9aaaccda31150c69164ad13e5e8c8ef4378ea00255cd1e3a249aeb46841c
3
+ size 131426
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:2dab296a18802c5fb56ad3d9d1e26cfcd915adc3b82be3c4dd50c7b294a781a2
3
- size 120855
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a7edcd122436aaa7322f06746c36814bf9a66e19bf6dd777a080bfb682dbce5d
3
+ size 121567
bot/cogs/__pycache__/events.cpython-311.pyc CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:1790d4598667f2166132f1c7a8fe1b7456b9636d022b8e17be45d36b6eef022e
3
- size 110376
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:58e4643fd664dcdb77bde357abcf03ef9507fb718cb6a8aa0a575b041c7a7102
3
+ size 141527
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:a0a9b36f61b4f52cd987d08126f68dce43ae5de6f0cf107f5fc5aeb53a5e6d74
3
- size 220308
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4a4041ede6a563d0c1605ccde48c9572c66d76bbbaa74f4a7e64abc807651d3a
3
+ size 223657
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
@@ -19,6 +19,7 @@ from bot.theme import (
19
  NEON_BLUE, NEON_YELLOW, panel_divider, success_embed, error_embed, warning_embed, info_embed,
20
  double_line, triple_line, shimmer, pick_neon_color, add_banner_to_embed
21
  )
 
22
  from bot.emojis import (
23
  ui, E_SHIELD, E_CROWN, E_TROPHY, E_FIRE, E_SPARKLE, E_LOCK, E_KEY,
24
  E_ARROW_BLUE, E_ARROW_GREEN, E_ARROW_PINK, E_ARROW_PURPLE, E_GEM, E_STAR
@@ -29,7 +30,7 @@ from bot.emojis import (
29
  # AWESOME ROLES DEFINITIONS
30
  # ═══════════════════════════════════════════════════════════════════════════════
31
 
32
- AWESOME_ROLES = [
33
  {
34
  "name": "✨ Cyan Legend",
35
  "color": NEON_CYAN,
@@ -79,25 +80,25 @@ AWESOME_ROLES = [
79
  "hoist": True,
80
  "description": "Trusted member with azure power"
81
  },
82
- ]
83
-
84
- PRESENCE_STATUS_MAP: dict[str, discord.Status] = {
85
- "online": discord.Status.online,
86
- "idle": discord.Status.idle,
87
- "dnd": discord.Status.dnd,
88
- "invisible": discord.Status.invisible,
89
- "offline": discord.Status.invisible,
90
- }
91
-
92
- PRESENCE_ACTIVITY_MAP: dict[str, discord.ActivityType] = {
93
- "playing": discord.ActivityType.playing,
94
- "watching": discord.ActivityType.watching,
95
- "listening": discord.ActivityType.listening,
96
- "competing": discord.ActivityType.competing,
97
- }
98
-
99
-
100
- class AdminMasterPanel(discord.ui.View):
101
  def __init__(self, cog: "Admin") -> None:
102
  super().__init__(timeout=None)
103
  self.cog = cog
@@ -128,27 +129,34 @@ class AdminMasterPanel(discord.ui.View):
128
  )
129
  return
130
  if choice == "shield":
131
- await interaction.response.send_message("Use: `/shield_state` or `/shield_level low|medium|high`", ephemeral=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  return
133
- if choice == "status":
134
- synced = "Enabled" if self.cog.bot.db._hf_sync_enabled else "Disabled"
135
- activity_obj = getattr(self.cog.bot, "activity", None)
136
- activity_name = getattr(activity_obj, "name", None) or "CYBER // GRID"
137
- activity_type = str(getattr(activity_obj, "type", "playing")).replace("ActivityType.", "")
138
- status_name = str(getattr(self.cog.bot, "status", "online")).replace("Status.", "")
139
- await interaction.response.send_message(
140
- (
141
- f"HF DB Sync: **{synced}**\n"
142
- f"Bot Status: **{status_name}**\n"
143
- f"Bot Activity: **{activity_type}** • **{activity_name}**\n\n"
144
- "Owner commands:\n"
145
- "`/set_bot_status <status> <activity_type> <text>`\n"
146
- "`/reset_bot_status`\n"
147
- "`/bot_status`"
148
- ),
149
- ephemeral=True,
150
- )
151
- return
152
  if choice == "giveaways":
153
  await interaction.response.send_message(
154
  "**Giveaway Commands:**\n"
@@ -411,8 +419,8 @@ class EmojiCloneModal(discord.ui.Modal):
411
  if not interaction.user.guild_permissions.manage_emojis:
412
  await interaction.response.send_message("Manage Emojis permission required.", ephemeral=True)
413
  return
414
- source = str(self.source.value).strip()
415
- name = str(self.target_name.value).strip() or None
416
  created, error_text = await self.cog._clone_emoji_to_guild(
417
  interaction.guild,
418
  interaction.user,
@@ -583,9 +591,9 @@ class Admin(commands.Cog):
583
  )
584
  return before, after
585
 
586
- async def _safe_ctx_send(
587
- self,
588
- ctx: commands.Context,
589
  *,
590
  content: str | None = None,
591
  embed: discord.Embed | None = None,
@@ -617,82 +625,82 @@ class Admin(commands.Cog):
617
 
618
  if ctx.channel:
619
  channel_kwargs = {k: v for k, v in kwargs.items() if k != "ephemeral"}
620
- msg = await ctx.channel.send(**channel_kwargs)
621
- if delete_after:
622
- await msg.delete(delay=delete_after)
623
-
624
- def _presence_status_label(self, status: discord.Status | str | None) -> str:
625
- value = str(status or "online").lower()
626
- return {
627
- "online": "online",
628
- "idle": "idle",
629
- "dnd": "dnd",
630
- "invisible": "invisible",
631
- "offline": "invisible",
632
- }.get(value, "online")
633
-
634
- def _presence_activity_label(self, activity_type: discord.ActivityType | str | None) -> str:
635
- value = str(activity_type or "playing").lower()
636
- return {
637
- "playing": "playing",
638
- "watching": "watching",
639
- "listening": "listening",
640
- "competing": "competing",
641
- }.get(value, "playing")
642
-
643
- async def _set_bot_presence(
644
- self,
645
- actor_id: int,
646
- *,
647
- status_name: str,
648
- activity_type_name: str,
649
- activity_text: str,
650
- ) -> None:
651
- resolved_status = PRESENCE_STATUS_MAP.get(status_name, discord.Status.online)
652
- resolved_activity_type = PRESENCE_ACTIVITY_MAP.get(activity_type_name, discord.ActivityType.playing)
653
- await self.bot.change_presence(
654
- status=resolved_status,
655
- activity=discord.Activity(type=resolved_activity_type, name=activity_text),
656
- )
657
- await self.bot.db.execute(
658
- "INSERT INTO bot_presence_config(id, status, activity_type, activity_text, updated_by, updated_at) "
659
- "VALUES (1, ?, ?, ?, ?, ?) "
660
- "ON CONFLICT(id) DO UPDATE SET "
661
- "status = excluded.status, "
662
- "activity_type = excluded.activity_type, "
663
- "activity_text = excluded.activity_text, "
664
- "updated_by = excluded.updated_by, "
665
- "updated_at = excluded.updated_at",
666
- status_name,
667
- activity_type_name,
668
- activity_text,
669
- actor_id,
670
- dt.datetime.utcnow().isoformat(),
671
- )
672
-
673
- async def _presence_embed(self) -> discord.Embed:
674
- current_activity = getattr(self.bot, "activity", None)
675
- activity_text = getattr(current_activity, "name", None) or "CYBER // GRID"
676
- activity_type_name = self._presence_activity_label(getattr(current_activity, "type", None))
677
- status_name = self._presence_status_label(getattr(self.bot, "status", None))
678
- row = await self.bot.db.fetchone(
679
- "SELECT updated_by, updated_at FROM bot_presence_config WHERE id = 1"
680
- )
681
- updated_by = f"<@{row[0]}>" if row and row[0] else "Unknown"
682
- updated_at = row[1] if row and row[1] else "Not saved yet"
683
- embed = info_embed(
684
- "Bot Presence",
685
- (
686
- f"Status: **{status_name}**\n"
687
- f"Activity: **{activity_type_name}**\n"
688
- f"Text: **{activity_text}**\n\n"
689
- f"Updated by: {updated_by}\n"
690
- f"Updated at: `{updated_at}`"
691
- ),
692
- )
693
- return embed
694
-
695
- @commands.hybrid_command(name="purge")
696
  @commands.has_permissions(manage_messages=True)
697
  async def purge(self, ctx: commands.Context, amount: int) -> None:
698
  """Delete a number of messages from the channel."""
@@ -712,7 +720,7 @@ class Admin(commands.Cog):
712
  embed = success_embed("🗑️ Messages Purged", f"{panel_divider('green')}\n{desc}\n{panel_divider('green')}")
713
  await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction), delete_after=5 if not ctx.interaction else None)
714
 
715
- @commands.hybrid_command(name="admin_panel", description="Economy/Shield/Status management panel", with_app_command=False)
716
  @commands.has_permissions(administrator=True)
717
  async def admin_panel(self, ctx: commands.Context) -> None:
718
  if ctx.interaction and not ctx.interaction.response.is_done():
@@ -724,103 +732,103 @@ class Admin(commands.Cog):
724
  ephemeral=bool(ctx.interaction),
725
  )
726
 
727
- @commands.hybrid_group(name="admin", fallback="panel", description="Administrative grouped controls")
728
- @commands.has_permissions(administrator=True)
729
- async def admin_group(self, ctx: commands.Context) -> None:
730
- await self.admin_panel(ctx)
731
-
732
- @admin_group.group(name="botstatus", invoke_without_command=True)
733
- @commands.is_owner()
734
- async def admin_botstatus_group(self, ctx: commands.Context) -> None:
735
- embed = await self._presence_embed()
736
- await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction))
737
-
738
- @admin_botstatus_group.command(name="set")
739
- @commands.is_owner()
740
- async def admin_botstatus_set(
741
- self,
742
- ctx: commands.Context,
743
- status: str,
744
- activity_type: str,
745
- *,
746
- text: str,
747
- ) -> None:
748
- status_name = (status or "").strip().lower()
749
- activity_type_name = (activity_type or "").strip().lower()
750
- activity_text = (text or "").strip()[:128]
751
-
752
- if status_name not in PRESENCE_STATUS_MAP:
753
- await self._safe_ctx_send(
754
- ctx,
755
- content="Invalid status. Use: `online`, `idle`, `dnd`, `invisible`",
756
- ephemeral=bool(ctx.interaction),
757
- )
758
- return
759
- if activity_type_name not in PRESENCE_ACTIVITY_MAP:
760
- await self._safe_ctx_send(
761
- ctx,
762
- content="Invalid activity type. Use: `playing`, `watching`, `listening`, `competing`",
763
- ephemeral=bool(ctx.interaction),
764
- )
765
- return
766
- if not activity_text:
767
- await self._safe_ctx_send(ctx, content="Activity text cannot be empty.", ephemeral=bool(ctx.interaction))
768
- return
769
-
770
- await self._set_bot_presence(
771
- ctx.author.id,
772
- status_name=status_name,
773
- activity_type_name=activity_type_name,
774
- activity_text=activity_text,
775
- )
776
- embed = success_embed(
777
- "Bot Status Updated",
778
- (
779
- f"Status: **{status_name}**\n"
780
- f"Activity: **{activity_type_name}**\n"
781
- f"Text: **{activity_text}**"
782
- ),
783
- )
784
- await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction))
785
-
786
- @admin_botstatus_group.command(name="reset")
787
- @commands.is_owner()
788
- async def admin_botstatus_reset(self, ctx: commands.Context) -> None:
789
- await self._set_bot_presence(
790
- ctx.author.id,
791
- status_name="online",
792
- activity_type_name="playing",
793
- activity_text="CYBER // GRID",
794
- )
795
- embed = success_embed("Bot Status Reset", "Restored default presence.")
796
- await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction))
797
-
798
- @commands.hybrid_command(name="bot_status", description="Show current bot presence", with_app_command=True)
799
- @commands.is_owner()
800
- async def show_bot_status(self, ctx: commands.Context) -> None:
801
- embed = await self._presence_embed()
802
- await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction))
803
-
804
- @commands.hybrid_command(name="set_bot_status", description="Set bot presence", with_app_command=True)
805
- @commands.is_owner()
806
- async def update_bot_status(
807
- self,
808
- ctx: commands.Context,
809
- status: str,
810
- activity_type: str,
811
- *,
812
- text: str,
813
- ) -> None:
814
- await self.admin_botstatus_set(ctx, status, activity_type, text=text)
815
-
816
- @commands.hybrid_command(name="reset_bot_status", description="Reset bot presence to default", with_app_command=True)
817
- @commands.is_owner()
818
- async def restore_bot_status(self, ctx: commands.Context) -> None:
819
- await self.admin_botstatus_reset(ctx)
820
-
821
- @admin_group.group(name="shield", invoke_without_command=True)
822
- @commands.has_permissions(administrator=True)
823
- async def admin_shield_group(self, ctx: commands.Context) -> None:
824
  await self._safe_ctx_send(
825
  ctx,
826
  content="Use `/admin shield state`, `/admin shield set_level <low|medium|high>`, or `/admin shield add_image <attachment>`",
@@ -855,7 +863,7 @@ class Admin(commands.Cog):
855
  )
856
  await self._safe_ctx_send(ctx, content="✅ Scam image signature saved.", ephemeral=bool(ctx.interaction))
857
 
858
- @commands.hybrid_command(name="shield_level", description="Set AI shield level low|medium|high", hidden=True, with_app_command=False)
859
  @commands.has_permissions(administrator=True)
860
  async def shield_level(self, ctx: commands.Context, level: str) -> None:
861
  if ctx.interaction and not ctx.interaction.response.is_done():
@@ -871,7 +879,7 @@ class Admin(commands.Cog):
871
  )
872
  await self._safe_ctx_send(ctx, content=f"🛡️ Shield level set to `{normalized}`", ephemeral=bool(ctx.interaction))
873
 
874
- @commands.hybrid_command(name="shield_state", description="Show current AI shield state", with_app_command=True)
875
  @commands.has_permissions(administrator=True)
876
  async def shield_state(self, ctx: commands.Context) -> None:
877
  if not ctx.guild:
@@ -900,7 +908,7 @@ class Admin(commands.Cog):
900
  )
901
  await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction))
902
 
903
- @commands.hybrid_command(name="econ_admin", description="Add/remove coins from user")
904
  @commands.has_permissions(administrator=True)
905
  async def econ_admin(self, ctx: commands.Context, member: discord.Member, action: str, amount: int) -> None:
906
  if ctx.interaction and not ctx.interaction.response.is_done():
@@ -922,7 +930,7 @@ class Admin(commands.Cog):
922
  )
923
  await self._safe_ctx_send(ctx, content=f"💰 {member.mention} coins: `{coins}` → `{new_coins}`", ephemeral=bool(ctx.interaction))
924
 
925
- @commands.hybrid_command(name="warn")
926
  @commands.has_permissions(manage_roles=True)
927
  async def warn(self, ctx: commands.Context, member: discord.Member, *, reason: str = "No reason provided") -> None:
928
  """Warn a member."""
@@ -970,7 +978,7 @@ class Admin(commands.Cog):
970
  except discord.Forbidden:
971
  pass
972
 
973
- @commands.hybrid_command(name="warnings")
974
  @commands.has_permissions(manage_roles=True)
975
  async def warnings(self, ctx: commands.Context, member: discord.Member) -> None:
976
  """View warnings for a member."""
@@ -1005,7 +1013,7 @@ class Admin(commands.Cog):
1005
  embed.set_thumbnail(url=member.display_avatar.url)
1006
  await ctx.reply(embed=embed)
1007
 
1008
- @commands.hybrid_command(name="clearwarn")
1009
  @commands.has_permissions(manage_roles=True)
1010
  async def clearwarn(self, ctx: commands.Context, member: discord.Member) -> None:
1011
  """Clear all warnings for a member."""
@@ -1024,7 +1032,7 @@ class Admin(commands.Cog):
1024
  embed = success_embed("✅ Warnings Cleared", f"🗑️ All warnings for {member.mention} have been removed.")
1025
  await ctx.reply(embed=embed)
1026
 
1027
- @commands.hybrid_command(name="kick")
1028
  @commands.has_permissions(kick_members=True)
1029
  async def kick(self, ctx: commands.Context, member: discord.Member, *, reason: str = "No reason provided") -> None:
1030
  """Kick a member from the server."""
@@ -1069,7 +1077,7 @@ class Admin(commands.Cog):
1069
  )
1070
  await ctx.reply(embed=embed)
1071
 
1072
- @commands.hybrid_command(name="ban")
1073
  @commands.has_permissions(ban_members=True)
1074
  async def ban(self, ctx: commands.Context, member: discord.Member, *, reason: str = "No reason provided") -> None:
1075
  """Ban a member from the server."""
@@ -1120,7 +1128,7 @@ class Admin(commands.Cog):
1120
  color=discord.Color.red(),
1121
  )
1122
 
1123
- @commands.hybrid_command(name="unban")
1124
  @commands.has_permissions(ban_members=True)
1125
  async def unban(self, ctx: commands.Context, user_id: int, *, reason: str = "No reason provided") -> None:
1126
  """Unban a user by their ID."""
@@ -1178,7 +1186,7 @@ class Admin(commands.Cog):
1178
  color=discord.Color.green(),
1179
  )
1180
 
1181
- @commands.hybrid_command(name="mute")
1182
  @commands.has_permissions(moderate_members=True)
1183
  async def mute(self, ctx: commands.Context, member: discord.Member, duration: int = 10, *, reason: str = "No reason provided") -> None:
1184
  """Timeout a member for a specified duration in minutes."""
@@ -1225,7 +1233,7 @@ class Admin(commands.Cog):
1225
  color=discord.Color.red(),
1226
  )
1227
 
1228
- @commands.hybrid_command(name="unmute")
1229
  @commands.has_permissions(moderate_members=True)
1230
  async def unmute(self, ctx: commands.Context, member: discord.Member, *, reason: str = "No reason provided") -> None:
1231
  """Remove timeout from a member."""
@@ -1320,13 +1328,13 @@ class Admin(commands.Cog):
1320
  if not ctx.guild:
1321
  await self._safe_ctx_send(ctx, content="Server only.", ephemeral=bool(ctx.interaction))
1322
  return
1323
- embed = info_embed(
1324
- "Emoji Clone Panel",
1325
- "Quick actions:\n- Clone from tag/ID/URL/name\n- View server emojis\n- Open bot emoji picker",
1326
- )
1327
  await self._safe_ctx_send(ctx, embed=embed, view=EmojiClonePanelView(self), ephemeral=bool(ctx.interaction))
1328
 
1329
- @commands.hybrid_command(name="emoji_clone_panel", description="Open the emoji clone panel", with_app_command=True)
1330
  @commands.has_permissions(manage_emojis=True)
1331
  async def emoji_clone_panel(self, ctx: commands.Context) -> None:
1332
  await self.admin_emoji_panel(ctx)
@@ -1377,7 +1385,7 @@ class Admin(commands.Cog):
1377
  )
1378
  await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction))
1379
 
1380
- @commands.hybrid_command(name="awesomeroles")
1381
  @commands.has_permissions(manage_roles=True)
1382
  async def awesomeroles(self, ctx: commands.Context) -> None:
1383
  """Create awesome roles for the server with proper permissions."""
@@ -1442,7 +1450,7 @@ class Admin(commands.Cog):
1442
  embed.set_footer(text=f"{E_STAR} Created by {ctx.author.display_name}")
1443
  await ctx.reply(embed=embed)
1444
 
1445
- @commands.hybrid_command(name="backupserver")
1446
  @commands.has_permissions(administrator=True)
1447
  async def backupserver(self, ctx: commands.Context) -> None:
1448
  """Create a backup of server structure."""
@@ -1516,7 +1524,7 @@ class Admin(commands.Cog):
1516
  )
1517
  await ctx.reply(embed=embed)
1518
 
1519
- @commands.hybrid_command(name="backup_panel", description="Interactive backup management panel")
1520
  @commands.has_permissions(administrator=True)
1521
  async def backup_panel(self, ctx: commands.Context) -> None:
1522
  """Open the interactive backup management panel."""
@@ -1757,7 +1765,7 @@ class SlowmodeCommandMixin:
1757
  pass
1758
 
1759
 
1760
- @commands.hybrid_command(name="slowmode")
1761
  @commands.has_permissions(manage_channels=True)
1762
  async def slowmode(self, ctx: commands.Context, seconds: int = 0) -> None:
1763
  """Set slowmode for the current channel."""
@@ -1779,7 +1787,7 @@ class SlowmodeCommandMixin:
1779
  embed = success_embed("⏱️ Slowmode Disabled", f"📺 **Channel:** {ctx.channel.mention}")
1780
  await ctx.reply(embed=embed)
1781
 
1782
- @commands.hybrid_command(name="lock")
1783
  @commands.has_permissions(manage_channels=True)
1784
  async def lock(self, ctx: commands.Context, channel: discord.TextChannel | None = None) -> None:
1785
  """Lock a channel to prevent messages."""
@@ -1797,7 +1805,7 @@ class SlowmodeCommandMixin:
1797
  embed = success_embed("🔒 Channel Locked", f"📺 **Channel:** {channel.mention}")
1798
  await ctx.reply(embed=embed)
1799
 
1800
- @commands.hybrid_command(name="unlock")
1801
  @commands.has_permissions(manage_channels=True)
1802
  async def unlock(self, ctx: commands.Context, channel: discord.TextChannel | None = None) -> None:
1803
  """Unlock a channel to allow messages."""
 
19
  NEON_BLUE, NEON_YELLOW, panel_divider, success_embed, error_embed, warning_embed, info_embed,
20
  double_line, triple_line, shimmer, pick_neon_color, add_banner_to_embed
21
  )
22
+ from bot.i18n import get_cmd_desc
23
  from bot.emojis import (
24
  ui, E_SHIELD, E_CROWN, E_TROPHY, E_FIRE, E_SPARKLE, E_LOCK, E_KEY,
25
  E_ARROW_BLUE, E_ARROW_GREEN, E_ARROW_PINK, E_ARROW_PURPLE, E_GEM, E_STAR
 
30
  # AWESOME ROLES DEFINITIONS
31
  # ═══════════════════════════════════════════════════════════════════════════════
32
 
33
+ AWESOME_ROLES = [
34
  {
35
  "name": "✨ Cyan Legend",
36
  "color": NEON_CYAN,
 
80
  "hoist": True,
81
  "description": "Trusted member with azure power"
82
  },
83
+ ]
84
+
85
+ PRESENCE_STATUS_MAP: dict[str, discord.Status] = {
86
+ "online": discord.Status.online,
87
+ "idle": discord.Status.idle,
88
+ "dnd": discord.Status.dnd,
89
+ "invisible": discord.Status.invisible,
90
+ "offline": discord.Status.invisible,
91
+ }
92
+
93
+ PRESENCE_ACTIVITY_MAP: dict[str, discord.ActivityType] = {
94
+ "playing": discord.ActivityType.playing,
95
+ "watching": discord.ActivityType.watching,
96
+ "listening": discord.ActivityType.listening,
97
+ "competing": discord.ActivityType.competing,
98
+ }
99
+
100
+
101
+ class AdminMasterPanel(discord.ui.View):
102
  def __init__(self, cog: "Admin") -> None:
103
  super().__init__(timeout=None)
104
  self.cog = cog
 
129
  )
130
  return
131
  if choice == "shield":
132
+ events_cog = self.cog.bot.get_cog("Events")
133
+ if events_cog is None:
134
+ await interaction.response.send_message("❌ Events cog not loaded.", ephemeral=True)
135
+ return
136
+ from bot.cogs.events import ShieldControlPanel
137
+ panel = ShieldControlPanel(events_cog, interaction.guild.id)
138
+ embed = await panel._build_embed()
139
+ await interaction.response.edit_message(embed=embed, view=panel)
140
+ return
141
+ if choice == "status":
142
+ synced = "Enabled" if self.cog.bot.db._hf_sync_enabled else "Disabled"
143
+ activity_obj = getattr(self.cog.bot, "activity", None)
144
+ activity_name = getattr(activity_obj, "name", None) or "CYBER // GRID"
145
+ activity_type = str(getattr(activity_obj, "type", "playing")).replace("ActivityType.", "")
146
+ status_name = str(getattr(self.cog.bot, "status", "online")).replace("Status.", "")
147
+ await interaction.response.send_message(
148
+ (
149
+ f"HF DB Sync: **{synced}**\n"
150
+ f"Bot Status: **{status_name}**\n"
151
+ f"Bot Activity: **{activity_type}** • **{activity_name}**\n\n"
152
+ "Owner commands:\n"
153
+ "`/set_bot_status <status> <activity_type> <text>`\n"
154
+ "`/reset_bot_status`\n"
155
+ "`/bot_status`"
156
+ ),
157
+ ephemeral=True,
158
+ )
159
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  if choice == "giveaways":
161
  await interaction.response.send_message(
162
  "**Giveaway Commands:**\n"
 
419
  if not interaction.user.guild_permissions.manage_emojis:
420
  await interaction.response.send_message("Manage Emojis permission required.", ephemeral=True)
421
  return
422
+ source = str(self.source.value).strip()
423
+ name = str(self.target_name.value).strip() or None
424
  created, error_text = await self.cog._clone_emoji_to_guild(
425
  interaction.guild,
426
  interaction.user,
 
591
  )
592
  return before, after
593
 
594
+ async def _safe_ctx_send(
595
+ self,
596
+ ctx: commands.Context,
597
  *,
598
  content: str | None = None,
599
  embed: discord.Embed | None = None,
 
625
 
626
  if ctx.channel:
627
  channel_kwargs = {k: v for k, v in kwargs.items() if k != "ephemeral"}
628
+ msg = await ctx.channel.send(**channel_kwargs)
629
+ if delete_after:
630
+ await msg.delete(delay=delete_after)
631
+
632
+ def _presence_status_label(self, status: discord.Status | str | None) -> str:
633
+ value = str(status or "online").lower()
634
+ return {
635
+ "online": "online",
636
+ "idle": "idle",
637
+ "dnd": "dnd",
638
+ "invisible": "invisible",
639
+ "offline": "invisible",
640
+ }.get(value, "online")
641
+
642
+ def _presence_activity_label(self, activity_type: discord.ActivityType | str | None) -> str:
643
+ value = str(activity_type or "playing").lower()
644
+ return {
645
+ "playing": "playing",
646
+ "watching": "watching",
647
+ "listening": "listening",
648
+ "competing": "competing",
649
+ }.get(value, "playing")
650
+
651
+ async def _set_bot_presence(
652
+ self,
653
+ actor_id: int,
654
+ *,
655
+ status_name: str,
656
+ activity_type_name: str,
657
+ activity_text: str,
658
+ ) -> None:
659
+ resolved_status = PRESENCE_STATUS_MAP.get(status_name, discord.Status.online)
660
+ resolved_activity_type = PRESENCE_ACTIVITY_MAP.get(activity_type_name, discord.ActivityType.playing)
661
+ await self.bot.change_presence(
662
+ status=resolved_status,
663
+ activity=discord.Activity(type=resolved_activity_type, name=activity_text),
664
+ )
665
+ await self.bot.db.execute(
666
+ "INSERT INTO bot_presence_config(id, status, activity_type, activity_text, updated_by, updated_at) "
667
+ "VALUES (1, ?, ?, ?, ?, ?) "
668
+ "ON CONFLICT(id) DO UPDATE SET "
669
+ "status = excluded.status, "
670
+ "activity_type = excluded.activity_type, "
671
+ "activity_text = excluded.activity_text, "
672
+ "updated_by = excluded.updated_by, "
673
+ "updated_at = excluded.updated_at",
674
+ status_name,
675
+ activity_type_name,
676
+ activity_text,
677
+ actor_id,
678
+ dt.datetime.utcnow().isoformat(),
679
+ )
680
+
681
+ async def _presence_embed(self) -> discord.Embed:
682
+ current_activity = getattr(self.bot, "activity", None)
683
+ activity_text = getattr(current_activity, "name", None) or "CYBER // GRID"
684
+ activity_type_name = self._presence_activity_label(getattr(current_activity, "type", None))
685
+ status_name = self._presence_status_label(getattr(self.bot, "status", None))
686
+ row = await self.bot.db.fetchone(
687
+ "SELECT updated_by, updated_at FROM bot_presence_config WHERE id = 1"
688
+ )
689
+ updated_by = f"<@{row[0]}>" if row and row[0] else "Unknown"
690
+ updated_at = row[1] if row and row[1] else "Not saved yet"
691
+ embed = info_embed(
692
+ "Bot Presence",
693
+ (
694
+ f"Status: **{status_name}**\n"
695
+ f"Activity: **{activity_type_name}**\n"
696
+ f"Text: **{activity_text}**\n\n"
697
+ f"Updated by: {updated_by}\n"
698
+ f"Updated at: `{updated_at}`"
699
+ ),
700
+ )
701
+ return embed
702
+
703
+ @commands.hybrid_command(name="purge", description=get_cmd_desc("commands.admin.purge_desc"))
704
  @commands.has_permissions(manage_messages=True)
705
  async def purge(self, ctx: commands.Context, amount: int) -> None:
706
  """Delete a number of messages from the channel."""
 
720
  embed = success_embed("🗑️ Messages Purged", f"{panel_divider('green')}\n{desc}\n{panel_divider('green')}")
721
  await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction), delete_after=5 if not ctx.interaction else None)
722
 
723
+ @commands.hybrid_command(name="admin_panel", description=get_cmd_desc("commands.admin.admin_panel_desc"), with_app_command=False)
724
  @commands.has_permissions(administrator=True)
725
  async def admin_panel(self, ctx: commands.Context) -> None:
726
  if ctx.interaction and not ctx.interaction.response.is_done():
 
732
  ephemeral=bool(ctx.interaction),
733
  )
734
 
735
+ @commands.hybrid_group(name="admin", fallback="panel", description="Administrative grouped controls")
736
+ @commands.has_permissions(administrator=True)
737
+ async def admin_group(self, ctx: commands.Context) -> None:
738
+ await self.admin_panel(ctx)
739
+
740
+ @admin_group.group(name="botstatus", invoke_without_command=True)
741
+ @commands.is_owner()
742
+ async def admin_botstatus_group(self, ctx: commands.Context) -> None:
743
+ embed = await self._presence_embed()
744
+ await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction))
745
+
746
+ @admin_botstatus_group.command(name="set")
747
+ @commands.is_owner()
748
+ async def admin_botstatus_set(
749
+ self,
750
+ ctx: commands.Context,
751
+ status: str,
752
+ activity_type: str,
753
+ *,
754
+ text: str,
755
+ ) -> None:
756
+ status_name = (status or "").strip().lower()
757
+ activity_type_name = (activity_type or "").strip().lower()
758
+ activity_text = (text or "").strip()[:128]
759
+
760
+ if status_name not in PRESENCE_STATUS_MAP:
761
+ await self._safe_ctx_send(
762
+ ctx,
763
+ content="Invalid status. Use: `online`, `idle`, `dnd`, `invisible`",
764
+ ephemeral=bool(ctx.interaction),
765
+ )
766
+ return
767
+ if activity_type_name not in PRESENCE_ACTIVITY_MAP:
768
+ await self._safe_ctx_send(
769
+ ctx,
770
+ content="Invalid activity type. Use: `playing`, `watching`, `listening`, `competing`",
771
+ ephemeral=bool(ctx.interaction),
772
+ )
773
+ return
774
+ if not activity_text:
775
+ await self._safe_ctx_send(ctx, content="Activity text cannot be empty.", ephemeral=bool(ctx.interaction))
776
+ return
777
+
778
+ await self._set_bot_presence(
779
+ ctx.author.id,
780
+ status_name=status_name,
781
+ activity_type_name=activity_type_name,
782
+ activity_text=activity_text,
783
+ )
784
+ embed = success_embed(
785
+ "Bot Status Updated",
786
+ (
787
+ f"Status: **{status_name}**\n"
788
+ f"Activity: **{activity_type_name}**\n"
789
+ f"Text: **{activity_text}**"
790
+ ),
791
+ )
792
+ await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction))
793
+
794
+ @admin_botstatus_group.command(name="reset")
795
+ @commands.is_owner()
796
+ async def admin_botstatus_reset(self, ctx: commands.Context) -> None:
797
+ await self._set_bot_presence(
798
+ ctx.author.id,
799
+ status_name="online",
800
+ activity_type_name="playing",
801
+ activity_text="CYBER // GRID",
802
+ )
803
+ embed = success_embed("Bot Status Reset", "Restored default presence.")
804
+ await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction))
805
+
806
+ @commands.hybrid_command(name="bot_status", description=get_cmd_desc("commands.admin.bot_status_desc"), with_app_command=True)
807
+ @commands.is_owner()
808
+ async def show_bot_status(self, ctx: commands.Context) -> None:
809
+ embed = await self._presence_embed()
810
+ await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction))
811
+
812
+ @commands.hybrid_command(name="set_bot_status", description=get_cmd_desc("commands.admin.set_bot_status_desc"), with_app_command=True)
813
+ @commands.is_owner()
814
+ async def update_bot_status(
815
+ self,
816
+ ctx: commands.Context,
817
+ status: str,
818
+ activity_type: str,
819
+ *,
820
+ text: str,
821
+ ) -> None:
822
+ await self.admin_botstatus_set(ctx, status, activity_type, text=text)
823
+
824
+ @commands.hybrid_command(name="reset_bot_status", description=get_cmd_desc("commands.admin.reset_bot_status_desc"), with_app_command=True)
825
+ @commands.is_owner()
826
+ async def restore_bot_status(self, ctx: commands.Context) -> None:
827
+ await self.admin_botstatus_reset(ctx)
828
+
829
+ @admin_group.group(name="shield", invoke_without_command=True)
830
+ @commands.has_permissions(administrator=True)
831
+ async def admin_shield_group(self, ctx: commands.Context) -> None:
832
  await self._safe_ctx_send(
833
  ctx,
834
  content="Use `/admin shield state`, `/admin shield set_level <low|medium|high>`, or `/admin shield add_image <attachment>`",
 
863
  )
864
  await self._safe_ctx_send(ctx, content="✅ Scam image signature saved.", ephemeral=bool(ctx.interaction))
865
 
866
+ @commands.hybrid_command(name="shield_level", description=get_cmd_desc("commands.admin.shield_level_desc"), hidden=True, with_app_command=False)
867
  @commands.has_permissions(administrator=True)
868
  async def shield_level(self, ctx: commands.Context, level: str) -> None:
869
  if ctx.interaction and not ctx.interaction.response.is_done():
 
879
  )
880
  await self._safe_ctx_send(ctx, content=f"🛡️ Shield level set to `{normalized}`", ephemeral=bool(ctx.interaction))
881
 
882
+ @commands.hybrid_command(name="shield_state", description=get_cmd_desc("commands.admin.shield_state_desc"), with_app_command=True)
883
  @commands.has_permissions(administrator=True)
884
  async def shield_state(self, ctx: commands.Context) -> None:
885
  if not ctx.guild:
 
908
  )
909
  await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction))
910
 
911
+ @commands.hybrid_command(name="econ_admin", description=get_cmd_desc("commands.admin.econ_admin_desc"))
912
  @commands.has_permissions(administrator=True)
913
  async def econ_admin(self, ctx: commands.Context, member: discord.Member, action: str, amount: int) -> None:
914
  if ctx.interaction and not ctx.interaction.response.is_done():
 
930
  )
931
  await self._safe_ctx_send(ctx, content=f"💰 {member.mention} coins: `{coins}` → `{new_coins}`", ephemeral=bool(ctx.interaction))
932
 
933
+ @commands.hybrid_command(name="warn", description=get_cmd_desc("commands.admin.warn_desc"))
934
  @commands.has_permissions(manage_roles=True)
935
  async def warn(self, ctx: commands.Context, member: discord.Member, *, reason: str = "No reason provided") -> None:
936
  """Warn a member."""
 
978
  except discord.Forbidden:
979
  pass
980
 
981
+ @commands.hybrid_command(name="warnings", description=get_cmd_desc("commands.admin.warnings_desc"))
982
  @commands.has_permissions(manage_roles=True)
983
  async def warnings(self, ctx: commands.Context, member: discord.Member) -> None:
984
  """View warnings for a member."""
 
1013
  embed.set_thumbnail(url=member.display_avatar.url)
1014
  await ctx.reply(embed=embed)
1015
 
1016
+ @commands.hybrid_command(name="clearwarn", description=get_cmd_desc("commands.admin.clearwarn_desc"))
1017
  @commands.has_permissions(manage_roles=True)
1018
  async def clearwarn(self, ctx: commands.Context, member: discord.Member) -> None:
1019
  """Clear all warnings for a member."""
 
1032
  embed = success_embed("✅ Warnings Cleared", f"🗑️ All warnings for {member.mention} have been removed.")
1033
  await ctx.reply(embed=embed)
1034
 
1035
+ @commands.hybrid_command(name="kick", description=get_cmd_desc("commands.admin.kick_desc"))
1036
  @commands.has_permissions(kick_members=True)
1037
  async def kick(self, ctx: commands.Context, member: discord.Member, *, reason: str = "No reason provided") -> None:
1038
  """Kick a member from the server."""
 
1077
  )
1078
  await ctx.reply(embed=embed)
1079
 
1080
+ @commands.hybrid_command(name="ban", description=get_cmd_desc("commands.admin.ban_desc"))
1081
  @commands.has_permissions(ban_members=True)
1082
  async def ban(self, ctx: commands.Context, member: discord.Member, *, reason: str = "No reason provided") -> None:
1083
  """Ban a member from the server."""
 
1128
  color=discord.Color.red(),
1129
  )
1130
 
1131
+ @commands.hybrid_command(name="unban", description=get_cmd_desc("commands.admin.unban_desc"))
1132
  @commands.has_permissions(ban_members=True)
1133
  async def unban(self, ctx: commands.Context, user_id: int, *, reason: str = "No reason provided") -> None:
1134
  """Unban a user by their ID."""
 
1186
  color=discord.Color.green(),
1187
  )
1188
 
1189
+ @commands.hybrid_command(name="mute", description=get_cmd_desc("commands.admin.mute_desc"))
1190
  @commands.has_permissions(moderate_members=True)
1191
  async def mute(self, ctx: commands.Context, member: discord.Member, duration: int = 10, *, reason: str = "No reason provided") -> None:
1192
  """Timeout a member for a specified duration in minutes."""
 
1233
  color=discord.Color.red(),
1234
  )
1235
 
1236
+ @commands.hybrid_command(name="unmute", description=get_cmd_desc("commands.admin.unmute_desc"))
1237
  @commands.has_permissions(moderate_members=True)
1238
  async def unmute(self, ctx: commands.Context, member: discord.Member, *, reason: str = "No reason provided") -> None:
1239
  """Remove timeout from a member."""
 
1328
  if not ctx.guild:
1329
  await self._safe_ctx_send(ctx, content="Server only.", ephemeral=bool(ctx.interaction))
1330
  return
1331
+ embed = info_embed(
1332
+ "Emoji Clone Panel",
1333
+ "Quick actions:\n- Clone from tag/ID/URL/name\n- View server emojis\n- Open bot emoji picker",
1334
+ )
1335
  await self._safe_ctx_send(ctx, embed=embed, view=EmojiClonePanelView(self), ephemeral=bool(ctx.interaction))
1336
 
1337
+ @commands.hybrid_command(name="emoji_clone_panel", description=get_cmd_desc("commands.admin.emoji_clone_panel_desc"), with_app_command=True)
1338
  @commands.has_permissions(manage_emojis=True)
1339
  async def emoji_clone_panel(self, ctx: commands.Context) -> None:
1340
  await self.admin_emoji_panel(ctx)
 
1385
  )
1386
  await self._safe_ctx_send(ctx, embed=embed, ephemeral=bool(ctx.interaction))
1387
 
1388
+ @commands.hybrid_command(name="awesomeroles", description=get_cmd_desc("commands.admin.awesomeroles_desc"))
1389
  @commands.has_permissions(manage_roles=True)
1390
  async def awesomeroles(self, ctx: commands.Context) -> None:
1391
  """Create awesome roles for the server with proper permissions."""
 
1450
  embed.set_footer(text=f"{E_STAR} Created by {ctx.author.display_name}")
1451
  await ctx.reply(embed=embed)
1452
 
1453
+ @commands.hybrid_command(name="backupserver", description=get_cmd_desc("commands.admin.backupserver_desc"))
1454
  @commands.has_permissions(administrator=True)
1455
  async def backupserver(self, ctx: commands.Context) -> None:
1456
  """Create a backup of server structure."""
 
1524
  )
1525
  await ctx.reply(embed=embed)
1526
 
1527
+ @commands.hybrid_command(name="backup_panel", description=get_cmd_desc("commands.admin.backup_panel_desc"))
1528
  @commands.has_permissions(administrator=True)
1529
  async def backup_panel(self, ctx: commands.Context) -> None:
1530
  """Open the interactive backup management panel."""
 
1765
  pass
1766
 
1767
 
1768
+ @commands.hybrid_command(name="slowmode", description=get_cmd_desc("commands.admin.slowmode_desc"))
1769
  @commands.has_permissions(manage_channels=True)
1770
  async def slowmode(self, ctx: commands.Context, seconds: int = 0) -> None:
1771
  """Set slowmode for the current channel."""
 
1787
  embed = success_embed("⏱️ Slowmode Disabled", f"📺 **Channel:** {ctx.channel.mention}")
1788
  await ctx.reply(embed=embed)
1789
 
1790
+ @commands.hybrid_command(name="lock", description=get_cmd_desc("commands.admin.lock_desc"))
1791
  @commands.has_permissions(manage_channels=True)
1792
  async def lock(self, ctx: commands.Context, channel: discord.TextChannel | None = None) -> None:
1793
  """Lock a channel to prevent messages."""
 
1805
  embed = success_embed("🔒 Channel Locked", f"📺 **Channel:** {channel.mention}")
1806
  await ctx.reply(embed=embed)
1807
 
1808
+ @commands.hybrid_command(name="unlock", description=get_cmd_desc("commands.admin.unlock_desc"))
1809
  @commands.has_permissions(manage_channels=True)
1810
  async def unlock(self, ctx: commands.Context, channel: discord.TextChannel | None = None) -> None:
1811
  """Unlock a channel to allow messages."""
bot/cogs/ai_admin.py CHANGED
@@ -3,15 +3,16 @@ 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
@@ -59,177 +60,246 @@ class IntelligenceLayer:
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,
@@ -238,15 +308,15 @@ IMPORTANT: For any request that involves creating events, giveaways, tournaments
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(
@@ -257,7 +327,7 @@ IMPORTANT: For any request that involves creating events, giveaways, tournaments
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:
@@ -288,6 +358,9 @@ class ExecutionEngine:
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)
@@ -301,101 +374,107 @@ class ExecutionEngine:
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")
@@ -411,11 +490,11 @@ class ExecutionEngine:
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")
@@ -489,214 +568,339 @@ class ExecutionEngine:
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:
@@ -712,34 +916,34 @@ class AIAdmin(commands.Cog):
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:
@@ -749,128 +953,166 @@ class AIAdmin(commands.Cog):
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:
@@ -885,46 +1127,51 @@ class AIAdmin(commands.Cog):
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:
 
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
+ from bot.i18n import get_cmd_desc
11
+ import re
12
+ from typing import Any
13
 
14
  import discord
15
+ from discord.ext import commands, tasks
16
 
17
  try:
18
  import aiohttp
 
60
  api_key = getattr(settings, "openrouter_api_key", None)
61
  if not api_key:
62
  return None
63
+
64
  model = getattr(settings, "openrouter_model", "openai/gpt-4o-mini") or "openai/gpt-4o-mini"
 
 
 
65
 
66
+ system_message = """You are a Human-like Discord Server Manager — an expert administrator who understands server structure, member behavior, and aesthetic organization.
67
+
68
+ You receive natural language requests (Arabic, English, or mixed) and output ONLY a valid JSON array of actions. Do NOT include any other text.
69
+
70
+ ═══════════════════════════════════════════════════════
71
+ YOUR CAPABILITIES
72
+ ═══════════════════════════════════════════════════════
73
 
74
+ ━━━ SERVER ORCHESTRATION ━━━
75
+ 1. CREATE_ROLE: Create a role with professional colors
76
  {
77
  "action": "create_role",
78
  "name": "Role Name",
79
+ "color": "#800080",
80
  "hoist": true,
81
+ "mentionable": false,
82
+ "reason": "Professional reason"
83
  }
84
 
85
+ 2. CREATE_CHANNEL: Create text/voice channels with emojis
86
  {
87
  "action": "create_channel",
88
+ "name": "💬-general-chat",
89
  "type": "text",
90
+ "category": "Community",
91
+ "topic": "Channel topic",
92
+ "locked_to_roles": ["Staff"],
93
+ "deny_everyone": true,
94
+ "reason": "Why needed"
95
  }
96
 
97
+ 3. CREATE_CATEGORY: Organize server sections
98
  {
99
+ "action": "create_category",
100
+ "name": "🏆 Competitions",
101
+ "reason": "Organize competition channels"
102
+ }
103
+
104
+ 4. PERMISSION_SETUP: Full permission architecture
105
+ {
106
+ "action": "setup_permissions",
107
  "channel": "channel-name",
108
+ "deny_roles": ["@everyone", "Member"],
109
+ "allow_roles": ["Staff", "Admin"],
110
+ "deny_permissions": ["send_messages", "view_channel"],
111
+ "allow_permissions": ["send_messages", "view_channel", "manage_messages"],
112
+ "reason": "Staff-only room setup"
113
  }
114
 
115
+ ━━━ COMMUNITY FEATURES ━━━
116
+ 5. ANNOUNCE: Rich embed announcement
117
+ {
118
+ "action": "announce",
119
+ "channel": "announcements",
120
+ "title": "📢 Important Update",
121
+ "description": "Announcement body",
122
+ "color": "#00FFFF",
123
+ "fields": [{"name": "Field 1", "value": "Value 1", "inline": true}],
124
+ "footer": "Footer text"
125
+ }
126
+
127
+ 6. CREATE_GIVEAWAY: Feature setup
128
  {
129
  "action": "create_giveaway",
130
+ "prize": "Discord Nitro",
131
+ "duration_minutes": 1440,
132
  "winners": 1,
133
+ "channel": "giveaways"
134
  }
135
 
136
+ 7. CREATE_TOURNAMENT: Tournament setup
137
  {
138
  "action": "create_tournament",
139
+ "name": "Valorant Cup",
140
+ "game": "Valorant",
141
  "max_participants": 16,
142
+ "channel": "tournaments"
143
  }
144
 
145
+ 8. CREATE_POLL: Community poll
146
  {
147
  "action": "create_poll",
148
+ "question": "What game should we play?",
149
+ "options": ["Valorant", "Minecraft", "Fortnite"],
150
+ "duration_minutes": 60
151
+ }
152
+
153
+ ━━━ MEMBER MANAGEMENT ━━━
154
+ 9. TIMEOUT_MEMBER: Temporary timeout
155
+ {
156
+ "action": "timeout_member",
157
+ "member": "@user or user_id",
158
+ "minutes": 30,
159
+ "reason": "Rule violation"
160
+ }
161
+
162
+ 10. UNTIMEOUT_MEMBER: Remove timeout
163
+ {
164
+ "action": "untimeout_member",
165
+ "member": "@user or user_id",
166
+ "reason": "Timeout removed"
167
+ }
168
+
169
+ 11. ADD_ROLE: Assign role to member
170
+ {
171
+ "action": "add_role",
172
+ "member": "@user or user_id",
173
+ "role": "VIP",
174
+ "reason": "Reward for contribution"
175
+ }
176
+
177
+ 12. REMOVE_ROLE: Remove role from member
178
+ {
179
+ "action": "remove_role",
180
+ "member": "@user or user_id",
181
+ "role": "Muted",
182
+ "reason": "Timeout period ended"
183
+ }
184
+
185
+ ━━━ CHANNEL MANAGEMENT ━━━
186
+ 13. LOCK_CHANNEL: Restrict channel
187
+ {
188
+ "action": "lock_channel",
189
+ "channel": "general",
190
+ "reason": "Raid prevention"
191
+ }
192
+
193
+ 14. UNLOCK_CHANNEL: Restore access
194
+ {
195
+ "action": "unlock_channel",
196
+ "channel": "general",
197
+ "reason": "Situation resolved"
198
+ }
199
+
200
+ 15. SET_SLOWMODE: Set delay
201
+ {
202
+ "action": "set_slowmode",
203
+ "channel": "general",
204
+ "seconds": 10,
205
+ "reason": "Reduce spam"
206
+ }
207
+
208
+ 16. PURGE_MESSAGES: Clean channel
209
+ {
210
+ "action": "purge_messages",
211
+ "channel": "general",
212
+ "amount": 50,
213
+ "reason": "Spam cleanup"
214
+ }
215
+
216
+ 17. DELETE_CHANNEL: Remove channel
217
+ {
218
+ "action": "delete_channel",
219
+ "channel": "old-channel",
220
+ "reason": "No longer needed"
221
  }
222
 
223
+ 18. RENAME_CHANNEL: Update name
224
+ {
225
+ "action": "rename_channel",
226
+ "channel": "old-name",
227
+ "new_name": "🎮-new-name",
228
+ "reason": "Better organization"
229
+ }
230
+
231
+ 19. RENAME_CATEGORY: Update category
232
+ {
233
+ "action": "rename_category",
234
+ "category": "Old Category",
235
+ "new_name": "🎮 New Category",
236
+ "reason": "Aesthetic update"
237
+ }
238
+
239
+ 20. DELETE_CATEGORY: Remove category
240
+ {
241
+ "action": "delete_category",
242
+ "category": "Unused Category",
243
+ "reason": "Cleanup"
244
+ }
245
+
246
+ ━━━ SCHEDULING & ANALYSIS ━━━
247
+ 21. SCHEDULE_TASK: Plan future action
248
+ {
249
+ "action": "schedule_task",
250
+ "run_at": "2025-01-15 21:00",
251
+ "action_to_run": {"action": "unlock_channel", "channel": "events", "reason": "Scheduled opening"},
252
+ "reason": "Auto-open events channel at 9 PM"
253
+ }
254
+
255
+ 22. ANALYZE_ACTIVITY: Member activity report
256
+ {
257
+ "action": "analyze_activity",
258
+ "scope": "all" or "category-name" or "channel-name",
259
+ "period": "24h" or "7d" or "30d"
260
+ }
261
+
262
+ 23. RUN_COMMAND: Fallback for anything else
263
+ {
264
+ "action": "run_command",
265
+ "command": "command_name",
266
+ "args": {"arg1": "value1"}
267
+ }
268
+
269
+ ═══════════════════════════════════════════════════════
270
+ YOUR BEHAVIOR RULES
271
+ ═══════════════════════════════════════════════════════
272
+
273
+ 🎨 AESTHETIC AUTONOMY:
274
+ - Use appropriate emojis in channel names (💬 chat, 🎮 gaming, 📢 announcements, 🎉 events)
275
+ - Choose professional hex colors: #1ABC9C (teal), #9B59B6 (purple), #E74C3C (red), #F1C40F (gold), #2ECC71 (green), #3498DB (blue)
276
+ - For staff roles: use purple (#9B59B6) or gold (#F1C40F)
277
+ - For member roles: use teal (#1ABC9C) or green (#2ECC71)
278
+
279
+ 🏗️ STRATEGIC PLANNING:
280
+ - When asked to "setup X section" (e.g., "جهز قسم المسابقات"), plan multiple steps: create_category → create_roles → create_channels → setup_permissions
281
+ - Always think about the complete structure, not just one action
282
+
283
+ 🔒 PERMISSION ARCHITECTURE:
284
+ - For "staff-only" or "private" channels: use setup_permissions with deny_everyone: true and allow_roles: ["Staff"]
285
+ - Understand hierarchy: @everyone should be denied, specific roles should be allowed
286
+
287
+ 🗣️ HUMAN-LIKE RESPONSES:
288
+ - ALWAYS include "response_to_user" as the FIRST field in the FIRST action
289
+ - Match the user's language (Arabic → Arabic response, English → English)
290
+ - Be professional but conversational — explain your choices
291
+ - For Arabic: use administrative terms (تم إنشاء، تم تعيين، القسم جاهز)
292
+
293
+ ⚠️ RISK ASSESSMENT:
294
+ - For destructive actions (delete, purge): mention impact in response_to_user
295
+ - Example: "سأحذف 50 رسالة من قناة general — هل أنت متأكد؟"
296
+
297
+ 📝 MULTI-ACTION CHAINS:
298
+ - Return ALL actions needed in one response
299
+ - Example: "جهز قسم الألعاب" → create_category → create_role → create_channel (multiple) → setup_permissions
300
+
301
+ OUTPUT FORMAT: JSON array only. No markdown. No explanation outside the JSON.
302
+ ALWAYS start the FIRST object with "response_to_user" field."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
 
304
  payload = {
305
  "model": model,
 
308
  {"role": "user", "content": prompt}
309
  ],
310
  "temperature": 0.3,
311
+ "max_tokens": 3000
312
  }
313
+
314
  headers = {
315
  "Authorization": f"Bearer {api_key}",
316
  "Content-Type": "application/json",
317
  "HTTP-Referer": "https://github.com/mega-bot",
318
  }
319
+
320
  try:
321
  session = await self._get_session()
322
  async with session.post(
 
327
  if resp.status != 200:
328
  return None
329
  data = await resp.json()
330
+
331
  content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
332
  return self._parse_json_response(content)
333
  except Exception:
 
358
  results = []
359
  for action in actions:
360
  action_type = action.get("action", "")
361
+ # Skip response_to_user — it's metadata, not an action
362
+ if action_type == "response_to_user":
363
+ continue
364
  try:
365
  if action_type == "create_role":
366
  result = await self._create_role(action, ctx)
 
374
  result = await self._create_tournament(action, ctx)
375
  elif action_type == "create_poll":
376
  result = await self._create_poll(action, ctx)
377
+ elif action_type == "run_command":
378
+ result = await self._run_command(action, ctx)
379
+ elif action_type == "timeout_member":
380
+ result = await self._timeout_member(action, ctx)
381
+ elif action_type == "untimeout_member":
382
+ result = await self._untimeout_member(action, ctx)
383
+ elif action_type == "add_role":
384
+ result = await self._add_role(action, ctx)
385
+ elif action_type == "remove_role":
386
+ result = await self._remove_role(action, ctx)
387
+ elif action_type == "lock_channel":
388
+ result = await self._lock_channel(action, ctx)
389
+ elif action_type == "unlock_channel":
390
+ result = await self._unlock_channel(action, ctx)
391
+ elif action_type == "set_slowmode":
392
+ result = await self._set_slowmode(action, ctx)
393
+ elif action_type == "purge_messages":
394
+ result = await self._purge_messages(action, ctx)
395
+ elif action_type == "delete_channel":
396
+ result = await self._delete_channel(action, ctx)
397
+ elif action_type == "rename_channel":
398
+ result = await self._rename_channel(action, ctx)
399
+ elif action_type == "create_category":
400
+ result = await self._create_category(action, ctx)
401
+ elif action_type == "rename_category":
402
+ result = await self._rename_category(action, ctx)
403
+ elif action_type == "delete_category":
404
+ result = await self._delete_category(action, ctx)
405
+ elif action_type == "setup_permissions":
406
+ result = await self._setup_permissions(action, ctx)
407
+ elif action_type == "schedule_task":
408
+ result = await self._schedule_task(action, ctx)
409
+ elif action_type == "analyze_activity":
410
+ result = await self._analyze_activity(action, ctx)
411
+ else:
412
+ result = f"Unknown action: {action_type}"
413
+ results.append(result)
414
+ except Exception as e:
415
+ results.append(f"Error executing {action_type}: {str(e)}")
416
+ return results
417
+
418
+ @staticmethod
419
+ def _resolve_member(guild: discord.Guild, ref: str | int | None) -> discord.Member | None:
420
+ if ref is None:
421
+ return None
422
+ text = str(ref).strip()
423
+ mention = re.search(r"<@!?(\d+)>", text)
424
+ if mention:
425
+ return guild.get_member(int(mention.group(1)))
426
+ if text.isdigit():
427
+ return guild.get_member(int(text))
428
+ return discord.utils.find(lambda m: m.name.lower() == text.lower() or m.display_name.lower() == text.lower(), guild.members)
429
+
430
+ @staticmethod
431
+ def _resolve_channel(guild: discord.Guild, ref: str | None, fallback: discord.abc.GuildChannel | None = None) -> discord.TextChannel | None:
432
+ if ref:
433
+ text = str(ref).strip()
434
+ mention = re.search(r"<#(\d+)>", text)
435
+ if mention:
436
+ ch = guild.get_channel(int(mention.group(1)))
437
+ return ch if isinstance(ch, discord.TextChannel) else None
438
+ by_name = discord.utils.get(guild.text_channels, name=text)
439
+ if by_name:
440
+ return by_name
441
+ if isinstance(fallback, discord.TextChannel):
442
+ return fallback
443
+ return None
444
+
445
+ @staticmethod
446
+ def _resolve_guild_channel(
447
+ guild: discord.Guild,
448
+ ref: str | int | None,
449
+ fallback: discord.abc.GuildChannel | None = None,
450
+ ) -> discord.abc.GuildChannel | None:
451
+ if ref is not None:
452
+ text = str(ref).strip()
453
+ mention = re.search(r"<#(\d+)>", text)
454
+ if mention:
455
+ return guild.get_channel(int(mention.group(1)))
456
+ if text.isdigit():
457
+ return guild.get_channel(int(text))
458
+ by_name = discord.utils.find(lambda c: c.name.lower() == text.lower(), guild.channels)
459
+ if by_name:
460
+ return by_name
461
+ if isinstance(fallback, discord.abc.GuildChannel):
462
+ return fallback
463
+ return None
464
+
465
+ @staticmethod
466
+ def _resolve_category(guild: discord.Guild, ref: str | int | None) -> discord.CategoryChannel | None:
467
+ if ref is None:
468
+ return None
469
+ text = str(ref).strip()
470
+ mention = re.search(r"<#(\d+)>", text)
471
+ if mention:
472
+ ch = guild.get_channel(int(mention.group(1)))
473
+ return ch if isinstance(ch, discord.CategoryChannel) else None
474
+ if text.isdigit():
475
+ ch = guild.get_channel(int(text))
476
+ return ch if isinstance(ch, discord.CategoryChannel) else None
477
+ return discord.utils.find(lambda c: c.name.lower() == text.lower(), guild.categories)
478
 
479
  async def _create_role(self, action: dict[str, Any], ctx: commands.Context) -> str:
480
  name = action.get("name", "New Role")
 
490
  role = await ctx.guild.create_role(name=name, color=color, hoist=hoist, reason=reason)
491
  return f"Created role: {role.mention}"
492
 
493
+ async def _create_channel(self, action: dict[str, Any], ctx: commands.Context) -> str:
494
+ if not ctx.author.guild_permissions.manage_channels:
495
+ return "Manage Channels permission required."
496
+ name = action.get("name", "new-channel")
497
+ channel_type = action.get("type", "text")
498
  category_name = action.get("category")
499
  locked_roles = action.get("locked_to_roles", [])
500
  reason = action.get("reason", "AI Admin")
 
568
  await cog.giveaway_create(ctx, int(duration), int(winners), prize=prize)
569
  return f"Giveaway created: {prize}"
570
 
571
+ async def _create_tournament(self, action: dict[str, Any], ctx: commands.Context) -> str:
572
+ name = action.get("name", "Tournament")
573
+ game = action.get("game", "Game")
574
+ max_participants = action.get("max_participants", 16)
575
+
576
+ cog = self.bot.get_cog("Engagement")
577
+ if not cog:
578
+ return "Engagement cog not found."
579
+
580
+ # Engagement exposes tournament creation via the base `/tournament` command.
581
+ games = f"{game}" if game else "chess, checkers, connect4, othello"
582
+ await ctx.invoke(cog.tournament, name=name, games=games)
583
+ return f"Tournament created: {name}"
584
+
585
+ async def _create_poll(self, action: dict[str, Any], ctx: commands.Context) -> str:
586
+ question = action.get("question", "Poll")
587
+ options = action.get("options", ["Yes", "No"])
588
+
589
+ cog = self.bot.get_cog("Utility")
590
+ if not cog:
591
+ return "Utility cog not found."
592
+
593
+ # Utility poll command accepts options as a "|" separated string.
594
+ options_str = "|".join(str(o) for o in options)
595
+ await ctx.invoke(cog.poll, question=question, options=options_str)
596
+ return f"Poll created: {question}"
597
 
598
+ async def _run_command(self, action: dict[str, Any], ctx: commands.Context) -> str:
599
+ command_name = action.get("command", "")
600
+ args = action.get("args", {})
601
 
602
  command = self.bot.get_command(command_name)
603
  if not command:
604
  return f"Command not found: {command_name}"
605
 
606
+ try:
607
+ await ctx.invoke(command, **args)
608
+ return f"Command executed: {command_name}"
609
+ except Exception as e:
610
+ return f"Error running command: {str(e)}"
611
+
612
+ async def _timeout_member(self, action: dict[str, Any], ctx: commands.Context) -> str:
613
+ member = self._resolve_member(ctx.guild, action.get("member"))
614
+ if not member:
615
+ return "Member not found."
616
+ minutes = max(1, min(int(action.get("minutes", 10)), 40320))
617
+ reason = action.get("reason", "AI Admin timeout")
618
+ until = discord.utils.utcnow() + dt.timedelta(minutes=minutes)
619
+ await member.timeout(until, reason=reason)
620
+ return f"Timed out {member.mention} for {minutes} minute(s)."
621
+
622
+ async def _untimeout_member(self, action: dict[str, Any], ctx: commands.Context) -> str:
623
+ member = self._resolve_member(ctx.guild, action.get("member"))
624
+ if not member:
625
+ return "Member not found."
626
+ reason = action.get("reason", "AI Admin untimeout")
627
+ await member.timeout(None, reason=reason)
628
+ return f"Removed timeout from {member.mention}."
629
+
630
+ async def _add_role(self, action: dict[str, Any], ctx: commands.Context) -> str:
631
+ member = self._resolve_member(ctx.guild, action.get("member"))
632
+ if not member:
633
+ return "Member not found."
634
+ role_name = str(action.get("role", "")).strip()
635
+ role = discord.utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles)
636
+ if not role:
637
+ return f"Role not found: {role_name}"
638
+ reason = action.get("reason", "AI Admin add role")
639
+ await member.add_roles(role, reason=reason)
640
+ return f"Added role **{role.name}** to {member.mention}."
641
+
642
+ async def _remove_role(self, action: dict[str, Any], ctx: commands.Context) -> str:
643
+ member = self._resolve_member(ctx.guild, action.get("member"))
644
+ if not member:
645
+ return "Member not found."
646
+ role_name = str(action.get("role", "")).strip()
647
+ role = discord.utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles)
648
+ if not role:
649
+ return f"Role not found: {role_name}"
650
+ reason = action.get("reason", "AI Admin remove role")
651
+ await member.remove_roles(role, reason=reason)
652
+ return f"Removed role **{role.name}** from {member.mention}."
653
+
654
+ async def _lock_channel(self, action: dict[str, Any], ctx: commands.Context) -> str:
655
+ if not ctx.author.guild_permissions.manage_channels:
656
+ return "Manage Channels permission required."
657
+ channel = self._resolve_channel(ctx.guild, action.get("channel"), ctx.channel)
658
+ if not channel:
659
+ return "Channel not found."
660
+ reason = action.get("reason", "AI Admin lock channel")
661
+ await channel.set_permissions(ctx.guild.default_role, send_messages=False, reason=reason)
662
+ return f"Locked channel {channel.mention}."
663
+
664
+ async def _unlock_channel(self, action: dict[str, Any], ctx: commands.Context) -> str:
665
+ if not ctx.author.guild_permissions.manage_channels:
666
+ return "Manage Channels permission required."
667
+ channel = self._resolve_channel(ctx.guild, action.get("channel"), ctx.channel)
668
+ if not channel:
669
+ return "Channel not found."
670
+ reason = action.get("reason", "AI Admin unlock channel")
671
+ await channel.set_permissions(ctx.guild.default_role, send_messages=True, reason=reason)
672
+ return f"Unlocked channel {channel.mention}."
673
+
674
+ async def _set_slowmode(self, action: dict[str, Any], ctx: commands.Context) -> str:
675
+ if not ctx.author.guild_permissions.manage_channels:
676
+ return "Manage Channels permission required."
677
+ channel = self._resolve_channel(ctx.guild, action.get("channel"), ctx.channel)
678
+ if not channel:
679
+ return "Channel not found."
680
+ seconds = max(0, min(int(action.get("seconds", 0)), 21600))
681
+ reason = action.get("reason", "AI Admin slowmode")
682
+ await channel.edit(slowmode_delay=seconds, reason=reason)
683
+ return f"Set slowmode in {channel.mention} to {seconds}s."
684
+
685
+ async def _purge_messages(self, action: dict[str, Any], ctx: commands.Context) -> str:
686
+ if not ctx.author.guild_permissions.manage_messages:
687
+ return "Manage Messages permission required."
688
+ channel = self._resolve_channel(ctx.guild, action.get("channel"), ctx.channel)
689
+ if not channel:
690
+ return "Channel not found."
691
+ amount = max(1, min(int(action.get("amount", 10)), 200))
692
+ deleted = await channel.purge(limit=amount)
693
+ return f"Purged {len(deleted)} message(s) in {channel.mention}."
694
+
695
+ async def _delete_channel(self, action: dict[str, Any], ctx: commands.Context) -> str:
696
+ if not ctx.author.guild_permissions.manage_channels:
697
+ return "Manage Channels permission required."
698
+ channel = self._resolve_guild_channel(ctx.guild, action.get("channel"), None)
699
+ if not channel:
700
+ return "Channel not found."
701
+ if ctx.channel and channel.id == ctx.channel.id:
702
+ return "Refusing to delete the channel currently being used for command execution."
703
+ reason = action.get("reason", "AI Admin delete channel")
704
+ name = channel.name
705
+ await channel.delete(reason=reason)
706
+ return f"Deleted channel #{name}."
707
+
708
+ async def _rename_channel(self, action: dict[str, Any], ctx: commands.Context) -> str:
709
+ if not ctx.author.guild_permissions.manage_channels:
710
+ return "Manage Channels permission required."
711
+ channel = self._resolve_guild_channel(ctx.guild, action.get("channel"), None)
712
+ if not channel:
713
+ return "Channel not found."
714
+ new_name = str(action.get("new_name", "")).strip()
715
+ if not new_name:
716
+ return "New channel name is required."
717
+ reason = action.get("reason", "AI Admin rename channel")
718
+ old_name = channel.name
719
+ await channel.edit(name=new_name[:100], reason=reason)
720
+ return f"Renamed channel **{old_name}** -> **{new_name[:100]}**."
721
+
722
+ async def _create_category(self, action: dict[str, Any], ctx: commands.Context) -> str:
723
+ if not ctx.author.guild_permissions.manage_channels:
724
+ return "Manage Channels permission required."
725
+ name = str(action.get("name", "new-category")).strip()[:100]
726
+ if not name:
727
+ return "Category name is required."
728
+ reason = action.get("reason", "AI Admin create category")
729
+ category = await ctx.guild.create_category(name=name, reason=reason)
730
+ return f"Created category: **{category.name}**."
731
+
732
+ async def _rename_category(self, action: dict[str, Any], ctx: commands.Context) -> str:
733
+ if not ctx.author.guild_permissions.manage_channels:
734
+ return "Manage Channels permission required."
735
+ category = self._resolve_category(ctx.guild, action.get("category"))
736
+ if not category:
737
+ return "Category not found."
738
+ new_name = str(action.get("new_name", "")).strip()[:100]
739
+ if not new_name:
740
+ return "New category name is required."
741
+ reason = action.get("reason", "AI Admin rename category")
742
+ old_name = category.name
743
+ await category.edit(name=new_name, reason=reason)
744
+ return f"Renamed category **{old_name}** -> **{new_name}**."
745
+
746
+ async def _delete_category(self, action: dict[str, Any], ctx: commands.Context) -> str:
747
+ if not ctx.author.guild_permissions.manage_channels:
748
+ return "Manage Channels permission required."
749
+ category = self._resolve_category(ctx.guild, action.get("category"))
750
+ if not category:
751
+ return "Category not found."
752
+ reason = action.get("reason", "AI Admin delete category")
753
+ name = category.name
754
+ channels_inside = len(category.channels)
755
+ await category.delete(reason=reason)
756
+ return f"Deleted category **{name}** (had {channels_inside} channel(s))."
757
+
758
+ async def _setup_permissions(self, action: dict[str, Any], ctx: commands.Context) -> str:
759
+ """Set up complex channel permissions for specific roles."""
760
+ if not ctx.author.guild_permissions.manage_channels:
761
+ return "Manage Channels permission required."
762
+ channel = self._resolve_channel(ctx.guild, action.get("channel"), ctx.channel)
763
+ if not channel:
764
+ return "Channel not found."
765
+
766
+ deny_roles = action.get("deny_roles", ["@everyone"])
767
+ allow_roles = action.get("allow_roles", [])
768
+ deny_perms = action.get("deny_permissions", ["send_messages"])
769
+ allow_perms = action.get("allow_permissions", ["send_messages"])
770
+ reason = action.get("reason", "AI Admin permission setup")
771
+
772
+ overwrites = {}
773
+ # Process denied roles
774
+ for role_name in deny_roles:
775
+ if role_name.lower() in ("@everyone", "everyone"):
776
+ role_obj = ctx.guild.default_role
777
+ else:
778
+ role_obj = discord.utils.get(ctx.guild.roles, name=role_name)
779
+ if role_obj:
780
+ deny_obj = discord.PermissionOverwrite()
781
+ for perm in deny_perms:
782
+ setattr(deny_obj, perm, False)
783
+ overwrites[role_obj] = deny_obj
784
+
785
+ # Process allowed roles
786
+ for role_name in allow_roles:
787
+ role_obj = discord.utils.get(ctx.guild.roles, name=role_name)
788
+ if role_obj:
789
+ allow_obj = discord.PermissionOverwrite()
790
+ for perm in allow_perms:
791
+ setattr(allow_obj, perm, True)
792
+ overwrites[role_obj] = allow_obj
793
+
794
+ await channel.edit(overwrites=overwrites, reason=reason)
795
+ denied = ", ".join(deny_roles)
796
+ allowed = ", ".join(allow_roles) if allow_roles else "none"
797
+ return f"Permissions set for **{channel.mention}**: denied [{denied}], allowed [{allowed}]."
798
+
799
+ async def _schedule_task(self, action: dict[str, Any], ctx: commands.Context) -> str:
800
+ """Schedule a task for future execution."""
801
+ run_at = action.get("run_at", "")
802
+ action_to_run = action.get("action_to_run", {})
803
+ reason = action.get("reason", "Scheduled task")
804
+
805
+ # Parse the datetime
806
+ try:
807
+ scheduled_time = dt.datetime.fromisoformat(run_at)
808
+ if scheduled_time.tzinfo is None:
809
+ scheduled_time = scheduled_time.replace(tzinfo=dt.timezone.utc)
810
+ except (ValueError, TypeError):
811
+ return f"Invalid schedule time: {run_at}. Use ISO format: YYYY-MM-DD HH:MM"
812
+
813
+ if scheduled_time <= dt.datetime.now(dt.timezone.utc):
814
+ return "Scheduled time must be in the future."
815
+
816
+ # Store in database
817
+ await self.bot.db.execute(
818
+ "INSERT INTO ai_scheduled_tasks(guild_id, run_at, action_json, reason, created_by) VALUES (?, ?, ?, ?, ?)",
819
+ ctx.guild.id,
820
+ scheduled_time.isoformat(),
821
+ json.dumps(action_to_run),
822
+ reason[:200],
823
+ ctx.author.id,
824
+ )
825
+ time_str = scheduled_time.strftime("%Y-%m-%d %H:%M UTC")
826
+ return f"⏰ Task scheduled for **{time_str}**: {reason[:100]}"
827
+
828
+ async def _analyze_activity(self, action: dict[str, Any], ctx: commands.Context) -> str:
829
+ """Analyze member activity and return a summary."""
830
+ scope = action.get("scope", "all")
831
+ period = action.get("period", "24h")
832
+
833
+ # Parse period
834
+ hours = {"24h": 24, "7d": 168, "30d": 720}.get(period, 24)
835
+
836
+ # Get recent messages in scope
837
+ if scope == "all":
838
+ channels = ctx.guild.text_channels
839
+ else:
840
+ ch = discord.utils.get(ctx.guild.text_channels, name=scope.lower().replace(" ", "-"))
841
+ cat = discord.utils.get(ctx.guild.categories, name=scope)
842
+ if ch:
843
+ channels = [ch]
844
+ elif cat:
845
+ channels = cat.text_channels
846
+ else:
847
+ channels = ctx.guild.text_channels
848
+
849
+ member_counts: dict[int, int] = {}
850
+ total = 0
851
+ for ch in channels[:10]: # Limit to first 10 channels to avoid timeout
852
+ try:
853
+ async for msg in ch.history(limit=200):
854
+ if msg.author.bot:
855
+ continue
856
+ age = dt.datetime.now(dt.timezone.utc) - msg.created_at
857
+ if age.total_seconds() > hours * 3600:
858
+ continue
859
+ member_counts[msg.author.id] = member_counts.get(msg.author.id, 0) + 1
860
+ total += 1
861
+ except (discord.Forbidden, discord.HTTPException):
862
+ continue
863
+
864
+ if not member_counts:
865
+ return f"📊 **Activity Report ({period})**: No recent activity found."
866
+
867
+ # Top 5 active
868
+ sorted_members = sorted(member_counts.items(), key=lambda x: -x[1])[:5]
869
+ lines = []
870
+ for rank, (user_id, count) in enumerate(sorted_members, 1):
871
+ member = ctx.guild.get_member(user_id)
872
+ name = member.mention if member else f"<@{user_id}>"
873
+ medal = {1: "🥇", 2: "🥈", 3: "🥉"}.get(rank, f"{rank}.")
874
+ lines.append(f"{medal} {name}: `{count}` messages")
875
+
876
+ unique_users = len(member_counts)
877
+ return (
878
+ f"📊 **Activity Report ({period})**\n"
879
+ f"Total messages: `{total}` | Active users: `{unique_users}`\n\n"
880
+ f"**Top 5:**\n" + "\n".join(lines)
881
+ )
882
 
883
+ class AIAdmin(commands.Cog):
884
  """Autonomous AI Administrator Cog."""
885
 
886
+ def __init__(self, bot: commands.Bot) -> None:
887
+ self.bot = bot
888
+ self.permission_guard = PermissionGuard()
889
+ self.intelligence = IntelligenceLayer(bot)
890
+ self.execution = ExecutionEngine(bot)
891
+
892
+ @staticmethod
893
+ def _parse_duration_minutes(text: str) -> int | None:
894
+ match = re.search(r"(\d+)\s*(m|min|mins|minute|minutes|h|hr|hour|hours|d|day|days)?", text, re.IGNORECASE)
895
+ if not match:
896
+ return None
897
+ value = int(match.group(1))
898
+ unit = (match.group(2) or "m").lower()
899
+ if unit.startswith("h"):
900
+ value *= 60
901
+ elif unit.startswith("d"):
902
+ value *= 60 * 24
903
+ return max(1, min(value, 40320))
904
 
905
  async def _try_direct_moderation(self, ctx: commands.Context, request: str) -> str | None:
906
  if not ctx.guild:
 
916
  if match:
917
  target = ctx.guild.get_member(int(match.group(1)))
918
 
919
+ if lower.startswith("kick ") and target:
920
  if not ctx.author.guild_permissions.kick_members:
921
  return "You need Kick Members permission."
922
  await target.kick(reason=f"AI Admin by {ctx.author}")
923
  return f"OK: Kicked {target.mention}"
924
 
925
+ if lower.startswith("ban ") and target:
926
  if not ctx.author.guild_permissions.ban_members:
927
  return "You need Ban Members permission."
928
  await target.ban(reason=f"AI Admin by {ctx.author}", delete_message_days=0)
929
  return f"OK: Banned {target.mention}"
930
 
931
+ if (lower.startswith("mute ") or lower.startswith("timeout ")) and target:
932
+ if not ctx.author.guild_permissions.moderate_members:
933
+ return "You need Moderate Members permission."
934
+ minutes = self._parse_duration_minutes(text) or 10
935
+ until = discord.utils.utcnow() + dt.timedelta(minutes=minutes)
936
+ await target.timeout(until, reason=f"AI Admin by {ctx.author}")
937
+ return f"OK: Timed out {target.mention} for {minutes} minute(s)"
938
+
939
+ if (lower.startswith("unmute ") or lower.startswith("untimeout ")) and target:
940
+ if not ctx.author.guild_permissions.moderate_members:
941
+ return "You need Moderate Members permission."
942
+ await target.timeout(None, reason=f"AI Admin by {ctx.author}")
943
+ return f"OK: Removed timeout from {target.mention}"
944
+
945
+ role_match = re.search(r"(?:give|add)\s+role\s+(.+?)\s+(?:to|for)\s+<@!?(\d+)>", text, re.IGNORECASE)
946
+ if role_match:
947
  role_name = role_match.group(1).strip(" \"'")
948
  member = ctx.guild.get_member(int(role_match.group(2)))
949
  if not member:
 
953
  return f"Role not found: {role_name}"
954
  if not ctx.author.guild_permissions.manage_roles:
955
  return "You need Manage Roles permission."
956
+ await member.add_roles(role, reason=f"AI Admin by {ctx.author}")
957
+ return f"OK: Added role **{role.name}** to {member.mention}"
958
+
959
+ remove_role_match = re.search(r"(?:remove)\s+role\s+(.+?)\s+(?:from)\s+<@!?(\d+)>", text, re.IGNORECASE)
960
+ if remove_role_match:
961
+ role_name = remove_role_match.group(1).strip(" \"'")
962
+ member = ctx.guild.get_member(int(remove_role_match.group(2)))
963
+ if not member:
964
+ return "Target member not found."
965
+ role = discord.utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles)
966
+ if not role:
967
+ return f"Role not found: {role_name}"
968
+ if not ctx.author.guild_permissions.manage_roles:
969
+ return "You need Manage Roles permission."
970
+ await member.remove_roles(role, reason=f"AI Admin by {ctx.author}")
971
+ return f"OK: Removed role **{role.name}** from {member.mention}"
972
+
973
+ lock_match = re.search(r"^lock(?:\s+<#(\d+)>)?", lower)
974
+ if lock_match:
975
+ if not ctx.author.guild_permissions.manage_channels:
976
+ return "You need Manage Channels permission."
977
+ channel = ctx.guild.get_channel(int(lock_match.group(1))) if lock_match.group(1) else ctx.channel
978
+ if isinstance(channel, discord.TextChannel):
979
+ await channel.set_permissions(ctx.guild.default_role, send_messages=False, reason=f"AI Admin by {ctx.author}")
980
+ return f"OK: Locked {channel.mention}"
981
+
982
+ unlock_match = re.search(r"^unlock(?:\s+<#(\d+)>)?", lower)
983
+ if unlock_match:
984
+ if not ctx.author.guild_permissions.manage_channels:
985
+ return "You need Manage Channels permission."
986
+ channel = ctx.guild.get_channel(int(unlock_match.group(1))) if unlock_match.group(1) else ctx.channel
987
+ if isinstance(channel, discord.TextChannel):
988
+ await channel.set_permissions(ctx.guild.default_role, send_messages=True, reason=f"AI Admin by {ctx.author}")
989
+ return f"OK: Unlocked {channel.mention}"
990
+
991
+ rename_channel_match = re.search(
992
+ r"(?:rename)\s+channel\s+(.+?)\s+(?:to|->)\s+(.+)$",
993
+ text,
994
+ re.IGNORECASE,
995
+ )
996
+ if rename_channel_match:
997
+ if not ctx.author.guild_permissions.manage_channels:
998
+ return "You need Manage Channels permission."
999
+ old_ref = rename_channel_match.group(1).strip(" \"'")
1000
+ new_name = rename_channel_match.group(2).strip(" \"'")
1001
+ channel = ExecutionEngine._resolve_guild_channel(ctx.guild, old_ref)
1002
+ if not channel:
1003
+ return f"Channel not found: {old_ref}"
1004
+ if not new_name:
1005
+ return "New channel name is required."
1006
+ old_name = channel.name
1007
+ await channel.edit(name=new_name[:100], reason=f"AI Admin by {ctx.author}")
1008
+ return f"OK: Renamed channel **{old_name}** -> **{new_name[:100]}**"
1009
+
1010
+ delete_channel_match = re.search(r"(?:delete|remove)\s+channel\s+(.+)$", text, re.IGNORECASE)
1011
+ if delete_channel_match:
1012
+ if not ctx.author.guild_permissions.manage_channels:
1013
+ return "You need Manage Channels permission."
1014
+ ref = delete_channel_match.group(1).strip(" \"'")
1015
+ channel = ExecutionEngine._resolve_guild_channel(ctx.guild, ref)
1016
+ if not channel:
1017
+ return f"Channel not found: {ref}"
1018
+ if ctx.channel and channel.id == ctx.channel.id:
1019
+ return "Refusing to delete the channel currently being used for command execution."
1020
+ name = channel.name
1021
+ await channel.delete(reason=f"AI Admin by {ctx.author}")
1022
+ return f"OK: Deleted channel **{name}**"
1023
+
1024
+ rename_category_match = re.search(
1025
+ r"(?:rename)\s+(?:category|directory)\s+(.+?)\s+(?:to|->)\s+(.+)$",
1026
+ text,
1027
+ re.IGNORECASE,
1028
+ )
1029
+ if rename_category_match:
1030
+ if not ctx.author.guild_permissions.manage_channels:
1031
+ return "You need Manage Channels permission."
1032
+ old_ref = rename_category_match.group(1).strip(" \"'")
1033
+ new_name = rename_category_match.group(2).strip(" \"'")
1034
+ category = ExecutionEngine._resolve_category(ctx.guild, old_ref)
1035
+ if not category:
1036
+ return f"Category not found: {old_ref}"
1037
+ if not new_name:
1038
+ return "New category name is required."
1039
+ old_name = category.name
1040
+ await category.edit(name=new_name[:100], reason=f"AI Admin by {ctx.author}")
1041
+ return f"OK: Renamed category **{old_name}** -> **{new_name[:100]}**"
1042
+
1043
+ delete_category_match = re.search(r"(?:delete|remove)\s+(?:category|directory)\s+(.+)$", text, re.IGNORECASE)
1044
+ if delete_category_match:
1045
+ if not ctx.author.guild_permissions.manage_channels:
1046
+ return "You need Manage Channels permission."
1047
+ ref = delete_category_match.group(1).strip(" \"'")
1048
+ category = ExecutionEngine._resolve_category(ctx.guild, ref)
1049
+ if not category:
1050
+ return f"Category not found: {ref}"
1051
+ name = category.name
1052
+ channels_inside = len(category.channels)
1053
+ await category.delete(reason=f"AI Admin by {ctx.author}")
1054
+ return f"OK: Deleted category **{name}** (had {channels_inside} channel(s))"
1055
+
1056
+ return None
1057
+
1058
+ @commands.Cog.listener()
1059
+ async def on_ready(self) -> None:
1060
+ """Execute any pending scheduled tasks on startup."""
1061
+ await self._check_scheduled_tasks()
1062
+
1063
+ async def _check_scheduled_tasks(self) -> None:
1064
+ """Check and execute any due scheduled tasks."""
1065
+ now = dt.datetime.now(dt.timezone.utc).isoformat()
1066
+ tasks = await self.bot.db.fetchall(
1067
+ "SELECT id, guild_id, action_json, reason FROM ai_scheduled_tasks WHERE executed = 0 AND run_at <= ?",
1068
+ now,
1069
+ )
1070
+ for task_id, guild_id, action_json, reason in tasks:
1071
+ try:
1072
+ action = json.loads(action_json)
1073
+ guild = self.bot.get_guild(guild_id)
1074
+ if not guild:
1075
+ await self.bot.db.execute("UPDATE ai_scheduled_tasks SET executed = 1 WHERE id = ?", task_id)
1076
+ continue
1077
+ # Create a fake context for execution
1078
+ class _FakeCtx:
1079
+ pass
1080
+ fake_ctx = _FakeCtx()
1081
+ fake_ctx.guild = guild
1082
+ fake_ctx.author = guild.me
1083
+ fake_ctx.channel = guild.system_channel
1084
+ fake_ctx.send = lambda *a, **k: None # type: ignore
1085
+ await self.execution.execute([action], fake_ctx) # type: ignore
1086
+ await self.bot.db.execute("UPDATE ai_scheduled_tasks SET executed = 1 WHERE id = ?", task_id)
1087
+ except Exception:
1088
+ pass
1089
+
1090
+ @tasks.loop(minutes=5)
1091
+ async def _scheduled_task_checker(self) -> None:
1092
+ """Periodically check for due tasks."""
1093
+ await self._check_scheduled_tasks()
1094
+
1095
  async def cog_unload(self) -> None:
1096
+ self._scheduled_task_checker.cancel()
1097
  await self.intelligence.close()
1098
 
1099
+ @commands.hybrid_command(name="ai_admin", description=get_cmd_desc("commands.ai.ai_admin_desc"))
1100
+ async def ai_admin(self, ctx: commands.Context, *, request: str) -> None:
1101
+ allowed, deny_reason = self.permission_guard.check(ctx)
1102
+ if not allowed:
1103
+ await ctx.send(deny_reason, ephemeral=True)
1104
+ return
1105
 
1106
  direct_result = await self._try_direct_moderation(ctx, request)
1107
  if direct_result is not None:
1108
  await ctx.send(direct_result, ephemeral=True)
1109
  return
1110
 
1111
+ if ctx.interaction and not ctx.interaction.response.is_done():
1112
+ try:
1113
+ await ctx.defer()
1114
+ except discord.InteractionResponded:
1115
+ pass
1116
 
1117
  actions = await self.intelligence.ask_ai(request)
1118
  if not actions:
 
1127
 
1128
  results = await self.execution.execute(actions, ctx)
1129
 
1130
+ try:
1131
+ if response_text:
1132
+ await ctx.send(response_text)
1133
+ else:
1134
+ await ctx.send("\n".join(results))
1135
+ except discord.NotFound:
1136
+ # Interaction message may have been deleted
1137
+ try:
1138
+ if ctx.channel:
1139
+ await ctx.channel.send(response_text or "\n".join(results))
1140
+ except Exception:
1141
+ pass
1142
 
1143
+ @commands.hybrid_command(name="ai_help", description=get_cmd_desc("commands.ai.ai_help_desc"))
1144
  async def ai_help(self, ctx: commands.Context) -> None:
1145
  embed = discord.Embed(
1146
+ title="🛡️ AI Admin — Server Orchestrator",
1147
  description=(
1148
+ "I'm your expert server manager. Ask me in Arabic or English and I'll handle it.\n\n"
1149
+ "**🏗️ Server Setup:**\n"
1150
+ "`/ai_admin جهز قسم البطولات` Full section with category, channels, roles\n"
1151
+ "`/ai_admin create a staff room with private access` — Locked channel with permissions\n\n"
1152
+ "**📢 Announcements:**\n"
1153
+ "`/ai_admin announce the tournament starts at 9 PM` — Rich embed announcement\n\n"
1154
+ "**👥 Member Management:**\n"
1155
+ "`/ai_admin give VIP role to @user` — Role assignment\n"
1156
+ "`/ai_admin mute @user for 30m` Timeout with reason\n\n"
1157
+ "** Scheduling:**\n"
1158
+ "`/ai_admin open events channel at 9 PM tonight` — Scheduled task\n"
1159
+ "`/ai_admin remove temp role after 1 week` — Future action\n\n"
1160
+ "**📊 Activity Analysis:**\n"
1161
+ "`/ai_admin who's most active in gaming?` — Member report\n\n"
1162
+ "**🎨 Direct Moderation Shortcuts:**\n"
1163
+ "`/ai_admin kick @user` | `ban @user` | `mute @user 30m`\n"
1164
+ "`/ai_admin lock #channel` | `unlock #channel`\n"
1165
+ "`/ai_admin purge 50 from #general`"
1166
+ ),
1167
+ color=discord.Color.blue()
1168
+ )
1169
+ embed.set_footer(text="AI Admin v2.0 — Server Orchestrator")
1170
+ try:
1171
+ await ctx.send(embed=embed, ephemeral=True)
1172
+ except discord.NotFound:
1173
+ if ctx.channel:
1174
+ await ctx.channel.send(embed=embed)
 
 
 
1175
 
1176
 
1177
  async def setup(bot: commands.Bot) -> None:
bot/cogs/ai_suite.py CHANGED
The diff for this file is too large to render. See raw diff
 
bot/cogs/board_games.py CHANGED
@@ -1,824 +1,825 @@
1
- from __future__ import annotations
2
-
3
- import random
4
- from dataclasses import dataclass
5
-
6
- import discord
7
- from discord.ext import commands
8
- from bot.emojis import ui
9
-
10
- FILES = "abcdefgh"
11
-
12
-
13
- @dataclass
14
- class BoardGameSession:
15
- game: str
16
- players: tuple[int, int] # second player can be 0 for bot
17
- turn: int
18
- board: list[list[str]]
19
- tournament_name: str | None = None
20
-
21
-
22
-
23
-
24
- class BoardMoveModal(discord.ui.Modal, title="Play Move"):
25
- move = discord.ui.TextInput(label="Move", placeholder="e2e4 / d3 / 4", max_length=10)
26
-
27
- def __init__(self, cog: "BoardGames") -> None:
28
- super().__init__(timeout=None)
29
- self.cog = cog
30
-
31
- async def on_submit(self, interaction: discord.Interaction) -> None:
32
- if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
33
- await interaction.response.send_message("Server text channels only.", ephemeral=True)
34
- return
35
- err = await self.cog._play_turn(
36
- guild=interaction.guild,
37
- channel=interaction.channel,
38
- author_id=interaction.user.id,
39
- move=str(self.move.value).strip(),
40
- )
41
- if err:
42
- await interaction.response.send_message(err, ephemeral=True)
43
- else:
44
- await interaction.response.send_message("✅ Move accepted.", ephemeral=True)
45
-
46
-
47
- class BoardActionView(discord.ui.View):
48
- def __init__(self, cog: "BoardGames") -> None:
49
- super().__init__(timeout=None)
50
- self.cog = cog
51
-
52
- @discord.ui.button(label="Play Move", style=discord.ButtonStyle.primary, emoji=ui("controller"), custom_id="board_play")
53
- async def play_move(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
54
- await interaction.response.send_modal(BoardMoveModal(self.cog))
55
-
56
- @discord.ui.button(label="Forfeit", style=discord.ButtonStyle.danger, emoji=ui("no"), custom_id="board_forfeit")
57
- async def forfeit(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
58
- if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
59
- await interaction.response.send_message("Server text channels only.", ephemeral=True)
60
- return
61
- err = await self.cog._forfeit(
62
- guild=interaction.guild,
63
- channel=interaction.channel,
64
- author_id=interaction.user.id,
65
- )
66
- if err:
67
- await interaction.response.send_message(err, ephemeral=True)
68
- return
69
- await interaction.response.send_message("🏳️ Forfeit processed.", ephemeral=True)
70
-
71
- @discord.ui.button(label="End Game", style=discord.ButtonStyle.danger, emoji=ui("lock"), custom_id="board_end")
72
- async def end_game(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
73
- if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
74
- await interaction.response.send_message("Server text channels only.", ephemeral=True)
75
- return
76
- session = self.cog.sessions.get(interaction.channel.id)
77
- if not session:
78
- await interaction.response.send_message("No active board game in this channel.", ephemeral=True)
79
- return
80
- if interaction.user.id not in session.players and not interaction.user.guild_permissions.manage_channels:
81
- await interaction.response.send_message("Only players/admins can end this game.", ephemeral=True)
82
- return
83
- self.cog.sessions.pop(interaction.channel.id, None)
84
- await interaction.response.send_message("🔒 Game session ended.", ephemeral=True)
85
- if interaction.channel.name.startswith("game-"):
86
- try:
87
- await interaction.channel.delete(reason=f"Game room closed by {interaction.user}")
88
- except Exception:
89
- pass
90
-
91
-
92
- class Connect4ActionView(BoardActionView):
93
- def __init__(self, cog: "BoardGames") -> None:
94
- super().__init__(cog)
95
- self.clear_items()
96
- for col in range(1, 8):
97
- button = discord.ui.Button(label=str(col), style=discord.ButtonStyle.secondary, row=0 if col <= 4 else 1)
98
-
99
- async def _drop(interaction: discord.Interaction, c: int = col) -> None:
100
- if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
101
- await interaction.response.send_message("Server text channels only.", ephemeral=True)
102
- return
103
- err = await self.cog._play_turn(
104
- guild=interaction.guild,
105
- channel=interaction.channel,
106
- author_id=interaction.user.id,
107
- move=str(c),
108
- )
109
- if err:
110
- await interaction.response.send_message(err, ephemeral=True)
111
- else:
112
- await interaction.response.send_message(f"✅ Dropped in column {c}", ephemeral=True)
113
-
114
- button.callback = _drop
115
- self.add_item(button)
116
-
117
- forfeit_btn = discord.ui.Button(label="Forfeit", style=discord.ButtonStyle.danger, emoji=ui("no"), row=2, custom_id="board_forfeit_c4")
118
- end_btn = discord.ui.Button(label="End Game", style=discord.ButtonStyle.danger, emoji=ui("lock"), row=2, custom_id="board_end_c4")
119
-
120
- async def _forfeit(interaction: discord.Interaction) -> None:
121
- if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
122
- await interaction.response.send_message("Server text channels only.", ephemeral=True)
123
- return
124
- err = await self.cog._forfeit(guild=interaction.guild, channel=interaction.channel, author_id=interaction.user.id)
125
- if err:
126
- await interaction.response.send_message(err, ephemeral=True)
127
- return
128
- await interaction.response.send_message("🏳️ Forfeit processed.", ephemeral=True)
129
-
130
- forfeit_btn.callback = _forfeit
131
- self.add_item(forfeit_btn)
132
-
133
- async def _end(interaction: discord.Interaction) -> None:
134
- if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
135
- await interaction.response.send_message("Server text channels only.", ephemeral=True)
136
- return
137
- session = self.cog.sessions.get(interaction.channel.id)
138
- if not session:
139
- await interaction.response.send_message("No active board game in this channel.", ephemeral=True)
140
- return
141
- if interaction.user.id not in session.players and not interaction.user.guild_permissions.manage_channels:
142
- await interaction.response.send_message("Only players/admins can end this game.", ephemeral=True)
143
- return
144
- self.cog.sessions.pop(interaction.channel.id, None)
145
- await interaction.response.send_message("🔒 Game session ended.", ephemeral=True)
146
- if interaction.channel.name.startswith("game-"):
147
- try:
148
- await interaction.channel.delete(reason=f"Game room closed by {interaction.user}")
149
- except Exception:
150
- pass
151
-
152
- end_btn.callback = _end
153
- self.add_item(end_btn)
154
-
155
-
156
- import random as _random
157
-
158
-
159
- class QuickRPSView(discord.ui.View):
160
- def __init__(self) -> None:
161
- super().__init__(timeout=60)
162
-
163
- @discord.ui.button(label="Rock 🪨", style=discord.ButtonStyle.primary)
164
- async def rock(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
165
- await self._resolve(interaction, "rock")
166
-
167
- @discord.ui.button(label="Paper 📄", style=discord.ButtonStyle.primary)
168
- async def paper(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
169
- await self._resolve(interaction, "paper")
170
-
171
- @discord.ui.button(label="Scissors ✂️", style=discord.ButtonStyle.primary)
172
- async def scissors(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
173
- await self._resolve(interaction, "scissors")
174
-
175
- async def _resolve(self, interaction: discord.Interaction, choice: str) -> None:
176
- bot_choice = _random.choice(["rock", "paper", "scissors"])
177
- emoji_map = {"rock": "🪨", "paper": "📄", "scissors": "✂️"}
178
- result_map = {("rock", "scissors"): "win", ("paper", "rock"): "win", ("scissors", "paper"): "win"}
179
- if choice == bot_choice:
180
- result = "🤝 Tie!"
181
- elif (choice, bot_choice) in result_map:
182
- result = "🎉 You win!"
183
- else:
184
- result = "💀 You lose!"
185
- await interaction.response.send_message(f"You: {emoji_map[choice]} | Bot: {emoji_map[bot_choice]}\n{result}", ephemeral=True)
186
- self.stop()
187
-
188
-
189
- class GamePanelView(discord.ui.View):
190
- def __init__(self, cog: "BoardGames") -> None:
191
- super().__init__(timeout=None)
192
- self.cog = cog
193
-
194
- async def _start(self, interaction: discord.Interaction, game: str) -> None:
195
- if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
196
- await interaction.response.send_message("Server text channels only.", ephemeral=True)
197
- return
198
- ok = await self.cog.start_game_session_interaction(interaction, game)
199
- if ok:
200
- await interaction.response.send_message(f"✅ Started **{game}** vs bot.", ephemeral=True)
201
-
202
- @discord.ui.button(label="Chess", style=discord.ButtonStyle.primary, emoji=ui("chess"), row=0, custom_id="board_chess")
203
- async def chess(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
204
- await self._start(interaction, "chess")
205
-
206
- @discord.ui.button(label="Checkers", style=discord.ButtonStyle.secondary, emoji=ui("joystick"), row=0, custom_id="board_checkers")
207
- async def checkers(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
208
- await self._start(interaction, "checkers")
209
-
210
- @discord.ui.button(label="Connect4", style=discord.ButtonStyle.success, emoji=ui("joystick"), row=0, custom_id="board_connect4")
211
- async def connect4(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
212
- await self._start(interaction, "connect4")
213
-
214
- @discord.ui.button(label="Othello", style=discord.ButtonStyle.blurple, emoji=ui("joystick"), row=0, custom_id="board_othello")
215
- async def othello(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
216
- await self._start(interaction, "othello")
217
-
218
- @discord.ui.button(label="TicTacToe", style=discord.ButtonStyle.primary, emoji=ui("x"), row=1, custom_id="board_tictactoe")
219
- async def tictactoe(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
220
- if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
221
- await interaction.response.send_message("Server text channels only.", ephemeral=True)
222
- return
223
- await interaction.response.send_message(f"❎ **TicTacToe**: {interaction.user.mention} vs 🤖 Bot\nUse `/xo` to play!", ephemeral=True)
224
-
225
- @discord.ui.button(label="Rock Paper Scissors", style=discord.ButtonStyle.secondary, emoji="✂️", row=1, custom_id="board_rps")
226
- async def rps(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
227
- if not interaction.guild:
228
- await interaction.response.send_message("Server only.", ephemeral=True)
229
- return
230
- await interaction.response.send_message(
231
- "Choose your move:",
232
- view=QuickRPSView(),
233
- ephemeral=True,
234
- )
235
-
236
-
237
- class BoardGames(commands.Cog):
238
- def __init__(self, bot: commands.Bot) -> None:
239
- self.bot = bot
240
- self.sessions: dict[int, BoardGameSession] = {}
241
-
242
- async def cog_load(self) -> None:
243
- self.bot.add_view(GamePanelView(self))
244
- self.bot.add_view(BoardActionView(self))
245
-
246
- def _coord(self, token: str) -> tuple[int, int] | None:
247
- token = token.strip().lower()
248
- if len(token) != 2 or token[0] not in FILES or token[1] not in "12345678":
249
- return None
250
- x = FILES.index(token[0])
251
- y = 8 - int(token[1])
252
- return y, x
253
-
254
- def _coord_to_text(self, y: int, x: int) -> str:
255
- return f"{FILES[x]}{8-y}"
256
-
257
- def _current_player(self, s: BoardGameSession) -> int:
258
- return s.players[s.turn]
259
-
260
- def _render(self, s: BoardGameSession) -> str:
261
- if s.game == "connect4":
262
- lines = ["1️⃣2️⃣3️⃣4️⃣5️⃣6️⃣7️⃣"]
263
- for row in s.board:
264
- lines.append("".join(row))
265
- return "\n".join(lines)
266
- if s.game == "othello":
267
- lines = ["⬛🇦🇧🇨🇩🇪🇫🇬🇭"]
268
- for y, row in enumerate(s.board):
269
- lines.append(f"{8-y}️⃣" + "".join(row))
270
- return "\n".join(lines)
271
- lines = [" 🇦 🇧 🇨 🇩 🇪 🇫 🇬 🇭"]
272
- for y, row in enumerate(s.board):
273
- lines.append(f"{8-y} " + " ".join(row))
274
- return "\n".join(lines)
275
-
276
- def _make_chess(self) -> list[list[str]]:
277
- return [
278
- list("♜♞♝♛♚♝♞♜"),
279
- list("♟♟♟♟♟♟♟♟"),
280
- list("▫▫▫▫▫▫▫▫"),
281
- list("▫▫▫▫▫▫▫▫"),
282
- list("▫▫▫▫▫▫▫▫"),
283
- list("▫▫▫▫▫▫▫▫"),
284
- list("♙♙♙♙♙♙♙♙"),
285
- list("♖♘♗♕♔♗♘♖"),
286
- ]
287
-
288
- def _make_checkers(self) -> list[list[str]]:
289
- board = [["▫" for _ in range(8)] for _ in range(8)]
290
- for y in range(3):
291
- for x in range(8):
292
- if (x + y) % 2 == 1:
293
- board[y][x] = "🔴"
294
- for y in range(5, 8):
295
- for x in range(8):
296
- if (x + y) % 2 == 1:
297
- board[y][x] = "⚪"
298
- return board
299
-
300
- def _make_connect4(self) -> list[list[str]]:
301
- return [["⚫" for _ in range(7)] for _ in range(6)]
302
-
303
- def _make_othello(self) -> list[list[str]]:
304
- b = [["🟩" for _ in range(8)] for _ in range(8)]
305
- b[3][3] = ""
306
- b[3][4] = ""
307
- b[4][3] = "⚫"
308
- b[4][4] = ""
309
- return b
310
-
311
- async def _announce(self, ctx: commands.Context, s: BoardGameSession, text: str) -> None:
312
- p1 = f"<@{s.players[0]}>"
313
- p2 = "🤖 Bot" if s.players[1] == 0 else f"<@{s.players[1]}>"
314
- turn = self._current_player(s)
315
- tname = "🤖 Bot" if turn == 0 else f"<@{turn}>"
316
- await ctx.send(
317
- f"**{s.game.title()}** | {p1} vs {p2}\n{text}\nTurn: {tname}\n{self._render(s)}"
318
- )
319
-
320
- async def start_game_session(
321
- self,
322
- ctx: commands.Context,
323
- game: str,
324
- opponent: discord.Member | None = None,
325
- tournament_name: str | None = None,
326
- ) -> bool:
327
- game = game.lower().strip()
328
- if game not in {"chess", "checkers", "connect4", "othello"}:
329
- await ctx.reply("Supported: chess, checkers, connect4, othello")
330
- return False
331
- if ctx.channel.id in self.sessions:
332
- await ctx.reply("There is already an active board game in this channel.")
333
- return False
334
-
335
- opponent_id = opponent.id if opponent else 0
336
- if opponent_id == ctx.author.id:
337
- await ctx.reply("Choose another player or leave opponent empty for bot.")
338
- return False
339
-
340
- if game == "chess":
341
- board = self._make_chess()
342
- elif game == "checkers":
343
- board = self._make_checkers()
344
- elif game == "connect4":
345
- board = self._make_connect4()
346
- else:
347
- board = self._make_othello()
348
-
349
- s = BoardGameSession(game=game, players=(ctx.author.id, opponent_id), turn=0, board=board, tournament_name=tournament_name)
350
- self.sessions[ctx.channel.id] = s
351
- await self._announce(ctx.channel, s, "Game started. Use buttons or `/board_move`.")
352
- return True
353
-
354
- async def start_game_session_interaction(self, interaction: discord.Interaction, game: str) -> bool:
355
- if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
356
- return False
357
- game = game.lower().strip()
358
- if game not in {"chess", "checkers", "connect4", "othello"}:
359
- await interaction.followup.send("Supported: chess, checkers, connect4, othello", ephemeral=True)
360
- return False
361
- if interaction.channel.id in self.sessions:
362
- await interaction.followup.send("There is already an active board game in this channel.", ephemeral=True)
363
- return False
364
-
365
- if game == "chess":
366
- board = self._make_chess()
367
- elif game == "checkers":
368
- board = self._make_checkers()
369
- elif game == "connect4":
370
- board = self._make_connect4()
371
- else:
372
- board = self._make_othello()
373
-
374
- s = BoardGameSession(game=game, players=(interaction.user.id, 0), turn=0, board=board)
375
- self.sessions[interaction.channel.id] = s
376
- await self._announce(interaction.channel, s, "Game started from panel.")
377
- return True
378
-
379
- async def start_tournament_duel(
380
- self,
381
- ctx: commands.Context,
382
- game: str,
383
- player1: discord.Member,
384
- player2: discord.Member | None,
385
- tournament_name: str,
386
- ) -> bool:
387
- if ctx.channel.id in self.sessions:
388
- await ctx.reply("There is already an active board game in this channel.")
389
- return False
390
- game = game.lower().strip()
391
- if game == "chess":
392
- board = self._make_chess()
393
- elif game == "checkers":
394
- board = self._make_checkers()
395
- elif game == "connect4":
396
- board = self._make_connect4()
397
- elif game == "othello":
398
- board = self._make_othello()
399
- else:
400
- await ctx.reply("Supported: chess, checkers, connect4, othello")
401
- return False
402
-
403
- s = BoardGameSession(
404
- game=game,
405
- players=(player1.id, player2.id if player2 else 0),
406
- turn=0,
407
- board=board,
408
- tournament_name=tournament_name,
409
- )
410
- self.sessions[ctx.channel.id] = s
411
- await self._announce(ctx.channel, s, f"Tournament duel: **{tournament_name}**")
412
- return True
413
-
414
-
415
- def _in_bounds(self, y: int, x: int) -> bool:
416
- return 0 <= y < 8 and 0 <= x < 8
417
-
418
- def _is_white(self, p: str) -> bool:
419
- return p in "♙♖♘♗♕♔"
420
-
421
- def _is_black(self, p: str) -> bool:
422
- return p in "♟♜♞♝♛♚"
423
-
424
- def _enemy(self, p: str, white_turn: bool) -> bool:
425
- return self._is_black(p) if white_turn else self._is_white(p)
426
-
427
- def _friend(self, p: str, white_turn: bool) -> bool:
428
- return self._is_white(p) if white_turn else self._is_black(p)
429
-
430
- def _chess_legal(self, b: list[list[str]], src: tuple[int, int], dst: tuple[int, int], white_turn: bool) -> bool:
431
- sy, sx = src
432
- dy, dx = dst
433
- if not (self._in_bounds(sy, sx) and self._in_bounds(dy, dx)):
434
- return False
435
- piece = b[sy][sx]
436
- if piece == "▫" or not self._friend(piece, white_turn):
437
- return False
438
- target = b[dy][dx]
439
- if target != "▫" and self._friend(target, white_turn):
440
- return False
441
-
442
- vy = dy - sy
443
- vx = dx - sx
444
- ady, adx = abs(vy), abs(vx)
445
-
446
- def clear_line(stepy: int, stepx: int) -> bool:
447
- y, x = sy + stepy, sx + stepx
448
- while (y, x) != (dy, dx):
449
- if b[y][x] != "▫":
450
- return False
451
- y += stepy
452
- x += stepx
453
- return True
454
-
455
- if piece in "♙♟":
456
- direction = -1 if piece == "♙" else 1
457
- start_row = 6 if piece == "♙" else 1
458
- if vx == 0 and target == "":
459
- if vy == direction:
460
- return True
461
- if sy == start_row and vy == 2 * direction and b[sy + direction][sx] == "▫":
462
- return True
463
- if adx == 1 and vy == direction and target != "▫" and self._enemy(target, white_turn):
464
- return True
465
- return False
466
- if piece in "♖♜":
467
- if sx == dx and clear_line(1 if vy > 0 else -1, 0):
468
- return True
469
- if sy == dy and clear_line(0, 1 if vx > 0 else -1):
470
- return True
471
- return False
472
- if piece in "♗♝":
473
- if adx == ady and clear_line(1 if vy > 0 else -1, 1 if vx > 0 else -1):
474
- return True
475
- return False
476
- if piece in "♕♛":
477
- if sx == dx and clear_line(1 if vy > 0 else -1, 0):
478
- return True
479
- if sy == dy and clear_line(0, 1 if vx > 0 else -1):
480
- return True
481
- if adx == ady and clear_line(1 if vy > 0 else -1, 1 if vx > 0 else -1):
482
- return True
483
- return False
484
- if piece in "♘♞":
485
- return (ady, adx) in {(1, 2), (2, 1)}
486
- if piece in "♔♚":
487
- return max(ady, adx) == 1
488
- return False
489
-
490
- def _checkers_legal(self, b: list[list[str]], src: tuple[int, int], dst: tuple[int, int], white_turn: bool) -> bool:
491
- sy, sx = src
492
- dy, dx = dst
493
- if not (self._in_bounds(sy, sx) and self._in_bounds(dy, dx)):
494
- return False
495
- me = "⚪" if white_turn else "🔴"
496
- enemy = "🔴" if white_turn else ""
497
- if b[sy][sx] != me or b[dy][dx] != "":
498
- return False
499
- vy, vx = dy - sy, dx - sx
500
- direction = -1 if white_turn else 1
501
- if abs(vx) == 1 and vy == direction:
502
- return True
503
- if abs(vx) == 2 and vy == 2 * direction:
504
- my, mx = sy + direction, sx + (1 if vx > 0 else -1)
505
- return b[my][mx] == enemy
506
- return False
507
-
508
- def _apply_checkers_capture(self, b: list[list[str]], src: tuple[int, int], dst: tuple[int, int], white_turn: bool) -> None:
509
- sy, sx = src
510
- dy, dx = dst
511
- if abs(dy - sy) == 2:
512
- my, mx = (sy + dy) // 2, (sx + dx) // 2
513
- b[my][mx] = "▫"
514
-
515
- def _connect4_drop(self, b: list[list[str]], col: int, white_turn: bool) -> tuple[int, int] | None:
516
- token = "🔵" if white_turn else "🟠"
517
- for y in range(len(b) - 1, -1, -1):
518
- if b[y][col] == "⚫":
519
- b[y][col] = token
520
- return y, col
521
- return None
522
-
523
- def _has_four(self, b: list[list[str]], y: int, x: int) -> bool:
524
- token = b[y][x]
525
- if token == "⚫":
526
- return False
527
- dirs = [(1, 0), (0, 1), (1, 1), (1, -1)]
528
- h, w = len(b), len(b[0])
529
- for dy, dx in dirs:
530
- c = 1
531
- for sign in (1, -1):
532
- ny, nx = y, x
533
- while True:
534
- ny += dy * sign
535
- nx += dx * sign
536
- if not (0 <= ny < h and 0 <= nx < w) or b[ny][nx] != token:
537
- break
538
- c += 1
539
- if c >= 4:
540
- return True
541
- return False
542
-
543
- def _othello_flips(self, b: list[list[str]], y: int, x: int, black_turn: bool) -> list[tuple[int, int]]:
544
- me = "⚫" if black_turn else "⚪"
545
- enemy = "" if black_turn else ""
546
- if not self._in_bounds(y, x) or b[y][x] != "🟩":
547
- return []
548
- flips: list[tuple[int, int]] = []
549
- for dy in (-1, 0, 1):
550
- for dx in (-1, 0, 1):
551
- if dy == 0 and dx == 0:
552
- continue
553
- path: list[tuple[int, int]] = []
554
- ny, nx = y + dy, x + dx
555
- while self._in_bounds(ny, nx) and b[ny][nx] == enemy:
556
- path.append((ny, nx))
557
- ny += dy
558
- nx += dx
559
- if path and self._in_bounds(ny, nx) and b[ny][nx] == me:
560
- flips.extend(path)
561
- return flips
562
-
563
- async def _finish(self, ctx: commands.Context, winner_id: int | None, reason: str) -> None:
564
- s = self.sessions.pop(ctx.channel.id, None)
565
- if not s:
566
- return
567
- if winner_id is None:
568
- await ctx.send(f"🤝 Draw | {reason}")
569
- return
570
-
571
- if winner_id == 0:
572
- await ctx.send(f"🤖 Bot won | {reason}")
573
- return
574
-
575
- await ctx.send(f"🏆 Winner: <@{winner_id}> | {reason}")
576
- if s.tournament_name:
577
- await self.bot.db.execute(
578
- "UPDATE tournaments SET winner_id = ?, status = 'finished' WHERE guild_id = ? AND name = ?",
579
- winner_id,
580
- ctx.guild.id,
581
- s.tournament_name,
582
- )
583
-
584
- async def _bot_move(self, ctx: commands.Context) -> None:
585
- s = self.sessions.get(ctx.channel.id)
586
- if not s or s.players[1] != 0 or self._current_player(s) != 0:
587
- return
588
- if s.game == "connect4":
589
- valid = [c for c in range(7) if s.board[0][c] == ""]
590
- col = random.choice(valid)
591
- spot = self._connect4_drop(s.board, col, white_turn=False)
592
- if spot and self._has_four(s.board, spot[0], spot[1]):
593
- await self._announce(ctx.channel, s, f"Bot played column {col + 1}.")
594
- await self._finish(ctx, 0, "Four in a row!")
595
- return
596
- s.turn = 0
597
- await self._announce(ctx.channel, s, f"Bot played column {col + 1}.")
598
- return
599
-
600
- if s.game == "othello":
601
- legal: list[tuple[int, int, list[tuple[int, int]]]] = []
602
- for y in range(8):
603
- for x in range(8):
604
- flips = self._othello_flips(s.board, y, x, black_turn=False)
605
- if flips:
606
- legal.append((y, x, flips))
607
- if not legal:
608
- s.turn = 0
609
- await self._announce(ctx.channel, s, "Bot has no valid move and passes.")
610
- return
611
- y, x, flips = random.choice(legal)
612
- s.board[y][x] = "⚪"
613
- for fy, fx in flips:
614
- s.board[fy][fx] = "⚪"
615
- s.turn = 0
616
- await self._announce(ctx.channel, s, f"Bot played {self._coord_to_text(y, x)}")
617
- return
618
-
619
- # chess/checkers bot move: random pseudo legal
620
- moves: list[tuple[tuple[int, int], tuple[int, int]]] = []
621
- for sy in range(8):
622
- for sx in range(8):
623
- for dy in range(8):
624
- for dx in range(8):
625
- if s.game == "chess":
626
- if self._chess_legal(s.board, (sy, sx), (dy, dx), white_turn=False):
627
- moves.append(((sy, sx), (dy, dx)))
628
- elif self._checkers_legal(s.board, (sy, sx), (dy, dx), white_turn=False):
629
- moves.append(((sy, sx), (dy, dx)))
630
- if not moves:
631
- await self._finish(ctx, s.players[0], "Bot has no legal moves.")
632
- return
633
- src, dst = random.choice(moves)
634
- sy, sx = src
635
- dy, dx = dst
636
- target = s.board[dy][dx]
637
- if s.game == "checkers":
638
- self._apply_checkers_capture(s.board, src, dst, white_turn=False)
639
- s.board[dy][dx] = s.board[sy][sx]
640
- s.board[sy][sx] = "▫"
641
- s.turn = 0
642
- await self._announce(ctx.channel, s, f"Bot played {self._coord_to_text(sy, sx)}{self._coord_to_text(dy, dx)}")
643
- if s.game == "chess" and target == "♔":
644
- await self._finish(ctx, 0, "White king captured.")
645
-
646
- @commands.hybrid_command(name="boardgames", hidden=True, description="Show supported board games", with_app_command=False)
647
- async def boardgames(self, ctx: commands.Context) -> None:
648
- embed = discord.Embed(title="Board Games", color=discord.Color.blurple())
649
- embed.description = (
650
- "• chess\n"
651
- "• checkers\n"
652
- "• connect4\n"
653
- "• othello\n\n"
654
- "Use `/board_start <game> [opponent]` then `/board_move`"
655
- )
656
- await ctx.reply(embed=embed)
657
-
658
- @commands.hybrid_command(name="board_start", hidden=True, description="Start a board game", with_app_command=False)
659
- async def board_start(self, ctx: commands.Context, game: str, opponent: discord.Member | None = None) -> None:
660
- await self.start_game_session(ctx, game, opponent)
661
-
662
- @commands.hybrid_command(name="chess", hidden=True, description="Start chess with player or bot", with_app_command=False)
663
- async def chess(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
664
- await self.start_game_session(ctx, "chess", opponent)
665
-
666
- @commands.hybrid_command(name="checkers", hidden=True, description="Start checkers with player or bot", with_app_command=False)
667
- async def checkers(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
668
- await self.start_game_session(ctx, "checkers", opponent)
669
-
670
- @commands.hybrid_command(name="connect4", hidden=True, description="Start connect4 with player or bot", with_app_command=False)
671
- async def connect4(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
672
- await self.start_game_session(ctx, "connect4", opponent)
673
-
674
- @commands.hybrid_command(name="othello", hidden=True, description="Start othello with player or bot", with_app_command=False)
675
- async def othello(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
676
- await self.start_game_session(ctx, "othello", opponent)
677
-
678
- async def _play_turn(self, *, guild: discord.Guild, channel: discord.TextChannel, author_id: int, move: str) -> str | None:
679
- s = self.sessions.get(channel.id)
680
- if not s:
681
- return "No active board game in this channel."
682
- if self._current_player(s) != author_id:
683
- return "Not your turn."
684
-
685
- is_first_player = author_id == s.players[0]
686
-
687
- if s.game == "connect4":
688
- try:
689
- col = int(move) - 1
690
- except ValueError:
691
- return "Connect4 move is column number 1-7"
692
- if not 0 <= col < 7:
693
- return "Column must be 1-7"
694
- spot = self._connect4_drop(s.board, col, white_turn=is_first_player)
695
- if not spot:
696
- return "Column is full."
697
- if self._has_four(s.board, spot[0], spot[1]):
698
- await self._announce(channel, s, f"Move: {col + 1}")
699
- winner = author_id if author_id != 0 else None
700
- await self._finish_ctxless(guild, channel, winner, "Four in a row!")
701
- return None
702
- if all(s.board[0][i] != "⚫" for i in range(7)):
703
- await self._announce(channel, s, f"Move: {col + 1}")
704
- await self._finish_ctxless(guild, channel, None, "Board full")
705
- return None
706
- s.turn = 1 - s.turn
707
- await self._announce(channel, s, f"Move: {col + 1}")
708
- if s.players[1] == 0:
709
- await self._bot_move_channel(guild, channel)
710
- return None
711
-
712
- if s.game == "othello":
713
- c = self._coord(move)
714
- if not c:
715
- return "Use coordinate like d3"
716
- y, x = c
717
- black_turn = is_first_player
718
- flips = self._othello_flips(s.board, y, x, black_turn=black_turn)
719
- if not flips:
720
- return "Illegal move."
721
- s.board[y][x] = "⚫" if black_turn else "⚪"
722
- for fy, fx in flips:
723
- s.board[fy][fx] = "⚫" if black_turn else "⚪"
724
- s.turn = 1 - s.turn
725
- await self._announce(channel, s, f"Move: {move.lower()}")
726
- if s.players[1] == 0:
727
- await self._bot_move_channel(guild, channel)
728
- return None
729
-
730
- if len(move) != 4:
731
- return "Use move format like e2e4"
732
- src = self._coord(move[:2])
733
- dst = self._coord(move[2:])
734
- if not src or not dst:
735
- return "Invalid coordinates. Example: e2e4"
736
-
737
- legal = self._chess_legal(s.board, src, dst, white_turn=is_first_player) if s.game == "chess" else self._checkers_legal(s.board, src, dst, white_turn=is_first_player)
738
- if not legal:
739
- return "Illegal move."
740
-
741
- sy, sx = src
742
- dy, dx = dst
743
- target = s.board[dy][dx]
744
- if s.game == "checkers":
745
- self._apply_checkers_capture(s.board, src, dst, white_turn=is_first_player)
746
- piece = s.board[sy][sx]
747
- s.board[dy][dx] = piece
748
- s.board[sy][sx] = "▫"
749
- if piece == "" and dy == 0:
750
- s.board[dy][dx] = ""
751
- if piece == "" and dy == 7:
752
- s.board[dy][dx] = ""
753
-
754
- if s.game == "chess" and target in {"♚", "♔"}:
755
- await self._announce(channel, s, f"Move: {move.lower()}")
756
- await self._finish_ctxless(guild, channel, author_id, "King captured.")
757
- return None
758
-
759
- s.turn = 1 - s.turn
760
- await self._announce(channel, s, f"Move: {move.lower()}")
761
- if s.players[1] == 0:
762
- await self._bot_move_channel(guild, channel)
763
- return None
764
-
765
- async def _finish_ctxless(self, guild: discord.Guild, channel: discord.TextChannel, winner_id: int | None, reason: str) -> None:
766
- s = self.sessions.pop(channel.id, None)
767
- if not s:
768
- return
769
- if winner_id is None:
770
- await channel.send(f"🤝 Draw | {reason}")
771
- return
772
- if winner_id == 0:
773
- await channel.send(f"🤖 Bot won | {reason}")
774
- return
775
- await channel.send(f"🏆 Winner: <@{winner_id}> | {reason}")
776
- if s.tournament_name:
777
- await self.bot.db.execute(
778
- "UPDATE tournaments SET winner_id = ?, status = 'finished' WHERE guild_id = ? AND name = ?",
779
- winner_id,
780
- guild.id,
781
- s.tournament_name,
782
- )
783
-
784
- async def _bot_move_channel(self, guild: discord.Guild, channel: discord.TextChannel) -> None:
785
- class Dummy:
786
- pass
787
- d=Dummy(); d.guild=guild; d.channel=channel
788
- await self._bot_move(d)
789
-
790
- async def _forfeit(self, *, guild: discord.Guild, channel: discord.TextChannel, author_id: int) -> str | None:
791
- s = self.sessions.get(channel.id)
792
- if not s:
793
- return "No active board game in this channel."
794
- if author_id not in s.players:
795
- return "Only players can forfeit."
796
- winner = s.players[1] if author_id == s.players[0] else s.players[0]
797
- await self._finish_ctxless(guild, channel, None if winner == 0 else winner, "Forfeit")
798
- return None
799
-
800
- @commands.hybrid_command(name="board_move", hidden=True, description="Play move in current board game", with_app_command=False)
801
- async def board_move(self, ctx: commands.Context, move: str) -> None:
802
- if not ctx.guild or not isinstance(ctx.channel, discord.TextChannel):
803
- await ctx.reply("Server text channels only.")
804
- return
805
- err = await self._play_turn(guild=ctx.guild, channel=ctx.channel, author_id=ctx.author.id, move=move)
806
- if err:
807
- await ctx.reply(err)
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:
815
- if not ctx.guild or not isinstance(ctx.channel, discord.TextChannel):
816
- await ctx.reply("Server text channels only.")
817
- return
818
- err = await self._forfeit(guild=ctx.guild, channel=ctx.channel, author_id=ctx.author.id)
819
- if err:
820
- await ctx.reply(err)
821
-
822
-
823
- async def setup(bot: commands.Bot) -> None:
824
- await bot.add_cog(BoardGames(bot))
 
 
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from dataclasses import dataclass
5
+
6
+ import discord
7
+ from discord.ext import commands
8
+ from bot.i18n import get_cmd_desc
9
+ from bot.emojis import ui
10
+
11
+ FILES = "abcdefgh"
12
+
13
+
14
+ @dataclass
15
+ class BoardGameSession:
16
+ game: str
17
+ players: tuple[int, int] # second player can be 0 for bot
18
+ turn: int
19
+ board: list[list[str]]
20
+ tournament_name: str | None = None
21
+
22
+
23
+
24
+
25
+ class BoardMoveModal(discord.ui.Modal, title="Play Move"):
26
+ move = discord.ui.TextInput(label="Move", placeholder="e2e4 / d3 / 4", max_length=10)
27
+
28
+ def __init__(self, cog: "BoardGames") -> None:
29
+ super().__init__(timeout=None)
30
+ self.cog = cog
31
+
32
+ async def on_submit(self, interaction: discord.Interaction) -> None:
33
+ if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
34
+ await interaction.response.send_message("Server text channels only.", ephemeral=True)
35
+ return
36
+ err = await self.cog._play_turn(
37
+ guild=interaction.guild,
38
+ channel=interaction.channel,
39
+ author_id=interaction.user.id,
40
+ move=str(self.move.value).strip(),
41
+ )
42
+ if err:
43
+ await interaction.response.send_message(err, ephemeral=True)
44
+ else:
45
+ await interaction.response.send_message("✅ Move accepted.", ephemeral=True)
46
+
47
+
48
+ class BoardActionView(discord.ui.View):
49
+ def __init__(self, cog: "BoardGames") -> None:
50
+ super().__init__(timeout=None)
51
+ self.cog = cog
52
+
53
+ @discord.ui.button(label="Play Move", style=discord.ButtonStyle.primary, emoji=ui("controller"), custom_id="board_play")
54
+ async def play_move(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
55
+ await interaction.response.send_modal(BoardMoveModal(self.cog))
56
+
57
+ @discord.ui.button(label="Forfeit", style=discord.ButtonStyle.danger, emoji=ui("no"), custom_id="board_forfeit")
58
+ async def forfeit(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
59
+ if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
60
+ await interaction.response.send_message("Server text channels only.", ephemeral=True)
61
+ return
62
+ err = await self.cog._forfeit(
63
+ guild=interaction.guild,
64
+ channel=interaction.channel,
65
+ author_id=interaction.user.id,
66
+ )
67
+ if err:
68
+ await interaction.response.send_message(err, ephemeral=True)
69
+ return
70
+ await interaction.response.send_message("🏳️ Forfeit processed.", ephemeral=True)
71
+
72
+ @discord.ui.button(label="End Game", style=discord.ButtonStyle.danger, emoji=ui("lock"), custom_id="board_end")
73
+ async def end_game(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
74
+ if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
75
+ await interaction.response.send_message("Server text channels only.", ephemeral=True)
76
+ return
77
+ session = self.cog.sessions.get(interaction.channel.id)
78
+ if not session:
79
+ await interaction.response.send_message("No active board game in this channel.", ephemeral=True)
80
+ return
81
+ if interaction.user.id not in session.players and not interaction.user.guild_permissions.manage_channels:
82
+ await interaction.response.send_message("Only players/admins can end this game.", ephemeral=True)
83
+ return
84
+ self.cog.sessions.pop(interaction.channel.id, None)
85
+ await interaction.response.send_message("🔒 Game session ended.", ephemeral=True)
86
+ if interaction.channel.name.startswith("game-"):
87
+ try:
88
+ await interaction.channel.delete(reason=f"Game room closed by {interaction.user}")
89
+ except Exception:
90
+ pass
91
+
92
+
93
+ class Connect4ActionView(BoardActionView):
94
+ def __init__(self, cog: "BoardGames") -> None:
95
+ super().__init__(cog)
96
+ self.clear_items()
97
+ for col in range(1, 8):
98
+ button = discord.ui.Button(label=str(col), style=discord.ButtonStyle.secondary, row=0 if col <= 4 else 1)
99
+
100
+ async def _drop(interaction: discord.Interaction, c: int = col) -> None:
101
+ if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
102
+ await interaction.response.send_message("Server text channels only.", ephemeral=True)
103
+ return
104
+ err = await self.cog._play_turn(
105
+ guild=interaction.guild,
106
+ channel=interaction.channel,
107
+ author_id=interaction.user.id,
108
+ move=str(c),
109
+ )
110
+ if err:
111
+ await interaction.response.send_message(err, ephemeral=True)
112
+ else:
113
+ await interaction.response.send_message(f"✅ Dropped in column {c}", ephemeral=True)
114
+
115
+ button.callback = _drop
116
+ self.add_item(button)
117
+
118
+ forfeit_btn = discord.ui.Button(label="Forfeit", style=discord.ButtonStyle.danger, emoji=ui("no"), row=2, custom_id="board_forfeit_c4")
119
+ end_btn = discord.ui.Button(label="End Game", style=discord.ButtonStyle.danger, emoji=ui("lock"), row=2, custom_id="board_end_c4")
120
+
121
+ async def _forfeit(interaction: discord.Interaction) -> None:
122
+ if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
123
+ await interaction.response.send_message("Server text channels only.", ephemeral=True)
124
+ return
125
+ err = await self.cog._forfeit(guild=interaction.guild, channel=interaction.channel, author_id=interaction.user.id)
126
+ if err:
127
+ await interaction.response.send_message(err, ephemeral=True)
128
+ return
129
+ await interaction.response.send_message("🏳️ Forfeit processed.", ephemeral=True)
130
+
131
+ forfeit_btn.callback = _forfeit
132
+ self.add_item(forfeit_btn)
133
+
134
+ async def _end(interaction: discord.Interaction) -> None:
135
+ if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
136
+ await interaction.response.send_message("Server text channels only.", ephemeral=True)
137
+ return
138
+ session = self.cog.sessions.get(interaction.channel.id)
139
+ if not session:
140
+ await interaction.response.send_message("No active board game in this channel.", ephemeral=True)
141
+ return
142
+ if interaction.user.id not in session.players and not interaction.user.guild_permissions.manage_channels:
143
+ await interaction.response.send_message("Only players/admins can end this game.", ephemeral=True)
144
+ return
145
+ self.cog.sessions.pop(interaction.channel.id, None)
146
+ await interaction.response.send_message("🔒 Game session ended.", ephemeral=True)
147
+ if interaction.channel.name.startswith("game-"):
148
+ try:
149
+ await interaction.channel.delete(reason=f"Game room closed by {interaction.user}")
150
+ except Exception:
151
+ pass
152
+
153
+ end_btn.callback = _end
154
+ self.add_item(end_btn)
155
+
156
+
157
+ import random as _random
158
+
159
+
160
+ class QuickRPSView(discord.ui.View):
161
+ def __init__(self) -> None:
162
+ super().__init__(timeout=60)
163
+
164
+ @discord.ui.button(label="Rock 🪨", style=discord.ButtonStyle.primary)
165
+ async def rock(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
166
+ await self._resolve(interaction, "rock")
167
+
168
+ @discord.ui.button(label="Paper 📄", style=discord.ButtonStyle.primary)
169
+ async def paper(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
170
+ await self._resolve(interaction, "paper")
171
+
172
+ @discord.ui.button(label="Scissors ✂️", style=discord.ButtonStyle.primary)
173
+ async def scissors(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
174
+ await self._resolve(interaction, "scissors")
175
+
176
+ async def _resolve(self, interaction: discord.Interaction, choice: str) -> None:
177
+ bot_choice = _random.choice(["rock", "paper", "scissors"])
178
+ emoji_map = {"rock": "🪨", "paper": "📄", "scissors": "✂️"}
179
+ result_map = {("rock", "scissors"): "win", ("paper", "rock"): "win", ("scissors", "paper"): "win"}
180
+ if choice == bot_choice:
181
+ result = "🤝 Tie!"
182
+ elif (choice, bot_choice) in result_map:
183
+ result = "🎉 You win!"
184
+ else:
185
+ result = "💀 You lose!"
186
+ await interaction.response.send_message(f"You: {emoji_map[choice]} | Bot: {emoji_map[bot_choice]}\n{result}", ephemeral=True)
187
+ self.stop()
188
+
189
+
190
+ class GamePanelView(discord.ui.View):
191
+ def __init__(self, cog: "BoardGames") -> None:
192
+ super().__init__(timeout=None)
193
+ self.cog = cog
194
+
195
+ async def _start(self, interaction: discord.Interaction, game: str) -> None:
196
+ if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
197
+ await interaction.response.send_message("Server text channels only.", ephemeral=True)
198
+ return
199
+ ok = await self.cog.start_game_session_interaction(interaction, game)
200
+ if ok:
201
+ await interaction.response.send_message(f"✅ Started **{game}** vs bot.", ephemeral=True)
202
+
203
+ @discord.ui.button(label="Chess", style=discord.ButtonStyle.primary, emoji=ui("chess"), row=0, custom_id="board_chess")
204
+ async def chess(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
205
+ await self._start(interaction, "chess")
206
+
207
+ @discord.ui.button(label="Checkers", style=discord.ButtonStyle.secondary, emoji=ui("joystick"), row=0, custom_id="board_checkers")
208
+ async def checkers(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
209
+ await self._start(interaction, "checkers")
210
+
211
+ @discord.ui.button(label="Connect4", style=discord.ButtonStyle.success, emoji=ui("joystick"), row=0, custom_id="board_connect4")
212
+ async def connect4(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
213
+ await self._start(interaction, "connect4")
214
+
215
+ @discord.ui.button(label="Othello", style=discord.ButtonStyle.blurple, emoji=ui("joystick"), row=0, custom_id="board_othello")
216
+ async def othello(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
217
+ await self._start(interaction, "othello")
218
+
219
+ @discord.ui.button(label="TicTacToe", style=discord.ButtonStyle.primary, emoji=ui("x"), row=1, custom_id="board_tictactoe")
220
+ async def tictactoe(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
221
+ if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
222
+ await interaction.response.send_message("Server text channels only.", ephemeral=True)
223
+ return
224
+ await interaction.response.send_message(f"❎ **TicTacToe**: {interaction.user.mention} vs 🤖 Bot\nUse `/xo` to play!", ephemeral=True)
225
+
226
+ @discord.ui.button(label="Rock Paper Scissors", style=discord.ButtonStyle.secondary, emoji="✂️", row=1, custom_id="board_rps")
227
+ async def rps(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
228
+ if not interaction.guild:
229
+ await interaction.response.send_message("Server only.", ephemeral=True)
230
+ return
231
+ await interaction.response.send_message(
232
+ "Choose your move:",
233
+ view=QuickRPSView(),
234
+ ephemeral=True,
235
+ )
236
+
237
+
238
+ class BoardGames(commands.Cog):
239
+ def __init__(self, bot: commands.Bot) -> None:
240
+ self.bot = bot
241
+ self.sessions: dict[int, BoardGameSession] = {}
242
+
243
+ async def cog_load(self) -> None:
244
+ self.bot.add_view(GamePanelView(self))
245
+ self.bot.add_view(BoardActionView(self))
246
+
247
+ def _coord(self, token: str) -> tuple[int, int] | None:
248
+ token = token.strip().lower()
249
+ if len(token) != 2 or token[0] not in FILES or token[1] not in "12345678":
250
+ return None
251
+ x = FILES.index(token[0])
252
+ y = 8 - int(token[1])
253
+ return y, x
254
+
255
+ def _coord_to_text(self, y: int, x: int) -> str:
256
+ return f"{FILES[x]}{8-y}"
257
+
258
+ def _current_player(self, s: BoardGameSession) -> int:
259
+ return s.players[s.turn]
260
+
261
+ def _render(self, s: BoardGameSession) -> str:
262
+ if s.game == "connect4":
263
+ lines = ["1️⃣2️⃣3️⃣4️⃣5️⃣6️⃣7️⃣"]
264
+ for row in s.board:
265
+ lines.append("".join(row))
266
+ return "\n".join(lines)
267
+ if s.game == "othello":
268
+ lines = ["⬛🇦🇧🇨🇩🇪🇫🇬🇭"]
269
+ for y, row in enumerate(s.board):
270
+ lines.append(f"{8-y}️⃣" + "".join(row))
271
+ return "\n".join(lines)
272
+ lines = [" 🇦 🇧 🇨 🇩 🇪 🇫 🇬 🇭"]
273
+ for y, row in enumerate(s.board):
274
+ lines.append(f"{8-y} " + " ".join(row))
275
+ return "\n".join(lines)
276
+
277
+ def _make_chess(self) -> list[list[str]]:
278
+ return [
279
+ list("♜♞♝♛♚♝♞♜"),
280
+ list("♟♟♟♟♟♟♟♟"),
281
+ list("▫▫▫▫▫▫▫▫"),
282
+ list("▫▫▫▫▫▫▫▫"),
283
+ list("▫▫▫▫▫▫▫▫"),
284
+ list("▫▫▫▫▫▫▫▫"),
285
+ list("♙♙♙♙♙♙♙♙"),
286
+ list("♖♘♗♕♔♗♘♖"),
287
+ ]
288
+
289
+ def _make_checkers(self) -> list[list[str]]:
290
+ board = [["▫" for _ in range(8)] for _ in range(8)]
291
+ for y in range(3):
292
+ for x in range(8):
293
+ if (x + y) % 2 == 1:
294
+ board[y][x] = "🔴"
295
+ for y in range(5, 8):
296
+ for x in range(8):
297
+ if (x + y) % 2 == 1:
298
+ board[y][x] = "⚪"
299
+ return board
300
+
301
+ def _make_connect4(self) -> list[list[str]]:
302
+ return [["⚫" for _ in range(7)] for _ in range(6)]
303
+
304
+ def _make_othello(self) -> list[list[str]]:
305
+ b = [["🟩" for _ in range(8)] for _ in range(8)]
306
+ b[3][3] = ""
307
+ b[3][4] = "⚫"
308
+ b[4][3] = ""
309
+ b[4][4] = "⚪"
310
+ return b
311
+
312
+ async def _announce(self, ctx: commands.Context, s: BoardGameSession, text: str) -> None:
313
+ p1 = f"<@{s.players[0]}>"
314
+ p2 = "🤖 Bot" if s.players[1] == 0 else f"<@{s.players[1]}>"
315
+ turn = self._current_player(s)
316
+ tname = "🤖 Bot" if turn == 0 else f"<@{turn}>"
317
+ await ctx.send(
318
+ f"**{s.game.title()}** | {p1} vs {p2}\n{text}\nTurn: {tname}\n{self._render(s)}"
319
+ )
320
+
321
+ async def start_game_session(
322
+ self,
323
+ ctx: commands.Context,
324
+ game: str,
325
+ opponent: discord.Member | None = None,
326
+ tournament_name: str | None = None,
327
+ ) -> bool:
328
+ game = game.lower().strip()
329
+ if game not in {"chess", "checkers", "connect4", "othello"}:
330
+ await ctx.reply("Supported: chess, checkers, connect4, othello")
331
+ return False
332
+ if ctx.channel.id in self.sessions:
333
+ await ctx.reply("There is already an active board game in this channel.")
334
+ return False
335
+
336
+ opponent_id = opponent.id if opponent else 0
337
+ if opponent_id == ctx.author.id:
338
+ await ctx.reply("Choose another player or leave opponent empty for bot.")
339
+ return False
340
+
341
+ if game == "chess":
342
+ board = self._make_chess()
343
+ elif game == "checkers":
344
+ board = self._make_checkers()
345
+ elif game == "connect4":
346
+ board = self._make_connect4()
347
+ else:
348
+ board = self._make_othello()
349
+
350
+ s = BoardGameSession(game=game, players=(ctx.author.id, opponent_id), turn=0, board=board, tournament_name=tournament_name)
351
+ self.sessions[ctx.channel.id] = s
352
+ await self._announce(ctx.channel, s, "Game started. Use buttons or `/board_move`.")
353
+ return True
354
+
355
+ async def start_game_session_interaction(self, interaction: discord.Interaction, game: str) -> bool:
356
+ if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
357
+ return False
358
+ game = game.lower().strip()
359
+ if game not in {"chess", "checkers", "connect4", "othello"}:
360
+ await interaction.followup.send("Supported: chess, checkers, connect4, othello", ephemeral=True)
361
+ return False
362
+ if interaction.channel.id in self.sessions:
363
+ await interaction.followup.send("There is already an active board game in this channel.", ephemeral=True)
364
+ return False
365
+
366
+ if game == "chess":
367
+ board = self._make_chess()
368
+ elif game == "checkers":
369
+ board = self._make_checkers()
370
+ elif game == "connect4":
371
+ board = self._make_connect4()
372
+ else:
373
+ board = self._make_othello()
374
+
375
+ s = BoardGameSession(game=game, players=(interaction.user.id, 0), turn=0, board=board)
376
+ self.sessions[interaction.channel.id] = s
377
+ await self._announce(interaction.channel, s, "Game started from panel.")
378
+ return True
379
+
380
+ async def start_tournament_duel(
381
+ self,
382
+ ctx: commands.Context,
383
+ game: str,
384
+ player1: discord.Member,
385
+ player2: discord.Member | None,
386
+ tournament_name: str,
387
+ ) -> bool:
388
+ if ctx.channel.id in self.sessions:
389
+ await ctx.reply("There is already an active board game in this channel.")
390
+ return False
391
+ game = game.lower().strip()
392
+ if game == "chess":
393
+ board = self._make_chess()
394
+ elif game == "checkers":
395
+ board = self._make_checkers()
396
+ elif game == "connect4":
397
+ board = self._make_connect4()
398
+ elif game == "othello":
399
+ board = self._make_othello()
400
+ else:
401
+ await ctx.reply("Supported: chess, checkers, connect4, othello")
402
+ return False
403
+
404
+ s = BoardGameSession(
405
+ game=game,
406
+ players=(player1.id, player2.id if player2 else 0),
407
+ turn=0,
408
+ board=board,
409
+ tournament_name=tournament_name,
410
+ )
411
+ self.sessions[ctx.channel.id] = s
412
+ await self._announce(ctx.channel, s, f"Tournament duel: **{tournament_name}**")
413
+ return True
414
+
415
+
416
+ def _in_bounds(self, y: int, x: int) -> bool:
417
+ return 0 <= y < 8 and 0 <= x < 8
418
+
419
+ def _is_white(self, p: str) -> bool:
420
+ return p in "♙♖♘♗♕♔"
421
+
422
+ def _is_black(self, p: str) -> bool:
423
+ return p in "♟♜♞♝♛♚"
424
+
425
+ def _enemy(self, p: str, white_turn: bool) -> bool:
426
+ return self._is_black(p) if white_turn else self._is_white(p)
427
+
428
+ def _friend(self, p: str, white_turn: bool) -> bool:
429
+ return self._is_white(p) if white_turn else self._is_black(p)
430
+
431
+ def _chess_legal(self, b: list[list[str]], src: tuple[int, int], dst: tuple[int, int], white_turn: bool) -> bool:
432
+ sy, sx = src
433
+ dy, dx = dst
434
+ if not (self._in_bounds(sy, sx) and self._in_bounds(dy, dx)):
435
+ return False
436
+ piece = b[sy][sx]
437
+ if piece == "▫" or not self._friend(piece, white_turn):
438
+ return False
439
+ target = b[dy][dx]
440
+ if target != "▫" and self._friend(target, white_turn):
441
+ return False
442
+
443
+ vy = dy - sy
444
+ vx = dx - sx
445
+ ady, adx = abs(vy), abs(vx)
446
+
447
+ def clear_line(stepy: int, stepx: int) -> bool:
448
+ y, x = sy + stepy, sx + stepx
449
+ while (y, x) != (dy, dx):
450
+ if b[y][x] != "▫":
451
+ return False
452
+ y += stepy
453
+ x += stepx
454
+ return True
455
+
456
+ if piece in "♙":
457
+ direction = -1 if piece == "♙" else 1
458
+ start_row = 6 if piece == "" else 1
459
+ if vx == 0 and target == "▫":
460
+ if vy == direction:
461
+ return True
462
+ if sy == start_row and vy == 2 * direction and b[sy + direction][sx] == "▫":
463
+ return True
464
+ if adx == 1 and vy == direction and target != "▫" and self._enemy(target, white_turn):
465
+ return True
466
+ return False
467
+ if piece in "♖♜":
468
+ if sx == dx and clear_line(1 if vy > 0 else -1, 0):
469
+ return True
470
+ if sy == dy and clear_line(0, 1 if vx > 0 else -1):
471
+ return True
472
+ return False
473
+ if piece in "♗♝":
474
+ if adx == ady and clear_line(1 if vy > 0 else -1, 1 if vx > 0 else -1):
475
+ return True
476
+ return False
477
+ if piece in "♕♛":
478
+ if sx == dx and clear_line(1 if vy > 0 else -1, 0):
479
+ return True
480
+ if sy == dy and clear_line(0, 1 if vx > 0 else -1):
481
+ return True
482
+ if adx == ady and clear_line(1 if vy > 0 else -1, 1 if vx > 0 else -1):
483
+ return True
484
+ return False
485
+ if piece in "♘♞":
486
+ return (ady, adx) in {(1, 2), (2, 1)}
487
+ if piece in "♔♚":
488
+ return max(ady, adx) == 1
489
+ return False
490
+
491
+ def _checkers_legal(self, b: list[list[str]], src: tuple[int, int], dst: tuple[int, int], white_turn: bool) -> bool:
492
+ sy, sx = src
493
+ dy, dx = dst
494
+ if not (self._in_bounds(sy, sx) and self._in_bounds(dy, dx)):
495
+ return False
496
+ me = "" if white_turn else "🔴"
497
+ enemy = "🔴" if white_turn else ""
498
+ if b[sy][sx] != me or b[dy][dx] != "▫":
499
+ return False
500
+ vy, vx = dy - sy, dx - sx
501
+ direction = -1 if white_turn else 1
502
+ if abs(vx) == 1 and vy == direction:
503
+ return True
504
+ if abs(vx) == 2 and vy == 2 * direction:
505
+ my, mx = sy + direction, sx + (1 if vx > 0 else -1)
506
+ return b[my][mx] == enemy
507
+ return False
508
+
509
+ def _apply_checkers_capture(self, b: list[list[str]], src: tuple[int, int], dst: tuple[int, int], white_turn: bool) -> None:
510
+ sy, sx = src
511
+ dy, dx = dst
512
+ if abs(dy - sy) == 2:
513
+ my, mx = (sy + dy) // 2, (sx + dx) // 2
514
+ b[my][mx] = "▫"
515
+
516
+ def _connect4_drop(self, b: list[list[str]], col: int, white_turn: bool) -> tuple[int, int] | None:
517
+ token = "🔵" if white_turn else "🟠"
518
+ for y in range(len(b) - 1, -1, -1):
519
+ if b[y][col] == "⚫":
520
+ b[y][col] = token
521
+ return y, col
522
+ return None
523
+
524
+ def _has_four(self, b: list[list[str]], y: int, x: int) -> bool:
525
+ token = b[y][x]
526
+ if token == "⚫":
527
+ return False
528
+ dirs = [(1, 0), (0, 1), (1, 1), (1, -1)]
529
+ h, w = len(b), len(b[0])
530
+ for dy, dx in dirs:
531
+ c = 1
532
+ for sign in (1, -1):
533
+ ny, nx = y, x
534
+ while True:
535
+ ny += dy * sign
536
+ nx += dx * sign
537
+ if not (0 <= ny < h and 0 <= nx < w) or b[ny][nx] != token:
538
+ break
539
+ c += 1
540
+ if c >= 4:
541
+ return True
542
+ return False
543
+
544
+ def _othello_flips(self, b: list[list[str]], y: int, x: int, black_turn: bool) -> list[tuple[int, int]]:
545
+ me = "" if black_turn else ""
546
+ enemy = "⚪" if black_turn else ""
547
+ if not self._in_bounds(y, x) or b[y][x] != "🟩":
548
+ return []
549
+ flips: list[tuple[int, int]] = []
550
+ for dy in (-1, 0, 1):
551
+ for dx in (-1, 0, 1):
552
+ if dy == 0 and dx == 0:
553
+ continue
554
+ path: list[tuple[int, int]] = []
555
+ ny, nx = y + dy, x + dx
556
+ while self._in_bounds(ny, nx) and b[ny][nx] == enemy:
557
+ path.append((ny, nx))
558
+ ny += dy
559
+ nx += dx
560
+ if path and self._in_bounds(ny, nx) and b[ny][nx] == me:
561
+ flips.extend(path)
562
+ return flips
563
+
564
+ async def _finish(self, ctx: commands.Context, winner_id: int | None, reason: str) -> None:
565
+ s = self.sessions.pop(ctx.channel.id, None)
566
+ if not s:
567
+ return
568
+ if winner_id is None:
569
+ await ctx.send(f"🤝 Draw | {reason}")
570
+ return
571
+
572
+ if winner_id == 0:
573
+ await ctx.send(f"🤖 Bot won | {reason}")
574
+ return
575
+
576
+ await ctx.send(f"🏆 Winner: <@{winner_id}> | {reason}")
577
+ if s.tournament_name:
578
+ await self.bot.db.execute(
579
+ "UPDATE tournaments SET winner_id = ?, status = 'finished' WHERE guild_id = ? AND name = ?",
580
+ winner_id,
581
+ ctx.guild.id,
582
+ s.tournament_name,
583
+ )
584
+
585
+ async def _bot_move(self, ctx: commands.Context) -> None:
586
+ s = self.sessions.get(ctx.channel.id)
587
+ if not s or s.players[1] != 0 or self._current_player(s) != 0:
588
+ return
589
+ if s.game == "connect4":
590
+ valid = [c for c in range(7) if s.board[0][c] == "⚫"]
591
+ col = random.choice(valid)
592
+ spot = self._connect4_drop(s.board, col, white_turn=False)
593
+ if spot and self._has_four(s.board, spot[0], spot[1]):
594
+ await self._announce(ctx.channel, s, f"Bot played column {col + 1}.")
595
+ await self._finish(ctx, 0, "Four in a row!")
596
+ return
597
+ s.turn = 0
598
+ await self._announce(ctx.channel, s, f"Bot played column {col + 1}.")
599
+ return
600
+
601
+ if s.game == "othello":
602
+ legal: list[tuple[int, int, list[tuple[int, int]]]] = []
603
+ for y in range(8):
604
+ for x in range(8):
605
+ flips = self._othello_flips(s.board, y, x, black_turn=False)
606
+ if flips:
607
+ legal.append((y, x, flips))
608
+ if not legal:
609
+ s.turn = 0
610
+ await self._announce(ctx.channel, s, "Bot has no valid move and passes.")
611
+ return
612
+ y, x, flips = random.choice(legal)
613
+ s.board[y][x] = "⚪"
614
+ for fy, fx in flips:
615
+ s.board[fy][fx] = "⚪"
616
+ s.turn = 0
617
+ await self._announce(ctx.channel, s, f"Bot played {self._coord_to_text(y, x)}")
618
+ return
619
+
620
+ # chess/checkers bot move: random pseudo legal
621
+ moves: list[tuple[tuple[int, int], tuple[int, int]]] = []
622
+ for sy in range(8):
623
+ for sx in range(8):
624
+ for dy in range(8):
625
+ for dx in range(8):
626
+ if s.game == "chess":
627
+ if self._chess_legal(s.board, (sy, sx), (dy, dx), white_turn=False):
628
+ moves.append(((sy, sx), (dy, dx)))
629
+ elif self._checkers_legal(s.board, (sy, sx), (dy, dx), white_turn=False):
630
+ moves.append(((sy, sx), (dy, dx)))
631
+ if not moves:
632
+ await self._finish(ctx, s.players[0], "Bot has no legal moves.")
633
+ return
634
+ src, dst = random.choice(moves)
635
+ sy, sx = src
636
+ dy, dx = dst
637
+ target = s.board[dy][dx]
638
+ if s.game == "checkers":
639
+ self._apply_checkers_capture(s.board, src, dst, white_turn=False)
640
+ s.board[dy][dx] = s.board[sy][sx]
641
+ s.board[sy][sx] = "▫"
642
+ s.turn = 0
643
+ await self._announce(ctx.channel, s, f"Bot played {self._coord_to_text(sy, sx)}{self._coord_to_text(dy, dx)}")
644
+ if s.game == "chess" and target == "♔":
645
+ await self._finish(ctx, 0, "White king captured.")
646
+
647
+ @commands.hybrid_command(name="boardgames", hidden=True, description=get_cmd_desc("commands.boardgames.boardgames_desc"), with_app_command=False)
648
+ async def boardgames(self, ctx: commands.Context) -> None:
649
+ embed = discord.Embed(title="Board Games", color=discord.Color.blurple())
650
+ embed.description = (
651
+ "• chess\n"
652
+ "• checkers\n"
653
+ "• connect4\n"
654
+ " othello\n\n"
655
+ "Use `/board_start <game> [opponent]` then `/board_move`"
656
+ )
657
+ await ctx.reply(embed=embed)
658
+
659
+ @commands.hybrid_command(name="board_start", hidden=True, description=get_cmd_desc("commands.boardgames.board_start_desc"), with_app_command=False)
660
+ async def board_start(self, ctx: commands.Context, game: str, opponent: discord.Member | None = None) -> None:
661
+ await self.start_game_session(ctx, game, opponent)
662
+
663
+ @commands.hybrid_command(name="chess", hidden=True, description=get_cmd_desc("commands.boardgames.chess_desc"), with_app_command=False)
664
+ async def chess(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
665
+ await self.start_game_session(ctx, "chess", opponent)
666
+
667
+ @commands.hybrid_command(name="checkers", hidden=True, description=get_cmd_desc("commands.boardgames.checkers_desc"), with_app_command=False)
668
+ async def checkers(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
669
+ await self.start_game_session(ctx, "checkers", opponent)
670
+
671
+ @commands.hybrid_command(name="connect4", hidden=True, description=get_cmd_desc("commands.boardgames.connect4_desc"), with_app_command=False)
672
+ async def connect4(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
673
+ await self.start_game_session(ctx, "connect4", opponent)
674
+
675
+ @commands.hybrid_command(name="othello", hidden=True, description=get_cmd_desc("commands.boardgames.othello_desc"), with_app_command=False)
676
+ async def othello(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
677
+ await self.start_game_session(ctx, "othello", opponent)
678
+
679
+ async def _play_turn(self, *, guild: discord.Guild, channel: discord.TextChannel, author_id: int, move: str) -> str | None:
680
+ s = self.sessions.get(channel.id)
681
+ if not s:
682
+ return "No active board game in this channel."
683
+ if self._current_player(s) != author_id:
684
+ return "Not your turn."
685
+
686
+ is_first_player = author_id == s.players[0]
687
+
688
+ if s.game == "connect4":
689
+ try:
690
+ col = int(move) - 1
691
+ except ValueError:
692
+ return "Connect4 move is column number 1-7"
693
+ if not 0 <= col < 7:
694
+ return "Column must be 1-7"
695
+ spot = self._connect4_drop(s.board, col, white_turn=is_first_player)
696
+ if not spot:
697
+ return "Column is full."
698
+ if self._has_four(s.board, spot[0], spot[1]):
699
+ await self._announce(channel, s, f"Move: {col + 1}")
700
+ winner = author_id if author_id != 0 else None
701
+ await self._finish_ctxless(guild, channel, winner, "Four in a row!")
702
+ return None
703
+ if all(s.board[0][i] != "⚫" for i in range(7)):
704
+ await self._announce(channel, s, f"Move: {col + 1}")
705
+ await self._finish_ctxless(guild, channel, None, "Board full")
706
+ return None
707
+ s.turn = 1 - s.turn
708
+ await self._announce(channel, s, f"Move: {col + 1}")
709
+ if s.players[1] == 0:
710
+ await self._bot_move_channel(guild, channel)
711
+ return None
712
+
713
+ if s.game == "othello":
714
+ c = self._coord(move)
715
+ if not c:
716
+ return "Use coordinate like d3"
717
+ y, x = c
718
+ black_turn = is_first_player
719
+ flips = self._othello_flips(s.board, y, x, black_turn=black_turn)
720
+ if not flips:
721
+ return "Illegal move."
722
+ s.board[y][x] = "⚫" if black_turn else "⚪"
723
+ for fy, fx in flips:
724
+ s.board[fy][fx] = "⚫" if black_turn else "⚪"
725
+ s.turn = 1 - s.turn
726
+ await self._announce(channel, s, f"Move: {move.lower()}")
727
+ if s.players[1] == 0:
728
+ await self._bot_move_channel(guild, channel)
729
+ return None
730
+
731
+ if len(move) != 4:
732
+ return "Use move format like e2e4"
733
+ src = self._coord(move[:2])
734
+ dst = self._coord(move[2:])
735
+ if not src or not dst:
736
+ return "Invalid coordinates. Example: e2e4"
737
+
738
+ legal = self._chess_legal(s.board, src, dst, white_turn=is_first_player) if s.game == "chess" else self._checkers_legal(s.board, src, dst, white_turn=is_first_player)
739
+ if not legal:
740
+ return "Illegal move."
741
+
742
+ sy, sx = src
743
+ dy, dx = dst
744
+ target = s.board[dy][dx]
745
+ if s.game == "checkers":
746
+ self._apply_checkers_capture(s.board, src, dst, white_turn=is_first_player)
747
+ piece = s.board[sy][sx]
748
+ s.board[dy][dx] = piece
749
+ s.board[sy][sx] = ""
750
+ if piece == "" and dy == 0:
751
+ s.board[dy][dx] = ""
752
+ if piece == "" and dy == 7:
753
+ s.board[dy][dx] = "♛"
754
+
755
+ if s.game == "chess" and target in {"♚", "♔"}:
756
+ await self._announce(channel, s, f"Move: {move.lower()}")
757
+ await self._finish_ctxless(guild, channel, author_id, "King captured.")
758
+ return None
759
+
760
+ s.turn = 1 - s.turn
761
+ await self._announce(channel, s, f"Move: {move.lower()}")
762
+ if s.players[1] == 0:
763
+ await self._bot_move_channel(guild, channel)
764
+ return None
765
+
766
+ async def _finish_ctxless(self, guild: discord.Guild, channel: discord.TextChannel, winner_id: int | None, reason: str) -> None:
767
+ s = self.sessions.pop(channel.id, None)
768
+ if not s:
769
+ return
770
+ if winner_id is None:
771
+ await channel.send(f"🤝 Draw | {reason}")
772
+ return
773
+ if winner_id == 0:
774
+ await channel.send(f"🤖 Bot won | {reason}")
775
+ return
776
+ await channel.send(f"🏆 Winner: <@{winner_id}> | {reason}")
777
+ if s.tournament_name:
778
+ await self.bot.db.execute(
779
+ "UPDATE tournaments SET winner_id = ?, status = 'finished' WHERE guild_id = ? AND name = ?",
780
+ winner_id,
781
+ guild.id,
782
+ s.tournament_name,
783
+ )
784
+
785
+ async def _bot_move_channel(self, guild: discord.Guild, channel: discord.TextChannel) -> None:
786
+ class Dummy:
787
+ pass
788
+ d=Dummy(); d.guild=guild; d.channel=channel
789
+ await self._bot_move(d)
790
+
791
+ async def _forfeit(self, *, guild: discord.Guild, channel: discord.TextChannel, author_id: int) -> str | None:
792
+ s = self.sessions.get(channel.id)
793
+ if not s:
794
+ return "No active board game in this channel."
795
+ if author_id not in s.players:
796
+ return "Only players can forfeit."
797
+ winner = s.players[1] if author_id == s.players[0] else s.players[0]
798
+ await self._finish_ctxless(guild, channel, None if winner == 0 else winner, "Forfeit")
799
+ return None
800
+
801
+ @commands.hybrid_command(name="board_move", hidden=True, description=get_cmd_desc("commands.boardgames.board_move_desc"), with_app_command=False)
802
+ async def board_move(self, ctx: commands.Context, move: str) -> None:
803
+ if not ctx.guild or not isinstance(ctx.channel, discord.TextChannel):
804
+ await ctx.reply("Server text channels only.")
805
+ return
806
+ err = await self._play_turn(guild=ctx.guild, channel=ctx.channel, author_id=ctx.author.id, move=move)
807
+ if err:
808
+ await ctx.reply(err)
809
+
810
+ @commands.hybrid_command(name="games_panel", hidden=True, description=get_cmd_desc("commands.boardgames.games_panel_desc"), with_app_command=False)
811
+ async def games_panel(self, ctx: commands.Context) -> None:
812
+ await ctx.reply("<:animatedarrowgreen:1477261279428087979> This panel is deprecated. Use `/gamehub` for the improved game experience.")
813
+
814
+ @commands.hybrid_command(name="board_forfeit", hidden=True, description=get_cmd_desc("commands.boardgames.board_forfeit_desc"), with_app_command=False)
815
+ async def board_forfeit(self, ctx: commands.Context) -> None:
816
+ if not ctx.guild or not isinstance(ctx.channel, discord.TextChannel):
817
+ await ctx.reply("Server text channels only.")
818
+ return
819
+ err = await self._forfeit(guild=ctx.guild, channel=ctx.channel, author_id=ctx.author.id)
820
+ if err:
821
+ await ctx.reply(err)
822
+
823
+
824
+ async def setup(bot: commands.Bot) -> None:
825
+ await bot.add_cog(BoardGames(bot))
bot/cogs/community.py CHANGED
The diff for this file is too large to render. See raw diff
 
bot/cogs/configuration.py CHANGED
@@ -1,636 +1,637 @@
1
- from __future__ import annotations
2
-
3
- import datetime as dt
4
- import re
5
-
6
- import discord
7
- from discord.ext import commands
8
-
9
- from bot.theme import fancy_header, NEON_CYAN, NEON_PURPLE
10
- from bot.emojis import ui
11
- from bot.emojis import ui
12
-
13
-
14
- class ConfigPanelView(discord.ui.View):
15
- def __init__(self, cog: "Configuration") -> None:
16
- super().__init__(timeout=None)
17
- self.cog = cog
18
-
19
- @discord.ui.button(label="Refresh Panel", style=discord.ButtonStyle.blurple, emoji=ui("refresh"), row=3, custom_id="config_refresh")
20
- async def refresh(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
21
- if not interaction.guild:
22
- await interaction.response.send_message("This button works only in servers.", ephemeral=True)
23
- return
24
- embed = await self.cog.build_config_embed(interaction.guild)
25
- await interaction.response.edit_message(embed=embed, view=self)
26
-
27
- @discord.ui.button(label="Toggle AutoMod", style=discord.ButtonStyle.danger, emoji="🛡️", row=0, custom_id="config_automod")
28
- async def toggle_automod(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
29
- if not interaction.guild:
30
- await interaction.response.send_message("Server only.", ephemeral=True)
31
- return
32
- if not interaction.user.guild_permissions.manage_guild:
33
- await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
34
- return
35
- row = await self.cog.bot.db.fetchone("SELECT automod_enabled FROM guild_config WHERE guild_id = ?", interaction.guild.id)
36
- current = row[0] if row else 0
37
- new_val = 0 if current else 1
38
- await self.cog.bot.db.execute(
39
- "INSERT INTO guild_config(guild_id, automod_enabled) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET automod_enabled = excluded.automod_enabled",
40
- interaction.guild.id, new_val,
41
- )
42
- state = "enabled" if new_val else "disabled"
43
- await interaction.response.send_message(f"🛡️ AutoMod {state}.", ephemeral=True)
44
- embed = await self.cog.build_config_embed(interaction.guild)
45
- await interaction.message.edit(embed=embed, view=self)
46
-
47
- @discord.ui.button(label="Toggle Daily", style=discord.ButtonStyle.success, emoji=ui("calendar"), row=0, custom_id="config_daily")
48
- async def toggle_daily(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
49
- if not interaction.guild:
50
- await interaction.response.send_message("Server only.", ephemeral=True)
51
- return
52
- if not interaction.user.guild_permissions.manage_guild:
53
- await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
54
- return
55
- row = await self.cog.bot.db.fetchone("SELECT daily_enabled FROM guild_config WHERE guild_id = ?", interaction.guild.id)
56
- current = row[0] if row else 0
57
- new_val = 0 if current else 1
58
- await self.cog.bot.db.execute(
59
- "INSERT INTO guild_config(guild_id, daily_enabled) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_enabled = excluded.daily_enabled",
60
- interaction.guild.id, new_val,
61
- )
62
- state = "enabled" if new_val else "disabled"
63
- await interaction.response.send_message(f"📅 Daily message {state}.", ephemeral=True)
64
- embed = await self.cog.build_config_embed(interaction.guild)
65
- await interaction.message.edit(embed=embed, view=self)
66
-
67
- @discord.ui.button(label="Toggle Support AI", style=discord.ButtonStyle.primary, emoji=ui("support"), row=0, custom_id="config_support_ai")
68
- async def toggle_support_ai(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
69
- if not interaction.guild:
70
- await interaction.response.send_message("Server only.", ephemeral=True)
71
- return
72
- if not interaction.user.guild_permissions.manage_guild:
73
- await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
74
- return
75
- row = await self.cog.bot.db.fetchone("SELECT support_ai_enabled FROM guild_config WHERE guild_id = ?", interaction.guild.id)
76
- current = row[0] if row else 0
77
- new_val = 0 if current else 1
78
- await self.cog.bot.db.execute(
79
- "INSERT INTO guild_config(guild_id, support_ai_enabled) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET support_ai_enabled = excluded.support_ai_enabled",
80
- interaction.guild.id, new_val,
81
- )
82
- state = "enabled" if new_val else "disabled"
83
- await interaction.response.send_message(f"🛟 Support AI {state}.", ephemeral=True)
84
- embed = await self.cog.build_config_embed(interaction.guild)
85
- await interaction.message.edit(embed=embed, view=self)
86
-
87
- @discord.ui.button(label="Toggle Wisdom", style=discord.ButtonStyle.secondary, emoji=ui("brain"), row=0, custom_id="config_wisdom")
88
- async def toggle_wisdom(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
89
- if not interaction.guild:
90
- await interaction.response.send_message("Server only.", ephemeral=True)
91
- return
92
- if not interaction.user.guild_permissions.manage_guild:
93
- await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
94
- return
95
- row = await self.cog.bot.db.fetchone("SELECT wisdom_enabled FROM guild_config WHERE guild_id = ?", interaction.guild.id)
96
- current = row[0] if row else 0
97
- new_val = 0 if current else 1
98
- await self.cog.bot.db.execute(
99
- "INSERT INTO guild_config(guild_id, wisdom_enabled) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET wisdom_enabled = excluded.wisdom_enabled",
100
- interaction.guild.id, new_val,
101
- )
102
- state = "enabled" if new_val else "disabled"
103
- await interaction.response.send_message(f"🧠 Daily wisdom {state}.", ephemeral=True)
104
- embed = await self.cog.build_config_embed(interaction.guild)
105
- await interaction.message.edit(embed=embed, view=self)
106
-
107
- @discord.ui.button(label="Set Log Channel", style=discord.ButtonStyle.blurple, emoji="📝", row=1, custom_id="config_log")
108
- async def set_log(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
109
- if not interaction.guild:
110
- await interaction.response.send_message("Server only.", ephemeral=True)
111
- return
112
- if not interaction.user.guild_permissions.manage_guild:
113
- await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
114
- return
115
- await interaction.response.send_message(
116
- "Select a text channel for logging:",
117
- view=ChannelSelectModal(self.cog, interaction.guild.id, "log_channel_id", "📝 Log channel set to"),
118
- ephemeral=True,
119
- )
120
-
121
- @discord.ui.button(label="Set Welcome Channel", style=discord.ButtonStyle.blurple, emoji="👋", row=1, custom_id="config_welcome")
122
- async def set_welcome(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
123
- if not interaction.guild:
124
- await interaction.response.send_message("Server only.", ephemeral=True)
125
- return
126
- if not interaction.user.guild_permissions.manage_guild:
127
- await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
128
- return
129
- await interaction.response.send_message(
130
- "Select a text channel for welcomes:",
131
- view=ChannelSelectModal(self.cog, interaction.guild.id, "welcome_channel_id", "👋 Welcome channel set to"),
132
- ephemeral=True,
133
- )
134
-
135
- @discord.ui.button(label="Set Daily Channel", style=discord.ButtonStyle.blurple, emoji="📨", row=1, custom_id="config_daily_ch")
136
- async def set_daily_ch(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
137
- if not interaction.guild:
138
- await interaction.response.send_message("Server only.", ephemeral=True)
139
- return
140
- if not interaction.user.guild_permissions.manage_guild:
141
- await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
142
- return
143
- await interaction.response.send_message(
144
- "Select a text channel for daily messages:",
145
- view=ChannelSelectModal(self.cog, interaction.guild.id, "daily_channel_id", "📨 Daily channel set to"),
146
- ephemeral=True,
147
- )
148
-
149
- @discord.ui.button(label="Set Poll Channel", style=discord.ButtonStyle.blurple, emoji="📊", row=1, custom_id="config_poll_ch")
150
- async def set_poll_ch(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
151
- if not interaction.guild:
152
- await interaction.response.send_message("Server only.", ephemeral=True)
153
- return
154
- if not interaction.user.guild_permissions.manage_guild:
155
- await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
156
- return
157
- await interaction.response.send_message(
158
- "Select a text channel for polls:",
159
- view=ChannelSelectModal(self.cog, interaction.guild.id, "poll_channel_id", "📊 Poll channel set to"),
160
- ephemeral=True,
161
- )
162
-
163
- @discord.ui.button(label="Set Daily Message", style=discord.ButtonStyle.gray, emoji="✏️", row=2, custom_id="config_daily_msg")
164
- async def set_daily_msg(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
165
- if not interaction.guild:
166
- await interaction.response.send_message("Server only.", ephemeral=True)
167
- return
168
- if not interaction.user.guild_permissions.manage_guild:
169
- await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
170
- return
171
- await interaction.response.send_modal(DailyMessageModal(self.cog, interaction.guild.id))
172
-
173
- @discord.ui.button(label="Test Daily", style=discord.ButtonStyle.gray, emoji="🧪", row=2, custom_id="config_test_daily")
174
- async def test_daily(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
175
- if not interaction.guild:
176
- await interaction.response.send_message("Server only.", ephemeral=True)
177
- return
178
- row = await self.cog.bot.db.fetchone(
179
- "SELECT daily_channel_id, daily_message, daily_title, daily_image_url, daily_button_label, daily_button_url FROM guild_config WHERE guild_id = ?",
180
- interaction.guild.id,
181
- )
182
- if not row or not row[0]:
183
- await interaction.response.send_message("Set a daily channel first.", ephemeral=True)
184
- return
185
- channel = interaction.guild.get_channel(row[0])
186
- if not channel:
187
- await interaction.response.send_message("Configured daily channel no longer exists.", ephemeral=True)
188
- return
189
- embed = self.cog._build_daily_embed(interaction.guild.name, row[2] or "", row[1] or "", row[3])
190
- view = None
191
- if row[4] and row[5]:
192
- view = discord.ui.View()
193
- view.add_item(discord.ui.Button(label=row[4], url=row[5]))
194
- await channel.send(embed=embed, view=view)
195
- await interaction.response.send_message(f"✅ Sent test daily message to {channel.mention}", ephemeral=True)
196
-
197
-
198
- class ChannelSelectModal(discord.ui.View):
199
- def __init__(self, cog: "Configuration", guild_id: int, column: str, success_prefix: str) -> None:
200
- super().__init__(timeout=300)
201
- self.cog = cog
202
- self.guild_id = guild_id
203
- self.column = column
204
- self.success_prefix = success_prefix
205
-
206
- self.channel_select = discord.ui.ChannelSelect(
207
- cls=discord.ui.ChannelSelect,
208
- channel_types=[discord.ChannelType.text],
209
- placeholder="Select a text channel...",
210
- min_values=1,
211
- max_values=1,
212
- custom_id="channel_select_modal",
213
- )
214
- self.channel_select.callback = self._channel_select_callback
215
- self.add_item(self.channel_select)
216
-
217
- async def _channel_select_callback(self, interaction: discord.Interaction) -> None:
218
- channel = self.channel_select.values[0]
219
- await self.cog.bot.db.execute(
220
- f"INSERT INTO guild_config(guild_id, {self.column}) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET {self.column} = excluded.{self.column}",
221
- self.guild_id, channel.id,
222
- )
223
- await interaction.response.send_message(f"{self.success_prefix} {channel.mention}", ephemeral=True)
224
-
225
-
226
- class DailyMessageModal(discord.ui.Modal, title="✏️ Set Daily Message"):
227
- message = discord.ui.TextInput(
228
- label="Daily Message Text",
229
- placeholder="Your daily message here...",
230
- required=True,
231
- style=discord.TextStyle.paragraph,
232
- max_length=500,
233
- )
234
-
235
- def __init__(self, cog: "Configuration", guild_id: int) -> None:
236
- super().__init__(timeout=None)
237
- self.cog = cog
238
- self.guild_id = guild_id
239
-
240
- async def on_submit(self, interaction: discord.Interaction) -> None:
241
- await self.cog.bot.db.execute(
242
- "INSERT INTO guild_config(guild_id, daily_message) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_message = excluded.daily_message",
243
- self.guild_id, str(self.message.value),
244
- )
245
- await interaction.response.send_message("🗓️ Daily message text updated.", ephemeral=True)
246
-
247
-
248
-
249
-
250
- class FreeGameSetupView(discord.ui.View):
251
- def __init__(self, cog: "Configuration", guild_id: int, channel_id: int, role_id: int | None = None) -> None:
252
- super().__init__(timeout=300)
253
- self.cog = cog
254
- self.guild_id = guild_id
255
- self.channel_id = channel_id
256
- self.role_id = role_id
257
- self.mention_type = "role"
258
- self.platforms = {"epic", "steam", "gog"}
259
-
260
- async def _save(self, interaction: discord.Interaction) -> None:
261
- await self.cog.bot.db.execute(
262
- "INSERT INTO guild_config(guild_id, free_games_channel_id, free_games_role_id, free_games_platforms, free_games_mention_type) VALUES (?, ?, ?, ?, ?) "
263
- "ON CONFLICT(guild_id) DO UPDATE SET free_games_channel_id=excluded.free_games_channel_id, free_games_role_id=excluded.free_games_role_id, free_games_platforms=excluded.free_games_platforms, free_games_mention_type=excluded.free_games_mention_type",
264
- self.guild_id,
265
- self.channel_id,
266
- self.role_id,
267
- ",".join(sorted(self.platforms)),
268
- self.mention_type,
269
- )
270
-
271
- @discord.ui.select(
272
- placeholder="Choose stores for alerts",
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)",
288
- min_values=0,
289
- max_values=1,
290
- cls=discord.ui.RoleSelect,
291
- )
292
- async def role_select(self, interaction: discord.Interaction, select: discord.ui.RoleSelect) -> None:
293
- role = select.values[0] if select.values else None
294
- self.role_id = role.id if role else None
295
- await self._save(interaction)
296
- if role:
297
- await interaction.response.send_message(f"✅ Free games role set to {role.mention}", ephemeral=True)
298
- else:
299
- await interaction.response.send_message("✅ Free games role cleared.", ephemeral=True)
300
-
301
- @discord.ui.select(
302
- placeholder="Mention style",
303
- min_values=1,
304
- max_values=1,
305
- options=[
306
- discord.SelectOption(label="Role mention", value="role", emoji="🏷️"),
307
- discord.SelectOption(label="@everyone", value="everyone", emoji="📢"),
308
- discord.SelectOption(label="@here", value="here", emoji="📣"),
309
- discord.SelectOption(label="No mention", value="none", emoji="🔕"),
310
- ],
311
- )
312
- async def mention_select(self, interaction: discord.Interaction, select: discord.ui.Select) -> None:
313
- self.mention_type = (select.values[0] if select.values else "role").strip().lower()
314
- await self._save(interaction)
315
- await interaction.response.send_message(f"✅ Mention type set to `{self.mention_type}`", ephemeral=True)
316
-
317
-
318
- class Configuration(commands.Cog):
319
- TIME_RE = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
320
-
321
- def __init__(self, bot: commands.Bot) -> None:
322
- self.bot = bot
323
-
324
- async def cog_load(self) -> None:
325
- self.bot.add_view(ConfigPanelView(self))
326
-
327
- async def build_config_embed(self, guild: discord.Guild) -> discord.Embed:
328
- row = await self.bot.db.fetchone(
329
- "SELECT log_channel_id, welcome_channel_id, suggestion_channel_id, automod_enabled, daily_channel_id, "
330
- "daily_enabled, daily_message, daily_time, daily_utc_offset, verify_channel_id, verify_role_id, "
331
- "daily_title, daily_image_url, daily_button_label, daily_button_url, poll_channel_id, free_games_channel_id, free_games_role_id, "
332
- "support_channel_id, support_ai_enabled, wisdom_channel_id, wisdom_enabled, game_news_channel_id, game_news_role_id "
333
- "FROM guild_config WHERE guild_id = ?",
334
- guild.id,
335
- )
336
-
337
- values = row if row else (None, None, None, 0, None, 0, "Not set", "09:00", 0, None, None, "🌅 Daily Message", None, None, None, None, None, None, None, 0, None, 0, None, None)
338
- (
339
- log_id,
340
- welcome_id,
341
- suggestion_id,
342
- automod_enabled,
343
- daily_channel_id,
344
- daily_enabled,
345
- daily_message,
346
- daily_time,
347
- daily_utc_offset,
348
- verify_channel_id,
349
- verify_role_id,
350
- daily_title,
351
- daily_image_url,
352
- daily_button_label,
353
- daily_button_url,
354
- poll_channel_id,
355
- free_games_channel_id,
356
- free_games_role_id,
357
- support_channel_id,
358
- support_ai_enabled,
359
- wisdom_channel_id,
360
- wisdom_enabled,
361
- game_news_channel_id,
362
- game_news_role_id,
363
- ) = values
364
-
365
- def mention(channel_id: int | None) -> str:
366
- return f"<#{channel_id}>" if channel_id else "Not set"
367
-
368
- embed = discord.Embed(title=fancy_header(f"Settings • {guild.name}"), color=NEON_CYAN)
369
- embed.add_field(name="Log Channel", value=mention(log_id), inline=True)
370
- embed.add_field(name="Welcome Channel", value=mention(welcome_id), inline=True)
371
- embed.add_field(name="Suggestion Channel", value=mention(suggestion_id), inline=True)
372
- embed.add_field(name="Verification Channel", value=mention(verify_channel_id), inline=True)
373
- embed.add_field(name="Verification Role", value=f"<@&{verify_role_id}>" if verify_role_id else "Not set", inline=True)
374
- embed.add_field(name="AutoMod", value="✅ On" if automod_enabled else " Off", inline=True)
375
- embed.add_field(name="Daily Channel", value=mention(daily_channel_id), inline=True)
376
- embed.add_field(name="Daily Enabled", value="✅ On" if daily_enabled else "❌ Off", inline=True)
377
- embed.add_field(name="Daily Time", value=f"`{daily_time or '09:00'}`", inline=True)
378
- embed.add_field(name="Daily UTC Offset", value=f"`UTC{daily_utc_offset:+d}`", inline=True)
379
- embed.add_field(name="Daily Title", value=(daily_title or "🌅 Daily Message")[:128], inline=False)
380
- embed.add_field(name="Daily Message Text", value=(daily_message or "Not set")[:1024], inline=False)
381
- embed.add_field(name="Daily Image URL", value=daily_image_url or "Not set", inline=False)
382
- embed.add_field(name="Daily Button", value=f"{daily_button_label or 'Not set'} | {daily_button_url or 'Not set'}", inline=False)
383
- embed.add_field(name="Poll Channel", value=mention(poll_channel_id), inline=True)
384
- embed.add_field(name="Free Games Channel", value=mention(free_games_channel_id), inline=True)
385
- embed.add_field(name="Free Games Role", value=f"<@&{free_games_role_id}>" if free_games_role_id else "Not set", inline=True)
386
- embed.add_field(name="Support AI Channel", value=mention(support_channel_id), inline=True)
387
- embed.add_field(name="Support AI Enabled", value="✅ On" if support_ai_enabled else "❌ Off", inline=True)
388
- embed.add_field(name="Wisdom Channel", value=mention(wisdom_channel_id), inline=True)
389
- embed.add_field(name="Wisdom Daily", value="✅ On" if wisdom_enabled else "❌ Off", inline=True)
390
- embed.add_field(name="Game News Channel", value=mention(game_news_channel_id), inline=True)
391
- embed.add_field(name="Game News Role", value=f"<@&{game_news_role_id}>" if game_news_role_id else "Not set", inline=True)
392
- embed.set_footer(text="Use /set commands to update Click Refresh to update panel")
393
- return embed
394
-
395
- def _build_daily_embed(self, guild_name: str, title: str, message_text: str, image_url: str | None) -> discord.Embed:
396
- today = dt.datetime.utcnow().date().isoformat()
397
- embed = discord.Embed(
398
- title=title or "🌅 Daily Message",
399
- description=message_text or "Have a productive and positive day!",
400
- color=discord.Color.gold(),
401
- )
402
- if image_url:
403
- embed.set_image(url=image_url)
404
- embed.set_footer(text=f"{guild_name} • {today}")
405
- return embed
406
-
407
- @commands.hybrid_group(name="set", fallback="show")
408
- @commands.has_permissions(manage_guild=True)
409
- async def setgroup(self, ctx: commands.Context) -> None:
410
- embed = await self.build_config_embed(ctx.guild)
411
- await ctx.reply(embed=embed, view=ConfigPanelView(self))
412
-
413
- @setgroup.command(name="log")
414
- async def set_log(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
415
- await self.bot.db.execute(
416
- "INSERT INTO guild_config(guild_id, log_channel_id) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET log_channel_id = excluded.log_channel_id",
417
- ctx.guild.id,
418
- channel.id,
419
- )
420
- await ctx.reply(f"📝 Log channel set to {channel.mention}")
421
-
422
- @setgroup.command(name="welcome")
423
- async def set_welcome(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
424
- await self.bot.db.execute(
425
- "INSERT INTO guild_config(guild_id, welcome_channel_id) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET welcome_channel_id = excluded.welcome_channel_id",
426
- ctx.guild.id,
427
- channel.id,
428
- )
429
- await ctx.reply(f"👋 Welcome channel set to {channel.mention}")
430
-
431
- @setgroup.command(name="suggestions")
432
- async def set_suggestion(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
433
- await self.bot.db.execute(
434
- "INSERT INTO guild_config(guild_id, suggestion_channel_id) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET suggestion_channel_id = excluded.suggestion_channel_id",
435
- ctx.guild.id,
436
- channel.id,
437
- )
438
- await ctx.reply(f"💡 Suggestion channel set to {channel.mention}")
439
-
440
- @setgroup.command(name="automod")
441
- async def set_automod(self, ctx: commands.Context, enabled: bool) -> None:
442
- await self.bot.db.execute(
443
- "INSERT INTO guild_config(guild_id, automod_enabled) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET automod_enabled = excluded.automod_enabled",
444
- ctx.guild.id,
445
- 1 if enabled else 0,
446
- )
447
- await ctx.reply(f"🛡️ AutoMod {'enabled' if enabled else 'disabled'}")
448
-
449
- @setgroup.command(name="dailychannel")
450
- async def set_daily_channel(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
451
- await self.bot.db.execute(
452
- "INSERT INTO guild_config(guild_id, daily_channel_id) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_channel_id = excluded.daily_channel_id",
453
- ctx.guild.id,
454
- channel.id,
455
- )
456
- await ctx.reply(f"📨 Daily message channel set to {channel.mention}")
457
-
458
- @setgroup.command(name="dailymessage")
459
- async def set_daily_message(self, ctx: commands.Context, *, text: str) -> None:
460
- await self.bot.db.execute(
461
- "INSERT INTO guild_config(guild_id, daily_message) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_message = excluded.daily_message",
462
- ctx.guild.id,
463
- text,
464
- )
465
- await ctx.reply("🗓️ Daily message text updated.")
466
-
467
- @setgroup.command(name="dailytitle")
468
- async def set_daily_title(self, ctx: commands.Context, *, title: str) -> None:
469
- await self.bot.db.execute(
470
- "INSERT INTO guild_config(guild_id, daily_title) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_title = excluded.daily_title",
471
- ctx.guild.id,
472
- title,
473
- )
474
- await ctx.reply("🖋️ Daily message title updated.")
475
-
476
- @setgroup.command(name="dailyimage")
477
- async def set_daily_image(self, ctx: commands.Context, image_url: str = "") -> None:
478
- await self.bot.db.execute(
479
- "INSERT INTO guild_config(guild_id, daily_image_url) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_image_url = excluded.daily_image_url",
480
- ctx.guild.id,
481
- image_url or None,
482
- )
483
- await ctx.reply("🖼️ Daily image updated (empty value removes it).")
484
-
485
- @setgroup.command(name="dailybutton")
486
- async def set_daily_button(self, ctx: commands.Context, label: str = "", url: str = "") -> None:
487
- await self.bot.db.execute(
488
- "INSERT INTO guild_config(guild_id, daily_button_label, daily_button_url) VALUES (?, ?, ?) "
489
- "ON CONFLICT(guild_id) DO UPDATE SET daily_button_label = excluded.daily_button_label, daily_button_url = excluded.daily_button_url",
490
- ctx.guild.id,
491
- label or None,
492
- url or None,
493
- )
494
- await ctx.reply("🔗 Daily button settings updated.")
495
-
496
- @setgroup.command(name="dailytime")
497
- async def set_daily_time(self, ctx: commands.Context, hhmm: str) -> None:
498
- hhmm = hhmm.strip()
499
- if not self.TIME_RE.fullmatch(hhmm):
500
- await ctx.reply("Use time in 24h format `HH:MM` (example: `18:30`).")
501
- return
502
- await self.bot.db.execute(
503
- "INSERT INTO guild_config(guild_id, daily_time) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_time = excluded.daily_time",
504
- ctx.guild.id,
505
- hhmm,
506
- )
507
- await ctx.reply(f"⏰ Daily message time set to `{hhmm}`")
508
-
509
- @setgroup.command(name="dailyutc")
510
- async def set_daily_utc(self, ctx: commands.Context, offset: int) -> None:
511
- if offset < -12 or offset > 14:
512
- await ctx.reply("UTC offset must be between `-12` and `+14`.")
513
- return
514
- await self.bot.db.execute(
515
- "INSERT INTO guild_config(guild_id, daily_utc_offset) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_utc_offset = excluded.daily_utc_offset",
516
- ctx.guild.id,
517
- offset,
518
- )
519
- await ctx.reply(f"🌍 Daily timezone set to `UTC{offset:+d}`")
520
-
521
- @setgroup.command(name="dailytest")
522
- async def set_daily_test(self, ctx: commands.Context) -> None:
523
- row = await self.bot.db.fetchone(
524
- "SELECT daily_channel_id, daily_message, daily_title, daily_image_url, daily_button_label, daily_button_url FROM guild_config WHERE guild_id = ?",
525
- ctx.guild.id,
526
- )
527
- if not row or not row[0]:
528
- await ctx.reply("Set a daily channel first with `/set dailychannel`.")
529
- return
530
- channel = ctx.guild.get_channel(row[0])
531
- if not channel:
532
- await ctx.reply("Configured daily channel no longer exists.")
533
- return
534
- embed = self._build_daily_embed(ctx.guild.name, row[2] or "", row[1] or "", row[3])
535
- view = None
536
- if row[4] and row[5]:
537
- view = discord.ui.View()
538
- view.add_item(discord.ui.Button(label=row[4], url=row[5]))
539
- await channel.send(embed=embed, view=view)
540
- await ctx.reply(f"✅ Sent test daily message to {channel.mention}")
541
-
542
- @setgroup.command(name="dailytoggle")
543
- async def set_daily_toggle(self, ctx: commands.Context, enabled: bool) -> None:
544
- await self.bot.db.execute(
545
- "INSERT INTO guild_config(guild_id, daily_enabled) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_enabled = excluded.daily_enabled",
546
- ctx.guild.id,
547
- 1 if enabled else 0,
548
- )
549
- await ctx.reply(f"📅 Daily message {'enabled' if enabled else 'disabled'}.")
550
-
551
-
552
- @setgroup.command(name="pollchannel")
553
- async def set_poll_channel(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
554
- await self.bot.db.execute(
555
- "INSERT INTO guild_config(guild_id, poll_channel_id) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET poll_channel_id = excluded.poll_channel_id",
556
- ctx.guild.id,
557
- channel.id,
558
- )
559
- await ctx.reply(f"📊 Poll channel set to {channel.mention}")
560
-
561
- @setgroup.command(name="freegames")
562
- async def set_free_games_channel(self, ctx: commands.Context, channel: discord.TextChannel, role: discord.Role | None = None) -> None:
563
- await self.bot.db.execute(
564
- "INSERT INTO guild_config(guild_id, free_games_channel_id, free_games_role_id) VALUES (?, ?, ?) "
565
- "ON CONFLICT(guild_id) DO UPDATE SET free_games_channel_id = excluded.free_games_channel_id, free_games_role_id = excluded.free_games_role_id",
566
- ctx.guild.id,
567
- channel.id,
568
- role.id if role else None,
569
- )
570
- mention = f" with role {role.mention}" if role else ""
571
- await ctx.reply(f"🎁 Free games alerts channel set to {channel.mention}{mention}")
572
-
573
- @setgroup.command(name="gamenews")
574
- async def set_game_news(self, ctx: commands.Context, channel: discord.TextChannel, role: discord.Role | None = None) -> None:
575
- await self.bot.db.execute(
576
- "INSERT INTO guild_config(guild_id, game_news_channel_id, game_news_role_id) VALUES (?, ?, ?) "
577
- "ON CONFLICT(guild_id) DO UPDATE SET game_news_channel_id = excluded.game_news_channel_id, game_news_role_id = excluded.game_news_role_id",
578
- ctx.guild.id,
579
- channel.id,
580
- role.id if role else None,
581
- )
582
- mention = f" with role {role.mention}" if role else ""
583
- await ctx.reply(f"📰 Game news alerts channel set to {channel.mention}{mention}")
584
-
585
- @setgroup.command(name="supportai")
586
- async def set_support_ai(self, ctx: commands.Context, channel: discord.TextChannel, enabled: bool = True) -> None:
587
- await self.bot.db.execute(
588
- "INSERT INTO guild_config(guild_id, support_channel_id, support_ai_enabled) VALUES (?, ?, ?) "
589
- "ON CONFLICT(guild_id) DO UPDATE SET support_channel_id = excluded.support_channel_id, support_ai_enabled = excluded.support_ai_enabled",
590
- ctx.guild.id,
591
- channel.id,
592
- 1 if enabled else 0,
593
- )
594
- await ctx.reply(f"🛟 Support AI {'enabled' if enabled else 'disabled'} in {channel.mention}")
595
-
596
- @setgroup.command(name="wisdom")
597
- async def set_wisdom(self, ctx: commands.Context, channel: discord.TextChannel, enabled: bool = True) -> None:
598
- await self.bot.db.execute(
599
- "INSERT INTO guild_config(guild_id, wisdom_channel_id, wisdom_enabled) VALUES (?, ?, ?) "
600
- "ON CONFLICT(guild_id) DO UPDATE SET wisdom_channel_id = excluded.wisdom_channel_id, wisdom_enabled = excluded.wisdom_enabled",
601
- ctx.guild.id,
602
- channel.id,
603
- 1 if enabled else 0,
604
- )
605
- await ctx.reply(f"🧠 Daily wisdom {'enabled' if enabled else 'disabled'} in {channel.mention}")
606
-
607
- @setgroup.command(name="freegame")
608
- async def set_free_game_alias(self, ctx: commands.Context, channel: discord.TextChannel, role: discord.Role | None = None) -> None:
609
- await self.set_free_games_channel(ctx, channel, role)
610
-
611
- @commands.hybrid_command(name="set_freegame", description="Setup free games alerts panel")
612
- @commands.has_permissions(administrator=True)
613
- async def set_freegame_panel(self, ctx: commands.Context, channel: discord.TextChannel, role: discord.Role | None = None) -> None:
614
- await self.bot.db.execute(
615
- "INSERT INTO guild_config(guild_id, free_games_channel_id, free_games_role_id) VALUES (?, ?, ?) "
616
- "ON CONFLICT(guild_id) DO UPDATE SET free_games_channel_id=excluded.free_games_channel_id, free_games_role_id=excluded.free_games_role_id",
617
- ctx.guild.id,
618
- channel.id,
619
- role.id if role else None,
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"
627
- "Use menu below to choose stores (Epic/Steam/GOG)."
628
- ),
629
- color=discord.Color.green(),
630
- )
631
- await ctx.reply(embed=embed, view=view)
632
-
633
-
634
-
635
- async def setup(bot: commands.Bot) -> None:
636
- await bot.add_cog(Configuration(bot))
 
 
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ import re
5
+
6
+ import discord
7
+ from discord.ext import commands
8
+
9
+ from bot.theme import fancy_header, NEON_CYAN, NEON_PURPLE
10
+ from bot.i18n import get_cmd_desc
11
+ from bot.emojis import ui
12
+ from bot.emojis import ui
13
+
14
+
15
+ class ConfigPanelView(discord.ui.View):
16
+ def __init__(self, cog: "Configuration") -> None:
17
+ super().__init__(timeout=None)
18
+ self.cog = cog
19
+
20
+ @discord.ui.button(label="Refresh Panel", style=discord.ButtonStyle.blurple, emoji=ui("refresh"), row=3, custom_id="config_refresh")
21
+ async def refresh(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
22
+ if not interaction.guild:
23
+ await interaction.response.send_message("This button works only in servers.", ephemeral=True)
24
+ return
25
+ embed = await self.cog.build_config_embed(interaction.guild)
26
+ await interaction.response.edit_message(embed=embed, view=self)
27
+
28
+ @discord.ui.button(label="Toggle AutoMod", style=discord.ButtonStyle.danger, emoji="🛡️", row=0, custom_id="config_automod")
29
+ async def toggle_automod(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
30
+ if not interaction.guild:
31
+ await interaction.response.send_message("Server only.", ephemeral=True)
32
+ return
33
+ if not interaction.user.guild_permissions.manage_guild:
34
+ await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
35
+ return
36
+ row = await self.cog.bot.db.fetchone("SELECT automod_enabled FROM guild_config WHERE guild_id = ?", interaction.guild.id)
37
+ current = row[0] if row else 0
38
+ new_val = 0 if current else 1
39
+ await self.cog.bot.db.execute(
40
+ "INSERT INTO guild_config(guild_id, automod_enabled) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET automod_enabled = excluded.automod_enabled",
41
+ interaction.guild.id, new_val,
42
+ )
43
+ state = "enabled" if new_val else "disabled"
44
+ await interaction.response.send_message(f"🛡️ AutoMod {state}.", ephemeral=True)
45
+ embed = await self.cog.build_config_embed(interaction.guild)
46
+ await interaction.message.edit(embed=embed, view=self)
47
+
48
+ @discord.ui.button(label="Toggle Daily", style=discord.ButtonStyle.success, emoji=ui("calendar"), row=0, custom_id="config_daily")
49
+ async def toggle_daily(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
50
+ if not interaction.guild:
51
+ await interaction.response.send_message("Server only.", ephemeral=True)
52
+ return
53
+ if not interaction.user.guild_permissions.manage_guild:
54
+ await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
55
+ return
56
+ row = await self.cog.bot.db.fetchone("SELECT daily_enabled FROM guild_config WHERE guild_id = ?", interaction.guild.id)
57
+ current = row[0] if row else 0
58
+ new_val = 0 if current else 1
59
+ await self.cog.bot.db.execute(
60
+ "INSERT INTO guild_config(guild_id, daily_enabled) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_enabled = excluded.daily_enabled",
61
+ interaction.guild.id, new_val,
62
+ )
63
+ state = "enabled" if new_val else "disabled"
64
+ await interaction.response.send_message(f"📅 Daily message {state}.", ephemeral=True)
65
+ embed = await self.cog.build_config_embed(interaction.guild)
66
+ await interaction.message.edit(embed=embed, view=self)
67
+
68
+ @discord.ui.button(label="Toggle Support AI", style=discord.ButtonStyle.primary, emoji=ui("support"), row=0, custom_id="config_support_ai")
69
+ async def toggle_support_ai(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
70
+ if not interaction.guild:
71
+ await interaction.response.send_message("Server only.", ephemeral=True)
72
+ return
73
+ if not interaction.user.guild_permissions.manage_guild:
74
+ await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
75
+ return
76
+ row = await self.cog.bot.db.fetchone("SELECT support_ai_enabled FROM guild_config WHERE guild_id = ?", interaction.guild.id)
77
+ current = row[0] if row else 0
78
+ new_val = 0 if current else 1
79
+ await self.cog.bot.db.execute(
80
+ "INSERT INTO guild_config(guild_id, support_ai_enabled) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET support_ai_enabled = excluded.support_ai_enabled",
81
+ interaction.guild.id, new_val,
82
+ )
83
+ state = "enabled" if new_val else "disabled"
84
+ await interaction.response.send_message(f"🛟 Support AI {state}.", ephemeral=True)
85
+ embed = await self.cog.build_config_embed(interaction.guild)
86
+ await interaction.message.edit(embed=embed, view=self)
87
+
88
+ @discord.ui.button(label="Toggle Wisdom", style=discord.ButtonStyle.secondary, emoji=ui("brain"), row=0, custom_id="config_wisdom")
89
+ async def toggle_wisdom(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
90
+ if not interaction.guild:
91
+ await interaction.response.send_message("Server only.", ephemeral=True)
92
+ return
93
+ if not interaction.user.guild_permissions.manage_guild:
94
+ await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
95
+ return
96
+ row = await self.cog.bot.db.fetchone("SELECT wisdom_enabled FROM guild_config WHERE guild_id = ?", interaction.guild.id)
97
+ current = row[0] if row else 0
98
+ new_val = 0 if current else 1
99
+ await self.cog.bot.db.execute(
100
+ "INSERT INTO guild_config(guild_id, wisdom_enabled) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET wisdom_enabled = excluded.wisdom_enabled",
101
+ interaction.guild.id, new_val,
102
+ )
103
+ state = "enabled" if new_val else "disabled"
104
+ await interaction.response.send_message(f"🧠 Daily wisdom {state}.", ephemeral=True)
105
+ embed = await self.cog.build_config_embed(interaction.guild)
106
+ await interaction.message.edit(embed=embed, view=self)
107
+
108
+ @discord.ui.button(label="Set Log Channel", style=discord.ButtonStyle.blurple, emoji="📝", row=1, custom_id="config_log")
109
+ async def set_log(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
110
+ if not interaction.guild:
111
+ await interaction.response.send_message("Server only.", ephemeral=True)
112
+ return
113
+ if not interaction.user.guild_permissions.manage_guild:
114
+ await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
115
+ return
116
+ await interaction.response.send_message(
117
+ "Select a text channel for logging:",
118
+ view=ChannelSelectModal(self.cog, interaction.guild.id, "log_channel_id", "📝 Log channel set to"),
119
+ ephemeral=True,
120
+ )
121
+
122
+ @discord.ui.button(label="Set Welcome Channel", style=discord.ButtonStyle.blurple, emoji="👋", row=1, custom_id="config_welcome")
123
+ async def set_welcome(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
124
+ if not interaction.guild:
125
+ await interaction.response.send_message("Server only.", ephemeral=True)
126
+ return
127
+ if not interaction.user.guild_permissions.manage_guild:
128
+ await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
129
+ return
130
+ await interaction.response.send_message(
131
+ "Select a text channel for welcomes:",
132
+ view=ChannelSelectModal(self.cog, interaction.guild.id, "welcome_channel_id", "👋 Welcome channel set to"),
133
+ ephemeral=True,
134
+ )
135
+
136
+ @discord.ui.button(label="Set Daily Channel", style=discord.ButtonStyle.blurple, emoji="📨", row=1, custom_id="config_daily_ch")
137
+ async def set_daily_ch(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
138
+ if not interaction.guild:
139
+ await interaction.response.send_message("Server only.", ephemeral=True)
140
+ return
141
+ if not interaction.user.guild_permissions.manage_guild:
142
+ await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
143
+ return
144
+ await interaction.response.send_message(
145
+ "Select a text channel for daily messages:",
146
+ view=ChannelSelectModal(self.cog, interaction.guild.id, "daily_channel_id", "📨 Daily channel set to"),
147
+ ephemeral=True,
148
+ )
149
+
150
+ @discord.ui.button(label="Set Poll Channel", style=discord.ButtonStyle.blurple, emoji="📊", row=1, custom_id="config_poll_ch")
151
+ async def set_poll_ch(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
152
+ if not interaction.guild:
153
+ await interaction.response.send_message("Server only.", ephemeral=True)
154
+ return
155
+ if not interaction.user.guild_permissions.manage_guild:
156
+ await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
157
+ return
158
+ await interaction.response.send_message(
159
+ "Select a text channel for polls:",
160
+ view=ChannelSelectModal(self.cog, interaction.guild.id, "poll_channel_id", "📊 Poll channel set to"),
161
+ ephemeral=True,
162
+ )
163
+
164
+ @discord.ui.button(label="Set Daily Message", style=discord.ButtonStyle.gray, emoji="✏️", row=2, custom_id="config_daily_msg")
165
+ async def set_daily_msg(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
166
+ if not interaction.guild:
167
+ await interaction.response.send_message("Server only.", ephemeral=True)
168
+ return
169
+ if not interaction.user.guild_permissions.manage_guild:
170
+ await interaction.response.send_message("Manage Server permission required.", ephemeral=True)
171
+ return
172
+ await interaction.response.send_modal(DailyMessageModal(self.cog, interaction.guild.id))
173
+
174
+ @discord.ui.button(label="Test Daily", style=discord.ButtonStyle.gray, emoji="🧪", row=2, custom_id="config_test_daily")
175
+ async def test_daily(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
176
+ if not interaction.guild:
177
+ await interaction.response.send_message("Server only.", ephemeral=True)
178
+ return
179
+ row = await self.cog.bot.db.fetchone(
180
+ "SELECT daily_channel_id, daily_message, daily_title, daily_image_url, daily_button_label, daily_button_url FROM guild_config WHERE guild_id = ?",
181
+ interaction.guild.id,
182
+ )
183
+ if not row or not row[0]:
184
+ await interaction.response.send_message("Set a daily channel first.", ephemeral=True)
185
+ return
186
+ channel = interaction.guild.get_channel(row[0])
187
+ if not channel:
188
+ await interaction.response.send_message("Configured daily channel no longer exists.", ephemeral=True)
189
+ return
190
+ embed = self.cog._build_daily_embed(interaction.guild.name, row[2] or "", row[1] or "", row[3])
191
+ view = None
192
+ if row[4] and row[5]:
193
+ view = discord.ui.View()
194
+ view.add_item(discord.ui.Button(label=row[4], url=row[5]))
195
+ await channel.send(embed=embed, view=view)
196
+ await interaction.response.send_message(f"✅ Sent test daily message to {channel.mention}", ephemeral=True)
197
+
198
+
199
+ class ChannelSelectModal(discord.ui.View):
200
+ def __init__(self, cog: "Configuration", guild_id: int, column: str, success_prefix: str) -> None:
201
+ super().__init__(timeout=300)
202
+ self.cog = cog
203
+ self.guild_id = guild_id
204
+ self.column = column
205
+ self.success_prefix = success_prefix
206
+
207
+ self.channel_select = discord.ui.ChannelSelect(
208
+ cls=discord.ui.ChannelSelect,
209
+ channel_types=[discord.ChannelType.text],
210
+ placeholder="Select a text channel...",
211
+ min_values=1,
212
+ max_values=1,
213
+ custom_id="channel_select_modal",
214
+ )
215
+ self.channel_select.callback = self._channel_select_callback
216
+ self.add_item(self.channel_select)
217
+
218
+ async def _channel_select_callback(self, interaction: discord.Interaction) -> None:
219
+ channel = self.channel_select.values[0]
220
+ await self.cog.bot.db.execute(
221
+ f"INSERT INTO guild_config(guild_id, {self.column}) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET {self.column} = excluded.{self.column}",
222
+ self.guild_id, channel.id,
223
+ )
224
+ await interaction.response.send_message(f"{self.success_prefix} {channel.mention}", ephemeral=True)
225
+
226
+
227
+ class DailyMessageModal(discord.ui.Modal, title="✏️ Set Daily Message"):
228
+ message = discord.ui.TextInput(
229
+ label="Daily Message Text",
230
+ placeholder="Your daily message here...",
231
+ required=True,
232
+ style=discord.TextStyle.paragraph,
233
+ max_length=500,
234
+ )
235
+
236
+ def __init__(self, cog: "Configuration", guild_id: int) -> None:
237
+ super().__init__(timeout=None)
238
+ self.cog = cog
239
+ self.guild_id = guild_id
240
+
241
+ async def on_submit(self, interaction: discord.Interaction) -> None:
242
+ await self.cog.bot.db.execute(
243
+ "INSERT INTO guild_config(guild_id, daily_message) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_message = excluded.daily_message",
244
+ self.guild_id, str(self.message.value),
245
+ )
246
+ await interaction.response.send_message("🗓️ Daily message text updated.", ephemeral=True)
247
+
248
+
249
+
250
+
251
+ class FreeGameSetupView(discord.ui.View):
252
+ def __init__(self, cog: "Configuration", guild_id: int, channel_id: int, role_id: int | None = None) -> None:
253
+ super().__init__(timeout=300)
254
+ self.cog = cog
255
+ self.guild_id = guild_id
256
+ self.channel_id = channel_id
257
+ self.role_id = role_id
258
+ self.mention_type = "role"
259
+ self.platforms = {"epic", "steam", "gog"}
260
+
261
+ async def _save(self, interaction: discord.Interaction) -> None:
262
+ await self.cog.bot.db.execute(
263
+ "INSERT INTO guild_config(guild_id, free_games_channel_id, free_games_role_id, free_games_platforms, free_games_mention_type) VALUES (?, ?, ?, ?, ?) "
264
+ "ON CONFLICT(guild_id) DO UPDATE SET free_games_channel_id=excluded.free_games_channel_id, free_games_role_id=excluded.free_games_role_id, free_games_platforms=excluded.free_games_platforms, free_games_mention_type=excluded.free_games_mention_type",
265
+ self.guild_id,
266
+ self.channel_id,
267
+ self.role_id,
268
+ ",".join(sorted(self.platforms)),
269
+ self.mention_type,
270
+ )
271
+
272
+ @discord.ui.select(
273
+ placeholder="Choose stores for alerts",
274
+ min_values=1,
275
+ max_values=3,
276
+ options=[
277
+ discord.SelectOption(label="Epic Games", value="epic", emoji="<:animatedarrowgreen:1477261279428087979>"),
278
+ discord.SelectOption(label="Steam", value="steam", emoji="<:animatedarrowgreen:1477261279428087979>"),
279
+ discord.SelectOption(label="GOG", value="gog", emoji="<:animatedarrowgreen:1477261279428087979>"),
280
+ ],
281
+ )
282
+ async def stores(self, interaction: discord.Interaction, select: discord.ui.Select) -> None:
283
+ self.platforms = set(select.values)
284
+ await self._save(interaction)
285
+ await interaction.response.send_message("<:animatedarrowgreen:1477261279428087979> Free games stores updated.", ephemeral=True)
286
+
287
+ @discord.ui.select(
288
+ placeholder="Choose ping role (optional)",
289
+ min_values=0,
290
+ max_values=1,
291
+ cls=discord.ui.RoleSelect,
292
+ )
293
+ async def role_select(self, interaction: discord.Interaction, select: discord.ui.RoleSelect) -> None:
294
+ role = select.values[0] if select.values else None
295
+ self.role_id = role.id if role else None
296
+ await self._save(interaction)
297
+ if role:
298
+ await interaction.response.send_message(f"✅ Free games role set to {role.mention}", ephemeral=True)
299
+ else:
300
+ await interaction.response.send_message("✅ Free games role cleared.", ephemeral=True)
301
+
302
+ @discord.ui.select(
303
+ placeholder="Mention style",
304
+ min_values=1,
305
+ max_values=1,
306
+ options=[
307
+ discord.SelectOption(label="Role mention", value="role", emoji="🏷️"),
308
+ discord.SelectOption(label="@everyone", value="everyone", emoji="📢"),
309
+ discord.SelectOption(label="@here", value="here", emoji="📣"),
310
+ discord.SelectOption(label="No mention", value="none", emoji="🔕"),
311
+ ],
312
+ )
313
+ async def mention_select(self, interaction: discord.Interaction, select: discord.ui.Select) -> None:
314
+ self.mention_type = (select.values[0] if select.values else "role").strip().lower()
315
+ await self._save(interaction)
316
+ await interaction.response.send_message(f"✅ Mention type set to `{self.mention_type}`", ephemeral=True)
317
+
318
+
319
+ class Configuration(commands.Cog):
320
+ TIME_RE = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
321
+
322
+ def __init__(self, bot: commands.Bot) -> None:
323
+ self.bot = bot
324
+
325
+ async def cog_load(self) -> None:
326
+ self.bot.add_view(ConfigPanelView(self))
327
+
328
+ async def build_config_embed(self, guild: discord.Guild) -> discord.Embed:
329
+ row = await self.bot.db.fetchone(
330
+ "SELECT log_channel_id, welcome_channel_id, suggestion_channel_id, automod_enabled, daily_channel_id, "
331
+ "daily_enabled, daily_message, daily_time, daily_utc_offset, verify_channel_id, verify_role_id, "
332
+ "daily_title, daily_image_url, daily_button_label, daily_button_url, poll_channel_id, free_games_channel_id, free_games_role_id, "
333
+ "support_channel_id, support_ai_enabled, wisdom_channel_id, wisdom_enabled, game_news_channel_id, game_news_role_id "
334
+ "FROM guild_config WHERE guild_id = ?",
335
+ guild.id,
336
+ )
337
+
338
+ values = row if row else (None, None, None, 0, None, 0, "Not set", "09:00", 0, None, None, "🌅 Daily Message", None, None, None, None, None, None, None, 0, None, 0, None, None)
339
+ (
340
+ log_id,
341
+ welcome_id,
342
+ suggestion_id,
343
+ automod_enabled,
344
+ daily_channel_id,
345
+ daily_enabled,
346
+ daily_message,
347
+ daily_time,
348
+ daily_utc_offset,
349
+ verify_channel_id,
350
+ verify_role_id,
351
+ daily_title,
352
+ daily_image_url,
353
+ daily_button_label,
354
+ daily_button_url,
355
+ poll_channel_id,
356
+ free_games_channel_id,
357
+ free_games_role_id,
358
+ support_channel_id,
359
+ support_ai_enabled,
360
+ wisdom_channel_id,
361
+ wisdom_enabled,
362
+ game_news_channel_id,
363
+ game_news_role_id,
364
+ ) = values
365
+
366
+ def mention(channel_id: int | None) -> str:
367
+ return f"<#{channel_id}>" if channel_id else "Not set"
368
+
369
+ embed = discord.Embed(title=fancy_header(f"Settings {guild.name}"), color=NEON_CYAN)
370
+ embed.add_field(name="Log Channel", value=mention(log_id), inline=True)
371
+ embed.add_field(name="Welcome Channel", value=mention(welcome_id), inline=True)
372
+ embed.add_field(name="Suggestion Channel", value=mention(suggestion_id), inline=True)
373
+ embed.add_field(name="Verification Channel", value=mention(verify_channel_id), inline=True)
374
+ embed.add_field(name="Verification Role", value=f"<@&{verify_role_id}>" if verify_role_id else "Not set", inline=True)
375
+ embed.add_field(name="AutoMod", value="✅ On" if automod_enabled else "❌ Off", inline=True)
376
+ embed.add_field(name="Daily Channel", value=mention(daily_channel_id), inline=True)
377
+ embed.add_field(name="Daily Enabled", value=" On" if daily_enabled else "❌ Off", inline=True)
378
+ embed.add_field(name="Daily Time", value=f"`{daily_time or '09:00'}`", inline=True)
379
+ embed.add_field(name="Daily UTC Offset", value=f"`UTC{daily_utc_offset:+d}`", inline=True)
380
+ embed.add_field(name="Daily Title", value=(daily_title or "🌅 Daily Message")[:128], inline=False)
381
+ embed.add_field(name="Daily Message Text", value=(daily_message or "Not set")[:1024], inline=False)
382
+ embed.add_field(name="Daily Image URL", value=daily_image_url or "Not set", inline=False)
383
+ embed.add_field(name="Daily Button", value=f"{daily_button_label or 'Not set'} | {daily_button_url or 'Not set'}", inline=False)
384
+ embed.add_field(name="Poll Channel", value=mention(poll_channel_id), inline=True)
385
+ embed.add_field(name="Free Games Channel", value=mention(free_games_channel_id), inline=True)
386
+ embed.add_field(name="Free Games Role", value=f"<@&{free_games_role_id}>" if free_games_role_id else "Not set", inline=True)
387
+ embed.add_field(name="Support AI Channel", value=mention(support_channel_id), inline=True)
388
+ embed.add_field(name="Support AI Enabled", value="✅ On" if support_ai_enabled else "❌ Off", inline=True)
389
+ embed.add_field(name="Wisdom Channel", value=mention(wisdom_channel_id), inline=True)
390
+ embed.add_field(name="Wisdom Daily", value="✅ On" if wisdom_enabled else "❌ Off", inline=True)
391
+ embed.add_field(name="Game News Channel", value=mention(game_news_channel_id), inline=True)
392
+ embed.add_field(name="Game News Role", value=f"<@&{game_news_role_id}>" if game_news_role_id else "Not set", inline=True)
393
+ embed.set_footer(text="Use /set commands to update • Click Refresh to update panel")
394
+ return embed
395
+
396
+ def _build_daily_embed(self, guild_name: str, title: str, message_text: str, image_url: str | None) -> discord.Embed:
397
+ today = dt.datetime.utcnow().date().isoformat()
398
+ embed = discord.Embed(
399
+ title=title or "🌅 Daily Message",
400
+ description=message_text or "Have a productive and positive day!",
401
+ color=discord.Color.gold(),
402
+ )
403
+ if image_url:
404
+ embed.set_image(url=image_url)
405
+ embed.set_footer(text=f"{guild_name} • {today}")
406
+ return embed
407
+
408
+ @commands.hybrid_group(name="set", fallback="show")
409
+ @commands.has_permissions(manage_guild=True)
410
+ async def setgroup(self, ctx: commands.Context) -> None:
411
+ embed = await self.build_config_embed(ctx.guild)
412
+ await ctx.reply(embed=embed, view=ConfigPanelView(self))
413
+
414
+ @setgroup.command(name="log")
415
+ async def set_log(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
416
+ await self.bot.db.execute(
417
+ "INSERT INTO guild_config(guild_id, log_channel_id) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET log_channel_id = excluded.log_channel_id",
418
+ ctx.guild.id,
419
+ channel.id,
420
+ )
421
+ await ctx.reply(f"📝 Log channel set to {channel.mention}")
422
+
423
+ @setgroup.command(name="welcome")
424
+ async def set_welcome(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
425
+ await self.bot.db.execute(
426
+ "INSERT INTO guild_config(guild_id, welcome_channel_id) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET welcome_channel_id = excluded.welcome_channel_id",
427
+ ctx.guild.id,
428
+ channel.id,
429
+ )
430
+ await ctx.reply(f"👋 Welcome channel set to {channel.mention}")
431
+
432
+ @setgroup.command(name="suggestions")
433
+ async def set_suggestion(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
434
+ await self.bot.db.execute(
435
+ "INSERT INTO guild_config(guild_id, suggestion_channel_id) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET suggestion_channel_id = excluded.suggestion_channel_id",
436
+ ctx.guild.id,
437
+ channel.id,
438
+ )
439
+ await ctx.reply(f"💡 Suggestion channel set to {channel.mention}")
440
+
441
+ @setgroup.command(name="automod")
442
+ async def set_automod(self, ctx: commands.Context, enabled: bool) -> None:
443
+ await self.bot.db.execute(
444
+ "INSERT INTO guild_config(guild_id, automod_enabled) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET automod_enabled = excluded.automod_enabled",
445
+ ctx.guild.id,
446
+ 1 if enabled else 0,
447
+ )
448
+ await ctx.reply(f"🛡️ AutoMod {'enabled' if enabled else 'disabled'}")
449
+
450
+ @setgroup.command(name="dailychannel")
451
+ async def set_daily_channel(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
452
+ await self.bot.db.execute(
453
+ "INSERT INTO guild_config(guild_id, daily_channel_id) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_channel_id = excluded.daily_channel_id",
454
+ ctx.guild.id,
455
+ channel.id,
456
+ )
457
+ await ctx.reply(f"📨 Daily message channel set to {channel.mention}")
458
+
459
+ @setgroup.command(name="dailymessage")
460
+ async def set_daily_message(self, ctx: commands.Context, *, text: str) -> None:
461
+ await self.bot.db.execute(
462
+ "INSERT INTO guild_config(guild_id, daily_message) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_message = excluded.daily_message",
463
+ ctx.guild.id,
464
+ text,
465
+ )
466
+ await ctx.reply("🗓️ Daily message text updated.")
467
+
468
+ @setgroup.command(name="dailytitle")
469
+ async def set_daily_title(self, ctx: commands.Context, *, title: str) -> None:
470
+ await self.bot.db.execute(
471
+ "INSERT INTO guild_config(guild_id, daily_title) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_title = excluded.daily_title",
472
+ ctx.guild.id,
473
+ title,
474
+ )
475
+ await ctx.reply("🖋️ Daily message title updated.")
476
+
477
+ @setgroup.command(name="dailyimage")
478
+ async def set_daily_image(self, ctx: commands.Context, image_url: str = "") -> None:
479
+ await self.bot.db.execute(
480
+ "INSERT INTO guild_config(guild_id, daily_image_url) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_image_url = excluded.daily_image_url",
481
+ ctx.guild.id,
482
+ image_url or None,
483
+ )
484
+ await ctx.reply("🖼️ Daily image updated (empty value removes it).")
485
+
486
+ @setgroup.command(name="dailybutton")
487
+ async def set_daily_button(self, ctx: commands.Context, label: str = "", url: str = "") -> None:
488
+ await self.bot.db.execute(
489
+ "INSERT INTO guild_config(guild_id, daily_button_label, daily_button_url) VALUES (?, ?, ?) "
490
+ "ON CONFLICT(guild_id) DO UPDATE SET daily_button_label = excluded.daily_button_label, daily_button_url = excluded.daily_button_url",
491
+ ctx.guild.id,
492
+ label or None,
493
+ url or None,
494
+ )
495
+ await ctx.reply("🔗 Daily button settings updated.")
496
+
497
+ @setgroup.command(name="dailytime")
498
+ async def set_daily_time(self, ctx: commands.Context, hhmm: str) -> None:
499
+ hhmm = hhmm.strip()
500
+ if not self.TIME_RE.fullmatch(hhmm):
501
+ await ctx.reply("Use time in 24h format `HH:MM` (example: `18:30`).")
502
+ return
503
+ await self.bot.db.execute(
504
+ "INSERT INTO guild_config(guild_id, daily_time) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_time = excluded.daily_time",
505
+ ctx.guild.id,
506
+ hhmm,
507
+ )
508
+ await ctx.reply(f"⏰ Daily message time set to `{hhmm}`")
509
+
510
+ @setgroup.command(name="dailyutc")
511
+ async def set_daily_utc(self, ctx: commands.Context, offset: int) -> None:
512
+ if offset < -12 or offset > 14:
513
+ await ctx.reply("UTC offset must be between `-12` and `+14`.")
514
+ return
515
+ await self.bot.db.execute(
516
+ "INSERT INTO guild_config(guild_id, daily_utc_offset) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_utc_offset = excluded.daily_utc_offset",
517
+ ctx.guild.id,
518
+ offset,
519
+ )
520
+ await ctx.reply(f"🌍 Daily timezone set to `UTC{offset:+d}`")
521
+
522
+ @setgroup.command(name="dailytest")
523
+ async def set_daily_test(self, ctx: commands.Context) -> None:
524
+ row = await self.bot.db.fetchone(
525
+ "SELECT daily_channel_id, daily_message, daily_title, daily_image_url, daily_button_label, daily_button_url FROM guild_config WHERE guild_id = ?",
526
+ ctx.guild.id,
527
+ )
528
+ if not row or not row[0]:
529
+ await ctx.reply("Set a daily channel first with `/set dailychannel`.")
530
+ return
531
+ channel = ctx.guild.get_channel(row[0])
532
+ if not channel:
533
+ await ctx.reply("Configured daily channel no longer exists.")
534
+ return
535
+ embed = self._build_daily_embed(ctx.guild.name, row[2] or "", row[1] or "", row[3])
536
+ view = None
537
+ if row[4] and row[5]:
538
+ view = discord.ui.View()
539
+ view.add_item(discord.ui.Button(label=row[4], url=row[5]))
540
+ await channel.send(embed=embed, view=view)
541
+ await ctx.reply(f"✅ Sent test daily message to {channel.mention}")
542
+
543
+ @setgroup.command(name="dailytoggle")
544
+ async def set_daily_toggle(self, ctx: commands.Context, enabled: bool) -> None:
545
+ await self.bot.db.execute(
546
+ "INSERT INTO guild_config(guild_id, daily_enabled) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET daily_enabled = excluded.daily_enabled",
547
+ ctx.guild.id,
548
+ 1 if enabled else 0,
549
+ )
550
+ await ctx.reply(f"📅 Daily message {'enabled' if enabled else 'disabled'}.")
551
+
552
+
553
+ @setgroup.command(name="pollchannel")
554
+ async def set_poll_channel(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
555
+ await self.bot.db.execute(
556
+ "INSERT INTO guild_config(guild_id, poll_channel_id) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET poll_channel_id = excluded.poll_channel_id",
557
+ ctx.guild.id,
558
+ channel.id,
559
+ )
560
+ await ctx.reply(f"📊 Poll channel set to {channel.mention}")
561
+
562
+ @setgroup.command(name="freegames")
563
+ async def set_free_games_channel(self, ctx: commands.Context, channel: discord.TextChannel, role: discord.Role | None = None) -> None:
564
+ await self.bot.db.execute(
565
+ "INSERT INTO guild_config(guild_id, free_games_channel_id, free_games_role_id) VALUES (?, ?, ?) "
566
+ "ON CONFLICT(guild_id) DO UPDATE SET free_games_channel_id = excluded.free_games_channel_id, free_games_role_id = excluded.free_games_role_id",
567
+ ctx.guild.id,
568
+ channel.id,
569
+ role.id if role else None,
570
+ )
571
+ mention = f" with role {role.mention}" if role else ""
572
+ await ctx.reply(f"🎁 Free games alerts channel set to {channel.mention}{mention}")
573
+
574
+ @setgroup.command(name="gamenews")
575
+ async def set_game_news(self, ctx: commands.Context, channel: discord.TextChannel, role: discord.Role | None = None) -> None:
576
+ await self.bot.db.execute(
577
+ "INSERT INTO guild_config(guild_id, game_news_channel_id, game_news_role_id) VALUES (?, ?, ?) "
578
+ "ON CONFLICT(guild_id) DO UPDATE SET game_news_channel_id = excluded.game_news_channel_id, game_news_role_id = excluded.game_news_role_id",
579
+ ctx.guild.id,
580
+ channel.id,
581
+ role.id if role else None,
582
+ )
583
+ mention = f" with role {role.mention}" if role else ""
584
+ await ctx.reply(f"📰 Game news alerts channel set to {channel.mention}{mention}")
585
+
586
+ @setgroup.command(name="supportai")
587
+ async def set_support_ai(self, ctx: commands.Context, channel: discord.TextChannel, enabled: bool = True) -> None:
588
+ await self.bot.db.execute(
589
+ "INSERT INTO guild_config(guild_id, support_channel_id, support_ai_enabled) VALUES (?, ?, ?) "
590
+ "ON CONFLICT(guild_id) DO UPDATE SET support_channel_id = excluded.support_channel_id, support_ai_enabled = excluded.support_ai_enabled",
591
+ ctx.guild.id,
592
+ channel.id,
593
+ 1 if enabled else 0,
594
+ )
595
+ await ctx.reply(f"🛟 Support AI {'enabled' if enabled else 'disabled'} in {channel.mention}")
596
+
597
+ @setgroup.command(name="wisdom")
598
+ async def set_wisdom(self, ctx: commands.Context, channel: discord.TextChannel, enabled: bool = True) -> None:
599
+ await self.bot.db.execute(
600
+ "INSERT INTO guild_config(guild_id, wisdom_channel_id, wisdom_enabled) VALUES (?, ?, ?) "
601
+ "ON CONFLICT(guild_id) DO UPDATE SET wisdom_channel_id = excluded.wisdom_channel_id, wisdom_enabled = excluded.wisdom_enabled",
602
+ ctx.guild.id,
603
+ channel.id,
604
+ 1 if enabled else 0,
605
+ )
606
+ await ctx.reply(f"🧠 Daily wisdom {'enabled' if enabled else 'disabled'} in {channel.mention}")
607
+
608
+ @setgroup.command(name="freegame")
609
+ async def set_free_game_alias(self, ctx: commands.Context, channel: discord.TextChannel, role: discord.Role | None = None) -> None:
610
+ await self.set_free_games_channel(ctx, channel, role)
611
+
612
+ @commands.hybrid_command(name="set_freegame", description=get_cmd_desc("commands.tools.set_freegame_desc"))
613
+ @commands.has_permissions(administrator=True)
614
+ async def set_freegame_panel(self, ctx: commands.Context, channel: discord.TextChannel, role: discord.Role | None = None) -> None:
615
+ await self.bot.db.execute(
616
+ "INSERT INTO guild_config(guild_id, free_games_channel_id, free_games_role_id) VALUES (?, ?, ?) "
617
+ "ON CONFLICT(guild_id) DO UPDATE SET free_games_channel_id=excluded.free_games_channel_id, free_games_role_id=excluded.free_games_role_id",
618
+ ctx.guild.id,
619
+ channel.id,
620
+ role.id if role else None,
621
+ )
622
+ view = FreeGameSetupView(self, ctx.guild.id, channel.id, role.id if role else None)
623
+ embed = discord.Embed(
624
+ title="<:animatedarrowgreen:1477261279428087979> Free Games Setup",
625
+ description=(
626
+ f"Channel: {channel.mention}\n"
627
+ f"Role ping: {role.mention if role else 'None'}\n"
628
+ "Use menu below to choose stores (Epic/Steam/GOG)."
629
+ ),
630
+ color=discord.Color.green(),
631
+ )
632
+ await ctx.reply(embed=embed, view=view)
633
+
634
+
635
+
636
+ async def setup(bot: commands.Bot) -> None:
637
+ await bot.add_cog(Configuration(bot))
bot/cogs/developer.py CHANGED
@@ -1,10 +1,16 @@
1
  from __future__ import annotations
2
 
 
3
  import traceback
4
 
5
  import discord
6
  from discord.ext import commands
7
 
 
 
 
 
 
8
 
9
  class Developer(commands.Cog):
10
  def __init__(self, bot: commands.Bot) -> None:
@@ -13,43 +19,140 @@ class Developer(commands.Cog):
13
  async def cog_check(self, ctx: commands.Context) -> bool:
14
  return await self.bot.is_owner(ctx.author)
15
 
16
- @commands.command(name="load", help="Load a cog extension by dotted path.")
17
- async def load(self, ctx: commands.Context, extension: str) -> None:
18
  await self.bot.load_extension(extension)
19
  await ctx.reply(f"Loaded {extension}")
20
 
21
- @commands.command(name="unload", help="Unload a cog extension by dotted path.")
22
- async def unload(self, ctx: commands.Context, extension: str) -> None:
23
  await self.bot.unload_extension(extension)
24
  await ctx.reply(f"Unloaded {extension}")
25
 
26
- @commands.command(name="reload", help="Reload a cog extension by dotted path.")
27
- async def reload(self, ctx: commands.Context, extension: str) -> None:
28
  await self.bot.reload_extension(extension)
29
  await ctx.reply(f"Reloaded {extension}")
30
 
31
- @commands.command(name="sync", help="Sync global slash commands to Discord.")
32
- async def sync(self, ctx: commands.Context) -> None:
33
  synced = await self.bot.tree.sync()
34
  await ctx.reply(f"Synced {len(synced)} app commands")
35
 
36
- @commands.command(name="shutdown", help="Gracefully shut down the bot process.")
37
- async def shutdown(self, ctx: commands.Context) -> None:
38
  await ctx.reply("Shutting down...")
39
  await self.bot.close()
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
- async def _safe_reply(self, ctx: commands.Context, message: str) -> None:
43
- try:
44
- await ctx.reply(message)
45
- except (discord.NotFound, discord.InteractionResponded):
46
- if ctx.channel:
47
- await ctx.channel.send(message)
48
- except discord.HTTPException as exc:
49
- if exc.code not in {10062, 40060}:
50
- raise
51
- if ctx.channel:
52
- await ctx.channel.send(message)
53
 
54
  @commands.Cog.listener()
55
  async def on_command_error(self, ctx: commands.Context, error: Exception) -> None:
 
1
  from __future__ import annotations
2
 
3
+ import re
4
  import traceback
5
 
6
  import discord
7
  from discord.ext import commands
8
 
9
+ from bot.emojis import (
10
+ CUSTOM_EMOJIS, _UI_ALIASES, FALLBACK_EMOJIS, _EMOJI_BOT,
11
+ _EMOJI_ID_RE, _normalize_key
12
+ )
13
+
14
 
15
  class Developer(commands.Cog):
16
  def __init__(self, bot: commands.Bot) -> None:
 
19
  async def cog_check(self, ctx: commands.Context) -> bool:
20
  return await self.bot.is_owner(ctx.author)
21
 
22
+ @commands.command(name="load", help="Load a cog extension by dotted path.")
23
+ async def load(self, ctx: commands.Context, extension: str) -> None:
24
  await self.bot.load_extension(extension)
25
  await ctx.reply(f"Loaded {extension}")
26
 
27
+ @commands.command(name="unload", help="Unload a cog extension by dotted path.")
28
+ async def unload(self, ctx: commands.Context, extension: str) -> None:
29
  await self.bot.unload_extension(extension)
30
  await ctx.reply(f"Unloaded {extension}")
31
 
32
+ @commands.command(name="reload", help="Reload a cog extension by dotted path.")
33
+ async def reload(self, ctx: commands.Context, extension: str) -> None:
34
  await self.bot.reload_extension(extension)
35
  await ctx.reply(f"Reloaded {extension}")
36
 
37
+ @commands.command(name="sync", help="Sync global slash commands to Discord.")
38
+ async def sync(self, ctx: commands.Context) -> None:
39
  synced = await self.bot.tree.sync()
40
  await ctx.reply(f"Synced {len(synced)} app commands")
41
 
42
+ @commands.command(name="shutdown", help="Gracefully shut down the bot process.")
43
+ async def shutdown(self, ctx: commands.Context) -> None:
44
  await ctx.reply("Shutting down...")
45
  await self.bot.close()
46
 
47
+ @commands.hybrid_command(name="emoji_scan", description="Show broken/unresolved custom emojis")
48
+ async def emoji_scan(self, ctx: commands.Context) -> None:
49
+ """Scan all configured custom emojis and show which ones are broken."""
50
+ if not self.bot.is_ready() or not self.bot.user:
51
+ await ctx.reply("⏳ Bot is not ready yet.", ephemeral=True)
52
+ return
53
+
54
+ bot_emojis = {e.id: e for e in self.bot.emojis}
55
+ broken: list[tuple[str, str, str]] = []
56
+ working: list[tuple[str, str]] = []
57
+ total = 0
58
+
59
+ # Scan CUSTOM_EMOJIS from emojies.txt
60
+ for key, value in CUSTOM_EMOJIS.items():
61
+ total += 1
62
+ emoji_id = self._extract_id(value)
63
+ if emoji_id and emoji_id not in bot_emojis:
64
+ broken.append((key, value, "emoji not in bot cache"))
65
+ elif not emoji_id:
66
+ broken.append((key, value, "no valid emoji ID found"))
67
+ else:
68
+ working.append((key, value))
69
+
70
+ # Scan _UI_ALIASES — check if any alias resolves
71
+ checked_aliases: set[str] = set()
72
+ for ui_key, aliases in _UI_ALIASES.items():
73
+ for alias in aliases:
74
+ norm = _normalize_key(alias)
75
+ if norm in checked_aliases:
76
+ continue
77
+ checked_aliases.add(norm)
78
+ total += 1
79
+ alias_value = CUSTOM_EMOJIS.get(norm, "")
80
+ if not alias_value:
81
+ # Alias doesn't map to any configured emoji
82
+ broken.append((f"ui:{ui_key}→{alias}", alias_value or "(none)", "alias has no configured emoji"))
83
+ continue
84
+ aid = self._extract_id(alias_value)
85
+ if aid and aid not in bot_emojis:
86
+ broken.append((f"ui:{ui_key}→{alias}", alias_value, "aliased emoji not in cache"))
87
+
88
+ # Deduplicate broken list by key
89
+ seen_keys: set[str] = set()
90
+ unique_broken: list[tuple[str, str, str]] = []
91
+ for key, value, reason in broken:
92
+ if key not in seen_keys:
93
+ seen_keys.add(key)
94
+ unique_broken.append((key, value, reason))
95
+ broken = unique_broken
96
+
97
+ # Build embed
98
+ color = discord.Color.red() if broken else discord.Color.green()
99
+ embed = discord.Embed(
100
+ title="🔍 Bot Emoji Health Check",
101
+ description=(
102
+ f"**Total configured:** `{total}`\n"
103
+ f"**✅ Working:** `{len(working) + len(checked_aliases) - len(broken)}`\n"
104
+ f"**❌ Broken:** `{len(broken)}`\n"
105
+ f"**Bot cache size:** `{len(bot_emojis)}`"
106
+ ),
107
+ color=color,
108
+ )
109
+
110
+ if broken:
111
+ # Group by reason
112
+ by_reason: dict[str, list[tuple[str, str]]] = {}
113
+ for key, value, reason in broken:
114
+ by_reason.setdefault(reason, []).append((key, value))
115
+
116
+ for reason, items in list(by_reason.items())[:10]: # Limit display
117
+ names = ", ".join(f"`{k}`" for k, v in items[:15])
118
+ if len(items) > 15:
119
+ names += f" (+{len(items) - 15} more)"
120
+ embed.add_field(
121
+ name=f"❌ {reason} ({len(items)})",
122
+ value=names[:1000],
123
+ inline=False,
124
+ )
125
+
126
+ embed.set_footer(text="Run this after the bot joins new servers to refresh emoji cache")
127
+
128
+ if ctx.interaction:
129
+ await ctx.interaction.response.send_message(embed=embed, ephemeral=True)
130
+ else:
131
+ await ctx.reply(embed=embed, ephemeral=True)
132
+
133
+ @staticmethod
134
+ def _extract_id(value: str) -> int | None:
135
+ """Extract numeric emoji ID from a custom emoji tag."""
136
+ if not value:
137
+ return None
138
+ m = _EMOJI_ID_RE.search(value)
139
+ if m:
140
+ return int(m.group(0))
141
+ if value.strip().isdigit():
142
+ return int(value.strip())
143
+ return None
144
 
145
+ async def _safe_reply(self, ctx: commands.Context, message: str) -> None:
146
+ try:
147
+ await ctx.reply(message)
148
+ except (discord.NotFound, discord.InteractionResponded):
149
+ if ctx.channel:
150
+ await ctx.channel.send(message)
151
+ except discord.HTTPException as exc:
152
+ if exc.code not in {10062, 40060}:
153
+ raise
154
+ if ctx.channel:
155
+ await ctx.channel.send(message)
156
 
157
  @commands.Cog.listener()
158
  async def on_command_error(self, ctx: commands.Context, error: Exception) -> None:
bot/cogs/engagement.py CHANGED
The diff for this file is too large to render. See raw diff
 
bot/cogs/events.py CHANGED
The diff for this file is too large to render. See raw diff
 
bot/cogs/fun.py CHANGED
@@ -1,980 +1,981 @@
1
- """
2
- Fun cog: Games, trivia, memes, and entertainment commands.
3
- Enhanced with rich emoji decorations, beautiful formatting, and multi-language support.
4
- """
5
-
6
- import html
7
- import random
8
-
9
- import aiohttp
10
- import discord
11
- from discord.ext import commands
12
-
13
- from bot.cogs.ai_suite import ImperialMotaz
14
- from bot.theme import (
15
- fancy_header, NEON_CYAN, NEON_PINK, NEON_PURPLE, NEON_LIME, NEON_ORANGE, NEON_BLUE,
16
- panel_divider, gaming_embed, success_embed, error_embed, info_embed,
17
- double_line, triple_line, shimmer, pick_neon_color
18
- )
19
- from bot.emojis import (
20
- ui, E_GAMEHUB, E_ARROW_PINK, E_ARROW_GREEN, E_ARROW_PURPLE, E_STAR,
21
- E_TROPHY, E_FIRE, E_SPARKLE, E_DIZZY, E_BOOM, E_ZAP, E_DIAMOND, E_CROWN, E_GEM
22
- )
23
- from bot.utils.shared import fetch_gaming_news, fetch_free_games, store_icon, FreeGameClaimView
24
-
25
-
26
- TRIVIA_BANK: dict[str, dict[str, list[dict[str, object]]]] = {
27
- "en": {
28
- "gaming": [
29
- {"q": "Which studio created The Witcher 3?", "c": ["CD Projekt Red", "Rockstar", "Ubisoft", "Bethesda"], "a": 0},
30
- {"q": "What is the default block in Minecraft?", "c": ["Stone", "Dirt", "Grass Block", "Sand"], "a": 2},
31
- {"q": "In which game would you find Master Chief?", "c": ["Halo", "Destiny", "Call of Duty", "Gears of War"], "a": 0},
32
- {"q": "What year was Fortnite released?", "c": ["2015", "2017", "2018", "2019"], "a": 1},
33
- {"q": "Which company makes the PlayStation?", "c": ["Microsoft", "Nintendo", "Sony", "Sega"], "a": 2},
34
- ],
35
- "movies": [
36
- {"q": "Who directed Inception?", "c": ["Christopher Nolan", "James Cameron", "Denis Villeneuve", "Ridley Scott"], "a": 0},
37
- {"q": "In the MCU, who is Peter Parker?", "c": ["Iron Man", "Spider-Man", "Doctor Strange", "Hawkeye"], "a": 1},
38
- {"q": "What is the highest-grossing film of all time?", "c": ["Avengers: Endgame", "Avatar", "Titanic", "Star Wars"], "a": 1},
39
- {"q": "Who played the Joker in The Dark Knight?", "c": ["Joaquin Phoenix", "Jack Nicholson", "Heath Ledger", "Jared Leto"], "a": 2},
40
- ],
41
- "series": [
42
- {"q": "Which series features Walter White?", "c": ["Narcos", "Breaking Bad", "Dark", "Sherlock"], "a": 1},
43
- {"q": "In Game of Thrones, what is Jon Snow's house at first?", "c": ["Lannister", "Baratheon", "Stark", "Targaryen"], "a": 2},
44
- {"q": "What is the name of the coffee shop in Friends?", "c": ["Central Park", "Central Perk", "Coffee House", "The Brew"], "a": 1},
45
- {"q": "How many seasons does Stranger Things have?", "c": ["3", "4", "5", "6"], "a": 2},
46
- ],
47
- },
48
- "ar": {
49
- "gaming": [
50
- {"q": "أي شركة طورت لعبة The Witcher 3؟", "c": ["CD Projekt Red", "Rockstar", "Ubisoft", "Bethesda"], "a": 0},
51
- {"q": "ما هو البلوك الافتراضي في Minecraft؟", "c": ["Stone", "Dirt", "Grass Block", "Sand"], "a": 2},
52
- {"q": "في أي لعبة تجد Master Chief؟", "c": ["Halo", "Destiny", "Call of Duty", "Gears of War"], "a": 0},
53
- {"q": "متى صدرت Fortnite؟", "c": ["2015", "2017", "2018", "2019"], "a": 1},
54
- ],
55
- "movies": [
56
- {"q": "من مخرج فيلم Inception؟", "c": ["كريستوفر نولان", "جيمس كاميرون", "دينيس فيلنوف", "ريدلي سكوت"], "a": 0},
57
- {"q": "في عالم مارفل، بيتر باركر هو؟", "c": ["Iron Man", "Spider-Man", "Doctor Strange", "Hawkeye"], "a": 1},
58
- {"q": "ما هو أعلى فيلم إيرادات في التاريخ؟", "c": ["Avengers: Endgame", "Avatar", "Titanic", "Star Wars"], "a": 1},
59
- ],
60
- "series": [
61
- {"q": "أي مسلسل يظهر فيه Walter White؟", "c": ["Narcos", "Breaking Bad", "Dark", "Sherlock"], "a": 1},
62
- {"q": "في Game of Thrones، جون سنو كان ضمن أي بيت بالبداية؟", "c": ["Lannister", "Baratheon", "Stark", "Targaryen"], "a": 2},
63
- ],
64
- },
65
- }
66
-
67
-
68
- class TriviaAnswerButton(discord.ui.Button["TriviaView"]):
69
- def __init__(self, label: str, index: int) -> None:
70
- super().__init__(label=label[:80], style=discord.ButtonStyle.secondary)
71
- self.index = index
72
-
73
- async def callback(self, interaction: discord.Interaction) -> None:
74
- await self.view.on_answer(interaction, self.index)
75
-
76
-
77
- class FreeGameClaimView(discord.ui.View):
78
- def __init__(self, url: str) -> None:
79
- super().__init__(timeout=None)
80
- self.add_item(
81
- discord.ui.Button(
82
- label=f"{ui('gift')} Claim Your Prize {ui('gift')}",
83
- style=discord.ButtonStyle.link,
84
- url=url,
85
- )
86
- )
87
-
88
-
89
- class TriviaView(discord.ui.View):
90
- def __init__(self, owner_id: int, correct_index: int, lang: str, cog: "Fun", guild_id: int | None) -> None:
91
- super().__init__(timeout=None)
92
- self.owner_id = owner_id
93
- self.correct_index = correct_index
94
- self.lang = lang
95
- self.answered = False
96
- self.cog = cog
97
- self.guild_id = guild_id
98
-
99
- async def on_answer(self, interaction: discord.Interaction, picked_index: int) -> None:
100
- if interaction.user.id != self.owner_id:
101
- msg = "❌ هذا السؤال لصاحب الأمر فقط." if self.lang == "ar" else "❌ This question is for the command author only."
102
- await interaction.response.send_message(msg, ephemeral=True)
103
- return
104
- if self.answered:
105
- msg = "⚠️ تمت الإجابة بالفعل." if self.lang == "ar" else "⚠️ Already answered."
106
- await interaction.response.send_message(msg, ephemeral=True)
107
- return
108
-
109
- self.answered = True
110
- is_correct = picked_index == self.correct_index
111
- for idx, child in enumerate(self.children):
112
- if isinstance(child, discord.ui.Button):
113
- child.disabled = True
114
- if idx == self.correct_index:
115
- child.style = discord.ButtonStyle.success
116
- elif idx == picked_index and not is_correct:
117
- child.style = discord.ButtonStyle.danger
118
-
119
- if is_correct:
120
- msg = f"✅ إجابة صحيحة! {E_STAR}" if self.lang == "ar" else f"✅ Correct answer! {E_STAR}"
121
- else:
122
- msg = "❌ إجابة خاطئة." if self.lang == "ar" else "❌ Wrong answer."
123
-
124
- await interaction.response.edit_message(view=self)
125
- if is_correct and interaction.guild and self.guild_id:
126
- reward = random.randint(20, 45)
127
- await self.cog.bot.db.execute(
128
- "INSERT INTO user_balance(guild_id, user_id, wallet, bank) VALUES (?, ?, ?, 0) "
129
- "ON CONFLICT(guild_id, user_id) DO UPDATE SET wallet = wallet + excluded.wallet",
130
- self.guild_id,
131
- interaction.user.id,
132
- reward,
133
- )
134
- if self.lang == "ar":
135
- msg += f" 💰 **+{reward}** عملة!"
136
- else:
137
- msg += f" 💰 **+{reward}** coins!"
138
- await interaction.followup.send(msg, ephemeral=True)
139
-
140
-
141
- GAME_HUB_GAMES = [
142
- {"id": "chess", "name": "Chess", "emoji": "♟️", "description": "Strategic board game"},
143
- {"id": "checkers", "name": "Checkers", "emoji": "🔴", "description": "Classic draughts game"},
144
- {"id": "connect4", "name": "Connect 4", "emoji": "🔴", "description": "Drop and connect"},
145
- {"id": "othello", "name": "Othello", "emoji": "", "description": "Reversi strategy"},
146
- {"id": "tictactoe", "name": "TicTacToe", "emoji": "", "description": "X and O battle"},
147
- {"id": "rps", "name": "Rock Paper Scissors", "emoji": "✂️", "description": "Quick decision game"},
148
- {"id": "trivia", "name": "Trivia Challenge", "emoji": "🧠", "description": "Knowledge contest"},
149
- {"id": "guess", "name": "Number Guess", "emoji": "🔢", "description": "Guess 1-10"},
150
- ]
151
-
152
-
153
- class GameHubSelect(discord.ui.Select):
154
- def __init__(self, cog: "Fun", invitee_id: int | None, lang: str = "en") -> None:
155
- self.cog = cog
156
- self.invitee_id = invitee_id
157
- self.lang = lang
158
-
159
- options = []
160
- for game in GAME_HUB_GAMES:
161
- if lang == "ar":
162
- desc = game["description"]
163
- else:
164
- desc = game["description"]
165
- options.append(
166
- discord.SelectOption(
167
- label=game["name"],
168
- value=game["id"],
169
- description=desc,
170
- emoji=game["emoji"]
171
- )
172
- )
173
- super().__init__(
174
- placeholder="🎮 Choose a game",
175
- min_values=1,
176
- max_values=1,
177
- options=options,
178
- custom_id="gamehub_select",
179
- )
180
-
181
- async def callback(self, interaction: discord.Interaction) -> None:
182
- if not interaction.guild:
183
- await interaction.response.send_message("Server only.", ephemeral=True)
184
- return
185
-
186
- guild = interaction.guild
187
- game_id = self.values[0]
188
- invitee = guild.get_member(self.invitee_id) if self.invitee_id else None
189
- guild_id = guild.id
190
- lang = await self.cog.bot.get_guild_language(guild_id)
191
-
192
- category = discord.utils.get(guild.categories, name="🕹️ Game Rooms")
193
- if category is None:
194
- category = await guild.create_category("🕹️ Game Rooms", reason="GameHub temporary rooms")
195
-
196
- overwrites = {
197
- guild.default_role: discord.PermissionOverwrite(view_channel=False),
198
- interaction.user: discord.PermissionOverwrite(view_channel=True, send_messages=True, read_message_history=True),
199
- }
200
- if invitee:
201
- overwrites[invitee] = discord.PermissionOverwrite(view_channel=True, send_messages=True, read_message_history=True)
202
- if guild.me:
203
- overwrites[guild.me] = discord.PermissionOverwrite(view_channel=True, send_messages=True, manage_channels=True)
204
-
205
- room = await guild.create_text_channel(
206
- name=f"game-{game_id}-{interaction.user.name[:8].lower()}",
207
- category=category,
208
- overwrites=overwrites,
209
- reason=f"GameHub room for {interaction.user}",
210
- )
211
-
212
- board_cog = self.cog.bot.get_cog("BoardGames")
213
- try:
214
- if game_id == "tictactoe":
215
- view = TicTacToeView(interaction.user, invitee)
216
- first = interaction.user.mention
217
- second = invitee.mention if invitee else "🤖 Bot"
218
- if lang == "ar":
219
- msg = await room.send(f"❎ تيك تاك تو: {first} (X) ضد {second} (O)\n🎮 الدور: {interaction.user.mention}", view=view)
220
- else:
221
- msg = await room.send(f"❎ TicTacToe: {first} (X) vs {second} (O)\n🎮 Turn: {interaction.user.mention}", view=view)
222
- if invitee is None:
223
- await view._maybe_bot(msg)
224
- elif game_id == "trivia":
225
- # Start trivia game
226
- item = random.choice(TRIVIA_BANK.get(lang, TRIVIA_BANK["en"])["gaming"])
227
- view = TriviaView(owner_id=interaction.user.id, correct_index=int(item["a"]), lang=lang, cog=self.cog, guild_id=guild_id)
228
- for idx, choice in enumerate(item["c"]):
229
- view.add_item(TriviaAnswerButton(str(choice), idx))
230
-
231
- if lang == "ar":
232
- q_title = "🧠 سؤال تراڤيا!"
233
- else:
234
- q_title = "🧠 Trivia Time!"
235
-
236
- embed = discord.Embed(
237
- title=q_title,
238
- description=f"{panel_divider('blue')}\n❓ {str(item['q'])}\n{panel_divider('blue')}",
239
- color=NEON_CYAN,
240
- )
241
- await room.send(embed=embed, view=view)
242
- elif game_id == "rps":
243
- # RPS game in channel
244
- if lang == "ar":
245
- await room.send(f"✂️ حجر ورقة مقص!\nاستخدم `rps rock/paper/scissors` للعب!")
246
- else:
247
- await room.send(f"✂️ Rock Paper Scissors!\nUse `rps rock/paper/scissors` to play!")
248
- elif game_id == "guess":
249
- # Number guessing game
250
- secret = random.randint(1, 10)
251
- if lang == "ar":
252
- await room.send(f"🔢 خمن الرقم من 1 إلى 10!\nاستخدم `/guess <رقم>` للعب!")
253
- else:
254
- await room.send(f"🔢 Guess a number from 1 to 10!\nUse `/guess <number>` to play!")
255
- elif board_cog and game_id in {"chess", "checkers", "connect4", "othello"}:
256
- fake_ctx = type("_Ctx", (), {"guild": guild, "channel": room, "author": interaction.user, "reply": room.send})
257
- opponent = invitee
258
- ok = await board_cog.start_game_session(fake_ctx, game_id, opponent)
259
- if not ok:
260
- if lang == "ar":
261
- await room.send("⚠️ تعذر تشغيل اللعبة. اختر لعبة أخرى.")
262
- else:
263
- await room.send("⚠️ Could not initialize selected board game. Choose another one.")
264
- else:
265
- game_name = next((g["name"] for g in GAME_HUB_GAMES if g["id"] == game_id), game_id.title())
266
- if lang == "ar":
267
- await room.send(f"🎮 **{game_name}** الغرفة جاهزة. العب الآن! {E_FIRE}")
268
- else:
269
- await room.send(f"🎮 **{game_name}** room is ready. Play now! {E_FIRE}")
270
- except Exception:
271
- if lang == "ar":
272
- await room.send("⚠️ تعذر تشغيل هذه اللعبة. جرب إنشاء غرفة أخرى.")
273
- else:
274
- await room.send("⚠️ Could not start this game. Try creating another room.")
275
-
276
- if lang == "ar":
277
- invite_text = (
278
- f"🎮 **{game_id.title()}** الغرفة جاهزة!\n"
279
- f"{panel_divider('purple')}\n"
280
- f"👤 **المضيف:** {interaction.user.mention}"
281
- )
282
- close_text = "🔒 أغلق الغرفة عند الانتهاء 👇"
283
- else:
284
- invite_text = (
285
- f"🎮 **{game_id.title()}** Room Ready!\n"
286
- f"{panel_divider('purple')}\n"
287
- f"👤 **Host:** {interaction.user.mention}"
288
- )
289
- close_text = "🔒 Close the room when done 👇"
290
-
291
- if invitee:
292
- if lang == "ar":
293
- invite_text += f"\n🎯 **مدعو:** {invitee.mention}"
294
- else:
295
- invite_text += f"\n🎯 **Invited:** {invitee.mention}"
296
- invite_text += f"\n\n{close_text}"
297
- await room.send(invite_text, view=RoomControlView(interaction.user.id))
298
-
299
- if lang == "ar":
300
- response_msg = f"✅ تم إنشاء الغرفة: {room.mention} 🎮"
301
- if invitee:
302
- response_msg += f" | تمت دعوة {invitee.mention}"
303
- else:
304
- response_msg = f"✅ Room created: {room.mention} 🎮"
305
- if invitee:
306
- response_msg += f" | Invited {invitee.mention}"
307
- await interaction.response.send_message(response_msg, ephemeral=True)
308
-
309
-
310
- class GameHubView(discord.ui.View):
311
- def __init__(self, cog: "Fun", invitee_id: int | None = None, lang: str = "en") -> None:
312
- super().__init__(timeout=None)
313
- self.add_item(GameHubSelect(cog, invitee_id, lang))
314
-
315
-
316
- class RoomControlView(discord.ui.View):
317
- def __init__(self, owner_id: int) -> None:
318
- super().__init__(timeout=None)
319
- self.owner_id = owner_id
320
-
321
- @discord.ui.button(label="End Game & Close Room", style=discord.ButtonStyle.danger, emoji=ui("lock"), custom_id="room_close")
322
- async def close_room(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
323
- if not isinstance(interaction.channel, discord.TextChannel):
324
- await interaction.response.send_message("Text channel only.", ephemeral=True)
325
- return
326
- if interaction.user.id != self.owner_id and not interaction.user.guild_permissions.manage_channels:
327
- await interaction.response.send_message("❌ Only room owner or admins can close this room.", ephemeral=True)
328
- return
329
- await interaction.response.send_message("🔒 Closing room...", ephemeral=True)
330
- try:
331
- await interaction.channel.delete(reason=f"Game room closed by {interaction.user}")
332
- except Exception:
333
- pass
334
-
335
- @discord.ui.button(label="Invite Friend", style=discord.ButtonStyle.success, emoji=ui("plus"), custom_id="room_invite")
336
- async def invite_friend(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
337
- if interaction.user.id != self.owner_id:
338
- await interaction.response.send_message("❌ Only room owner can invite.", ephemeral=True)
339
- return
340
- await interaction.response.send_message("➕ Use `/gamehub @user` to invite someone to a new game room!", ephemeral=True)
341
-
342
-
343
- class TicTacToeButton(discord.ui.Button["TicTacToeView"]):
344
- def __init__(self, index: int) -> None:
345
- super().__init__(label=" ", style=discord.ButtonStyle.secondary, row=index // 3)
346
- self.index = index
347
-
348
- async def callback(self, interaction: discord.Interaction) -> None:
349
- if self.view:
350
- await self.view.play(interaction, self.index)
351
-
352
-
353
- class TicTacToeView(discord.ui.View):
354
- def __init__(self, player_x: discord.Member, player_o: discord.Member | None) -> None:
355
- super().__init__(timeout=None)
356
- self.player_x = player_x
357
- self.player_o = player_o
358
- self.turn_x = True
359
- self.board = ["" for _ in range(9)]
360
- for i in range(9):
361
- self.add_item(TicTacToeButton(i))
362
-
363
- def _check(self, mark: str) -> bool:
364
- lines = [(0,1,2),(3,4,5),(6,7,8),(0,3,6),(1,4,7),(2,5,8),(0,4,8),(2,4,6)]
365
- return any(self.board[a]==self.board[b]==self.board[c]==mark for a,b,c in lines)
366
-
367
- def _free(self) -> list[int]:
368
- return [i for i,v in enumerate(self.board) if not v]
369
-
370
- async def _maybe_bot(self, message: discord.Message) -> None:
371
- if self.player_o is not None or not self._free() or not self.turn_x:
372
- return
373
- move = random.choice(self._free())
374
- self.board[move] = "O"
375
- btn = self.children[move]
376
- if isinstance(btn, discord.ui.Button):
377
- btn.label = "O"
378
- btn.style = discord.ButtonStyle.danger
379
- btn.disabled = True
380
- self.turn_x = True
381
- if self._check("O"):
382
- for child in self.children:
383
- if isinstance(child, discord.ui.Button):
384
- child.disabled = True
385
- await message.edit(content=f"🤖 Bot won TicTacToe! {E_BOOM}", view=self)
386
- return
387
- if not self._free():
388
- await message.edit(content="🤝 TicTacToe draw!", view=self)
389
- return
390
- await message.edit(content=f"❎ Turn: {self.player_x.mention}", view=self)
391
-
392
- async def play(self, interaction: discord.Interaction, index: int) -> None:
393
- if self.board[index]:
394
- await interaction.response.send_message("This cell is already used.", ephemeral=True)
395
- return
396
- current = self.player_x if self.turn_x else self.player_o
397
- if self.turn_x and interaction.user.id != self.player_x.id:
398
- await interaction.response.send_message("It is X player's turn.", ephemeral=True)
399
- return
400
- if not self.turn_x and self.player_o and interaction.user.id != self.player_o.id:
401
- await interaction.response.send_message("It is O player's turn.", ephemeral=True)
402
- return
403
- mark = "X" if self.turn_x else "O"
404
- self.board[index] = mark
405
- btn = self.children[index]
406
- if isinstance(btn, discord.ui.Button):
407
- btn.label = mark
408
- btn.style = discord.ButtonStyle.success if mark == "X" else discord.ButtonStyle.danger
409
- btn.disabled = True
410
-
411
- if self._check(mark):
412
- for child in self.children:
413
- if isinstance(child, discord.ui.Button):
414
- child.disabled = True
415
- winner = interaction.user.mention if self.player_o else (self.player_x.mention if mark == "X" else "🤖 Bot")
416
- await interaction.response.edit_message(content=f"🏆 TicTacToe Winner: {winner}! {E_TROPHY}", view=self)
417
- return
418
-
419
- if not self._free():
420
- await interaction.response.edit_message(content="🤝 TicTacToe draw!", view=self)
421
- return
422
-
423
- self.turn_x = not self.turn_x
424
- nxt = self.player_x.mention if self.turn_x else (self.player_o.mention if self.player_o else "🤖 Bot")
425
- await interaction.response.edit_message(content=f"🎮 Turn: {nxt}", view=self)
426
- if not self.turn_x and self.player_o is None and interaction.message:
427
- await self._maybe_bot(interaction.message)
428
-
429
-
430
- class Fun(commands.Cog):
431
- """Games and entertainment with beautiful panels and multi-language support."""
432
-
433
- def __init__(self, bot: commands.Bot) -> None:
434
- self.bot = bot
435
-
436
- async def cog_load(self) -> None:
437
- self.bot.add_view(GameHubView(self))
438
-
439
- async def _fetch_trivia_api(self) -> dict[str, object] | None:
440
- url = "https://opentdb.com/api.php?amount=50"
441
- async with aiohttp.ClientSession() as session:
442
- async with session.get(url, timeout=15) as response:
443
- data = await response.json(content_type=None)
444
- results = data.get("results") if isinstance(data, dict) else None
445
- if not isinstance(results, list) or not results:
446
- return None
447
- item = random.choice(results)
448
- question = html.unescape(str(item.get("question", "")).strip())
449
- correct = html.unescape(str(item.get("correct_answer", "")).strip())
450
- wrong = [html.unescape(str(c).strip()) for c in item.get("incorrect_answers", []) if str(c).strip()]
451
- if not question or not correct or not wrong:
452
- return None
453
- choices = wrong[:]
454
- choices.append(correct)
455
- random.shuffle(choices)
456
- answer_index = choices.index(correct)
457
- return {"q": question, "c": choices[:4], "a": answer_index}
458
-
459
- @commands.hybrid_command(name="8ball")
460
- async def eightball(self, ctx: commands.Context, *, question: str) -> None:
461
- guild_id = ctx.guild.id if ctx.guild else None
462
- lang = await self.bot.get_guild_language(guild_id)
463
-
464
- if lang == "ar":
465
- answers = [
466
- "نعم! ✅", "لا ❌", "ربما 🤔", "بالتأكيد! 💯",
467
- "ليس الآن ", "اسأل لاحقاً 🔮", "100% نعم! 🔥", "غير واضح 🌫️"
468
- ]
469
- embed = discord.Embed(
470
- title="🎱 الكرة السحرية",
471
- description=(
472
- f"{panel_divider('pink')}\n"
473
- f"❓ **السؤال:** {question}\n"
474
- f"🎱 **الجواب:** {random.choice(answers)}\n"
475
- f"{panel_divider('pink')}"
476
- ),
477
- color=NEON_PINK,
478
- )
479
- else:
480
- answers = [
481
- "Yes! ✅", "No ❌", "Maybe 🤔", "Definitely! 💯",
482
- "Not now ⏰", "Ask later 🔮", "100% Yes! 🔥", "Unclear 🌫️"
483
- ]
484
- embed = discord.Embed(
485
- title="🎱 Magic 8 Ball",
486
- description=(
487
- f"{panel_divider('pink')}\n"
488
- f"❓ **Question:** {question}\n"
489
- f"🎱 **Answer:** {random.choice(answers)}\n"
490
- f"{panel_divider('pink')}"
491
- ),
492
- color=NEON_PINK,
493
- )
494
- await ctx.reply(embed=embed)
495
-
496
- @commands.hybrid_command(name="meme")
497
- async def meme(self, ctx: commands.Context) -> None:
498
- guild_id = ctx.guild.id if ctx.guild else None
499
- lang = await self.bot.get_guild_language(guild_id)
500
-
501
- url = "https://meme-api.com/gimme"
502
- try:
503
- async with aiohttp.ClientSession() as session:
504
- async with session.get(url, timeout=10) as response:
505
- data = await response.json()
506
- except Exception:
507
- if lang == "ar":
508
- await ctx.reply("⚠️ تعذر تحميل الميم الآن. جرب مرة أخرى! 🔄")
509
- else:
510
- await ctx.reply("⚠️ Could not load meme right now. Try again in a minute! 🔄")
511
- return
512
-
513
- embed = discord.Embed(
514
- title=f"😂 {data.get('title', 'Random Meme')}",
515
- color=NEON_CYAN,
516
- url=data.get("postLink")
517
- )
518
- if data.get("url"):
519
- embed.set_image(url=data["url"])
520
- embed.set_footer(text=f"👍 {data.get('ups', 0)} | r/{data.get('subreddit', 'meme')}")
521
- await ctx.reply(embed=embed)
522
-
523
- @commands.hybrid_command(name="trivia", description="Gaming/Movies/Series trivia | تراڤيا", hidden=True, with_app_command=False)
524
- async def trivia(self, ctx: commands.Context, category: str = "", source: str = "local") -> None:
525
- guild_id = ctx.guild.id if ctx.guild else None
526
- lang = await self.bot.get_guild_language(guild_id)
527
-
528
- normalized_source = source.strip().lower() or "local"
529
- if normalized_source not in {"local", "api"}:
530
- if lang == "ar":
531
- await ctx.reply("❌ المصدر يجب أن يكون `local` أو `api`")
532
- else:
533
- await ctx.reply("❌ Source must be `local` or `api`")
534
- return
535
-
536
- normalized = category.strip().lower()
537
- item: dict[str, object] | None = None
538
- if lang == "ar":
539
- topic_text = "الألعاب • الأفلام • المسلسلات"
540
- else:
541
- topic_text = "Gaming • Movies • Series"
542
-
543
- if normalized_source == "api":
544
- try:
545
- item = await self._fetch_trivia_api()
546
- topic_text = "OpenTDB (50 questions pool)"
547
- except Exception:
548
- item = None
549
-
550
- if item is None:
551
- aliases = {
552
- "gaming": "gaming", "games": "gaming", "game": "gaming", "قيمز": "gaming", "العاب": "gaming",
553
- "movies": "movies", "movie": "movies", "افلام": "movies", "أفلام": "movies",
554
- "series": "series", "show": "series", "shows": "series", "مسلسلات": "series",
555
- }
556
- normalized = aliases.get(normalized, normalized) if normalized else random.choice(["gaming", "movies", "series"])
557
- if normalized not in {"gaming", "movies", "series"}:
558
- if lang == "ar":
559
- await ctx.reply("❌ قسم غير مدعوم. استخدم: gaming أو movies أو series")
560
- else:
561
- await ctx.reply("❌ Unsupported category. Use: gaming, movies, or series")
562
- return
563
-
564
- # Get trivia from appropriate language bank
565
- trivia_bank = TRIVIA_BANK.get(lang, TRIVIA_BANK["en"])
566
- if normalized not in trivia_bank:
567
- trivia_bank = TRIVIA_BANK["en"]
568
- item = random.choice(trivia_bank[normalized])
569
-
570
- view = TriviaView(owner_id=ctx.author.id, correct_index=int(item["a"]), lang=lang, cog=self, guild_id=guild_id)
571
- for idx, choice in enumerate(item["c"]):
572
- view.add_item(TriviaAnswerButton(str(choice), idx))
573
-
574
- if lang == "ar":
575
- title = "🧠 سؤال تراڤيا!"
576
- cat_label = "📁 القسم"
577
- topics_label = "📚 المواضيع"
578
- else:
579
- title = "🧠 Trivia Time!"
580
- cat_label = "📁 Category"
581
- topics_label = "📚 Topics"
582
-
583
- embed = discord.Embed(
584
- title=title,
585
- description=f"{panel_divider('blue')}\n❓ {str(item['q'])}\n{panel_divider('blue')}",
586
- color=NEON_CYAN,
587
- )
588
- cat_value = normalized.title() if normalized else ("General" if normalized_source == "api" else "Random")
589
- embed.add_field(name=cat_label, value=cat_value, inline=True)
590
- embed.add_field(name=topics_label, value=topic_text, inline=True)
591
- await ctx.reply(embed=embed, view=view)
592
-
593
- async def _fetch_gaming_news(self) -> list[dict[str, str]]:
594
- return await fetch_gaming_news()
595
-
596
- async def _fetch_free_games(self) -> list[dict[str, str]]:
597
- return await fetch_free_games()
598
-
599
- @staticmethod
600
- def _store_icon(platform: str) -> str:
601
- return store_icon(platform)
602
-
603
- def _free_game_embed(self, item: dict[str, str]) -> tuple[discord.Embed, discord.ui.View]:
604
- title = item["title"][:120]
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", "")))
624
- return embed, FreeGameClaimView(link)
625
-
626
- @commands.hybrid_command(name="gaming_news", description="Latest gaming news worldwide | آخر أخبار الألعاب")
627
- async def gaming_news(self, ctx: commands.Context) -> None:
628
- guild_id = ctx.guild.id if ctx.guild else None
629
- lang = await self.bot.get_guild_language(guild_id)
630
-
631
- try:
632
- items = await self._fetch_gaming_news()
633
- except Exception:
634
- if lang == "ar":
635
- await ctx.reply("❌ تعذر جلب أخبار الألعاب الآن.")
636
- else:
637
- await ctx.reply("❌ Could not fetch gaming news right now.")
638
- return
639
-
640
- if not items:
641
- if lang == "ar":
642
- await ctx.reply("لا توجد أخبار متاحة الآن.")
643
- else:
644
- await ctx.reply("No gaming news available right now.")
645
- return
646
-
647
- if lang == "ar":
648
- title = "🕹️ آخر أخبار الألعاب عالميًا"
649
- else:
650
- title = "🕹️ Latest Gaming News Worldwide"
651
-
652
- embed = discord.Embed(
653
- title=title,
654
- description=f"{panel_divider('green')}",
655
- color=NEON_CYAN,
656
- )
657
- for item in items:
658
- date_hint = f"\n🗓️ {item['pub_date'][:16]}" if item.get("pub_date") else ""
659
- if lang == "ar":
660
- embed.add_field(name=item["title"][:256], value=f"📰 [اقرأ المقال]({item['link']}){date_hint}", inline=False)
661
- else:
662
- embed.add_field(name=item["title"][:256], value=f"📰 [Read Article]({item['link']}){date_hint}", inline=False)
663
- await ctx.reply(embed=embed)
664
-
665
- @commands.hybrid_command(name="freegames", description="List active free games on Epic/Steam/GOG", with_app_command=False)
666
- async def freegames(self, ctx: commands.Context) -> None:
667
- guild_id = ctx.guild.id if ctx.guild else None
668
- lang = await self.bot.get_guild_language(guild_id)
669
-
670
- try:
671
- items = await self._fetch_free_games()
672
- except Exception:
673
- if lang == "ar":
674
- await ctx.reply("❌ تعذر جلب العروض الآن.")
675
- else:
676
- await ctx.reply("❌ Could not fetch free game offers right now.")
677
- return
678
-
679
- if not items:
680
- if lang == "ar":
681
- await ctx.reply("لا توجد عروض مجانية نشطة الآن.")
682
- else:
683
- await ctx.reply("No active free game offers right now.")
684
- return
685
-
686
- for item in items:
687
- embed, view = self._free_game_embed(item)
688
- await ctx.reply(embed=embed, view=view)
689
-
690
- @commands.hybrid_command(name="free_games", description="Temporary free game offers | عروض الألعاب المجانية")
691
- async def free_games(self, ctx: commands.Context) -> None:
692
- await self.freegames(ctx)
693
-
694
- @commands.hybrid_group(name="gamehub", fallback="panel", description="Interactive game hub with temporary private rooms")
695
- async def gamehub(self, ctx: commands.Context, invite: discord.Member | None = None) -> None:
696
- if not ctx.guild:
697
- await ctx.reply("Server only.")
698
- return
699
-
700
- guild_id = ctx.guild.id
701
- lang = await self.bot.get_guild_language(guild_id)
702
-
703
- if invite and invite.bot:
704
- if lang == "ar":
705
- await ctx.reply("❌ ادعُ عضواً حقيقياً، ليس بوت.")
706
- else:
707
- await ctx.reply("❌ Invite a real member, not a bot.")
708
- return
709
- if invite and invite.id == ctx.author.id:
710
- if lang == "ar":
711
- await ctx.reply("❌ ادعُ عضواً آخر.")
712
- else:
713
- await ctx.reply("❌ Invite another member.")
714
- return
715
-
716
- games_list = " • ".join([f"{g['emoji']} {g['name']}" for g in GAME_HUB_GAMES])
717
-
718
- if lang == "ar":
719
- title = "🎮 مركز الألعاب"
720
- desc = (
721
- f"{panel_divider('pink')}\n"
722
- f"🕹️ اختر لعبة أدناه لإنشاء **غرفة خاصة**.\n"
723
- f"👥 ادعُ صديقاً أو العب ضد البوت.\n"
724
- f"🗑️ الغرفة تُحذف تلقائياً عند إغلاقها.\n"
725
- f"{panel_divider('pink')}\n\n"
726
- f"**الألعاب:** {games_list}"
727
- )
728
- invited_label = "🎯 العضو المدعو"
729
- else:
730
- title = "🎮 Game Hub"
731
- desc = (
732
- f"{panel_divider('pink')}\n"
733
- f"🕹️ Select a game below to create a **private room**.\n"
734
- f"👥 Invite a friend or play solo vs the bot.\n"
735
- f"🗑️ The room auto-deletes when you close it.\n"
736
- f"{panel_divider('pink')}\n\n"
737
- f"**Games:** {games_list}"
738
- )
739
- invited_label = "🎯 Invited Member"
740
-
741
- embed = discord.Embed(
742
- title=title,
743
- description=desc,
744
- color=NEON_CYAN,
745
- )
746
- if invite:
747
- embed.add_field(name=invited_label, value=invite.mention, inline=False)
748
- await ctx.reply(embed=embed, view=GameHubView(self, invite.id if invite else None, lang))
749
-
750
- @gamehub.command(name="xo", description="TicTacToe vs member or bot")
751
- async def gamehub_xo(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
752
- await self.xo(ctx, opponent=opponent)
753
-
754
- @gamehub.command(name="mario", description="Mario/Pac-Man arcade challenge")
755
- async def gamehub_mario(self, ctx: commands.Context) -> None:
756
- await self.mario(ctx)
757
-
758
- @gamehub.command(name="dice", description="Roll dice")
759
- async def gamehub_dice(self, ctx: commands.Context, sides: int = 6) -> None:
760
- await self.dice(ctx, sides=sides)
761
-
762
- @gamehub.command(name="slots", description="Slot machine game")
763
- async def gamehub_slots(self, ctx: commands.Context, bet: int = 50) -> None:
764
- await self.slots(ctx, bet=bet)
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:
772
- guild_id = ctx.guild.id if ctx.guild else None
773
- lang = await self.bot.get_guild_language(guild_id)
774
-
775
- if opponent and opponent.bot:
776
- opponent = None
777
- view = TicTacToeView(ctx.author, opponent)
778
- second = opponent.mention if opponent else "🤖 Bot"
779
-
780
- if lang == "ar":
781
- msg = await ctx.reply(
782
- f"❎ تيك تاك تو: {ctx.author.mention} (X) ضد {second} (O)\n"
783
- f"🎮 الدور: {ctx.author.mention}",
784
- view=view
785
- )
786
- else:
787
- msg = await ctx.reply(
788
- f"❎ TicTacToe: {ctx.author.mention} (X) vs {second} (O)\n"
789
- f"🎮 Turn: {ctx.author.mention}",
790
- view=view
791
- )
792
- if opponent is None:
793
- await view._maybe_bot(msg)
794
-
795
- @commands.hybrid_command(name="choose", hidden=True, with_app_command=False)
796
- async def choose(self, ctx: commands.Context, *, options: str) -> None:
797
- guild_id = ctx.guild.id if ctx.guild else None
798
- lang = await self.bot.get_guild_language(guild_id)
799
-
800
- parts = [p.strip() for p in options.split(",") if p.strip()]
801
- if len(parts) < 2:
802
- if lang == "ar":
803
- await ctx.reply("❌ أدخل خيارين على الأقل مفصولين بفواصل.")
804
- else:
805
- await ctx.reply("❌ Provide at least 2 options separated by commas.")
806
- return
807
-
808
- choice = random.choice(parts)
809
-
810
- if lang == "ar":
811
- embed = discord.Embed(
812
- title="🎲 اختيار عشوائي",
813
- description=(
814
- f"{panel_divider('purple')}\n"
815
- f"📝 الخيارات: {', '.join(parts)}\n"
816
- f"🎯 **المختار:** {choice}\n"
817
- f"{panel_divider('purple')}"
818
- ),
819
- color=NEON_PINK,
820
- )
821
- else:
822
- embed = discord.Embed(
823
- title="🎲 Random Choice",
824
- description=(
825
- f"{panel_divider('purple')}\n"
826
- f"📝 Options: {', '.join(parts)}\n"
827
- f"🎯 **Selected:** {choice}\n"
828
- f"{panel_divider('purple')}"
829
- ),
830
- color=NEON_PINK,
831
- )
832
- await ctx.reply(embed=embed)
833
-
834
- @commands.hybrid_command(name="mario", description="Mario/Pac-Man arcade challenge", hidden=True, with_app_command=False)
835
- async def mario(self, ctx: commands.Context) -> None:
836
- guild_id = ctx.guild.id if ctx.guild else None
837
- lang = await self.bot.get_guild_language(guild_id)
838
-
839
- coins = random.randint(5, 120)
840
- level = random.randint(1, 8)
841
-
842
- if lang == "ar":
843
- embed = discord.Embed(
844
- title="🍄 تحدي الآركيد",
845
- description=(
846
- f"{panel_divider('orange')}\n"
847
- f"🍄 {ctx.author.mention} جمع **{coins}** عملة!\n"
848
- f" تجاوز المرحلة **{level}**!\n"
849
- f"{panel_divider('orange')}"
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",
857
- description=(
858
- f"{panel_divider('orange')}\n"
859
- f"🍄 {ctx.author.mention} collected **{coins}** coins!\n"
860
- f" Cleared level **{level}**!\n"
861
- f"{panel_divider('orange')}"
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
-
869
- @commands.hybrid_command(name="dice", description="Roll dice", hidden=True, with_app_command=False)
870
- async def dice(self, ctx: commands.Context, sides: int = 6) -> None:
871
- """Roll a dice with specified sides."""
872
- guild_id = ctx.guild.id if ctx.guild else None
873
- lang = await self.bot.get_guild_language(guild_id)
874
-
875
- sides = max(2, min(sides, 100))
876
- result = random.randint(1, sides)
877
-
878
- if lang == "ar":
879
- embed = gaming_embed(
880
- "🎲 رمي النرد",
881
- f"🎲 رميت نرد بـ **{sides}** أوجه\n🎯 النتيجة: **{result}**"
882
- )
883
- else:
884
- embed = gaming_embed(
885
- "🎲 Dice Roll",
886
- f"🎲 Rolled a **{sides}**-sided dice\n🎯 Result: **{result}**"
887
- )
888
- await ctx.reply(embed=embed)
889
-
890
- @commands.hybrid_command(name="slots", description="Slot machine game", hidden=True, with_app_command=False)
891
- async def slots(self, ctx: commands.Context, bet: int = 10) -> None:
892
- """Play a slot machine game."""
893
- guild_id = ctx.guild.id if ctx.guild else None
894
- lang = await self.bot.get_guild_language(guild_id)
895
-
896
- bet = max(10, bet)
897
-
898
- # Check balance
899
- row = await self.bot.db.fetchone(
900
- "SELECT wallet FROM user_balance WHERE guild_id = ? AND user_id = ?",
901
- guild_id,
902
- ctx.author.id,
903
- )
904
- wallet = row[0] if row else 0
905
-
906
- if wallet < bet:
907
- if lang == "ar":
908
- await ctx.reply(f"❌ ليس لديك رصيد كافي. محفظتك: **{wallet}** عملة")
909
- else:
910
- await ctx.reply(f"❌ Insufficient balance. Your wallet: **{wallet}** coins")
911
- return
912
-
913
- # Slot symbols
914
- symbols = ["🍒", "🍋", "🍊", "🍇", "✅", "💎", "7️⃣"]
915
- weights = [25, 20, 18, 15, 12, 7, 3] # Weighted probabilities
916
-
917
- # Spin
918
- results = random.choices(symbols, weights=weights, k=3)
919
-
920
- # Calculate winnings
921
- if results[0] == results[1] == results[2]:
922
- # Jackpot!
923
- if results[0] == "7️⃣":
924
- multiplier = 10
925
- elif results[0] == "💎":
926
- multiplier = 7
927
- elif results[0] == "✅":
928
- multiplier = 5
929
- else:
930
- multiplier = 3
931
- winnings = bet * multiplier
932
- elif results[0] == results[1] or results[1] == results[2] or results[0] == results[2]:
933
- winnings = bet * 2
934
- else:
935
- winnings = 0
936
-
937
- # Update balance
938
- if winnings > 0:
939
- await self._add_coins(guild_id, ctx.author.id, winnings - bet)
940
- else:
941
- await self._add_coins(guild_id, ctx.author.id, -bet)
942
-
943
- # Build display
944
- slot_display = " | ".join(results)
945
-
946
- if winnings >= bet * 5:
947
- result_text = f"🎰 **{'جائزة كبرى!' if lang == 'ar' else 'JACKPOT!'}** {E_FIRE}"
948
- elif winnings > 0:
949
- result_text = f"✅ **{'فزت!' if lang == 'ar' else 'You won!'}**"
950
- else:
951
- result_text = f"❌ **{'حظ أوفر' if lang == 'ar' else 'Better luck next time'}**"
952
-
953
- if lang == "ar":
954
- embed = gaming_embed(
955
- "🎰 ماكينة القمار",
956
- f"┌───────┐\n│ {slot_display} │\n└───────┘\n\n{result_text}\n💰 **الرابح:** {winnings} عملة"
957
- )
958
- else:
959
- embed = gaming_embed(
960
- "🎰 Slot Machine",
961
- f"┌───────┐\n│ {slot_display} │\n└───────┘\n\n{result_text}\n💰 **Won:** {winnings} coins"
962
- )
963
- await ctx.reply(embed=embed)
964
-
965
- async def _add_coins(self, guild_id: int, user_id: int, amount: int) -> None:
966
- """Helper to add/remove coins from user balance."""
967
- if amount == 0:
968
- return
969
- await self.bot.db.execute(
970
- "INSERT INTO user_balance(guild_id, user_id, wallet, bank) VALUES (?, ?, ?, 0) "
971
- "ON CONFLICT(guild_id, user_id) DO UPDATE SET wallet = wallet + ?",
972
- guild_id,
973
- user_id,
974
- amount if amount > 0 else 0,
975
- amount,
976
- )
977
-
978
-
979
- async def setup(bot: commands.Bot) -> None:
980
- await bot.add_cog(Fun(bot))
 
 
1
+ """
2
+ Fun cog: Games, trivia, memes, and entertainment commands.
3
+ Enhanced with rich emoji decorations, beautiful formatting, and multi-language support.
4
+ """
5
+
6
+ import html
7
+ import random
8
+
9
+ import aiohttp
10
+ import discord
11
+ from discord.ext import commands
12
+
13
+ from bot.cogs.ai_suite import ImperialMotaz
14
+ from bot.theme import (
15
+ fancy_header, NEON_CYAN, NEON_PINK, NEON_PURPLE, NEON_LIME, NEON_ORANGE, NEON_BLUE,
16
+ panel_divider, gaming_embed, success_embed, error_embed, info_embed,
17
+ double_line, triple_line, shimmer, pick_neon_color
18
+ )
19
+ from bot.i18n import get_cmd_desc
20
+ from bot.emojis import (
21
+ ui, E_GAMEHUB, E_ARROW_PINK, E_ARROW_GREEN, E_ARROW_PURPLE, E_STAR,
22
+ E_TROPHY, E_FIRE, E_SPARKLE, E_DIZZY, E_BOOM, E_ZAP, E_DIAMOND, E_CROWN, E_GEM
23
+ )
24
+ from bot.utils.shared import fetch_gaming_news, fetch_free_games, store_icon, FreeGameClaimView
25
+
26
+
27
+ TRIVIA_BANK: dict[str, dict[str, list[dict[str, object]]]] = {
28
+ "en": {
29
+ "gaming": [
30
+ {"q": "Which studio created The Witcher 3?", "c": ["CD Projekt Red", "Rockstar", "Ubisoft", "Bethesda"], "a": 0},
31
+ {"q": "What is the default block in Minecraft?", "c": ["Stone", "Dirt", "Grass Block", "Sand"], "a": 2},
32
+ {"q": "In which game would you find Master Chief?", "c": ["Halo", "Destiny", "Call of Duty", "Gears of War"], "a": 0},
33
+ {"q": "What year was Fortnite released?", "c": ["2015", "2017", "2018", "2019"], "a": 1},
34
+ {"q": "Which company makes the PlayStation?", "c": ["Microsoft", "Nintendo", "Sony", "Sega"], "a": 2},
35
+ ],
36
+ "movies": [
37
+ {"q": "Who directed Inception?", "c": ["Christopher Nolan", "James Cameron", "Denis Villeneuve", "Ridley Scott"], "a": 0},
38
+ {"q": "In the MCU, who is Peter Parker?", "c": ["Iron Man", "Spider-Man", "Doctor Strange", "Hawkeye"], "a": 1},
39
+ {"q": "What is the highest-grossing film of all time?", "c": ["Avengers: Endgame", "Avatar", "Titanic", "Star Wars"], "a": 1},
40
+ {"q": "Who played the Joker in The Dark Knight?", "c": ["Joaquin Phoenix", "Jack Nicholson", "Heath Ledger", "Jared Leto"], "a": 2},
41
+ ],
42
+ "series": [
43
+ {"q": "Which series features Walter White?", "c": ["Narcos", "Breaking Bad", "Dark", "Sherlock"], "a": 1},
44
+ {"q": "In Game of Thrones, what is Jon Snow's house at first?", "c": ["Lannister", "Baratheon", "Stark", "Targaryen"], "a": 2},
45
+ {"q": "What is the name of the coffee shop in Friends?", "c": ["Central Park", "Central Perk", "Coffee House", "The Brew"], "a": 1},
46
+ {"q": "How many seasons does Stranger Things have?", "c": ["3", "4", "5", "6"], "a": 2},
47
+ ],
48
+ },
49
+ "ar": {
50
+ "gaming": [
51
+ {"q": "أي شركة طورت لعبة The Witcher 3؟", "c": ["CD Projekt Red", "Rockstar", "Ubisoft", "Bethesda"], "a": 0},
52
+ {"q": "ما هو البلوك الافتراضي في Minecraft؟", "c": ["Stone", "Dirt", "Grass Block", "Sand"], "a": 2},
53
+ {"q": "في أي لعبة تجد Master Chief؟", "c": ["Halo", "Destiny", "Call of Duty", "Gears of War"], "a": 0},
54
+ {"q": "متى صدرت Fortnite؟", "c": ["2015", "2017", "2018", "2019"], "a": 1},
55
+ ],
56
+ "movies": [
57
+ {"q": "من مخرج فيلم Inception؟", "c": ["كريستوفر نولان", "جيمس كاميرون", "دينيس فيلنوف", "ريدلي سكوت"], "a": 0},
58
+ {"q": "في عالم مارفل، بيتر باركر هو؟", "c": ["Iron Man", "Spider-Man", "Doctor Strange", "Hawkeye"], "a": 1},
59
+ {"q": "ما هو أعلى فيلم إيرادات في التاريخ؟", "c": ["Avengers: Endgame", "Avatar", "Titanic", "Star Wars"], "a": 1},
60
+ ],
61
+ "series": [
62
+ {"q": "أي مسلسل يظهر فيه Walter White؟", "c": ["Narcos", "Breaking Bad", "Dark", "Sherlock"], "a": 1},
63
+ {"q": "في Game of Thrones، جون سنو كان ضمن أي بيت بالبداية؟", "c": ["Lannister", "Baratheon", "Stark", "Targaryen"], "a": 2},
64
+ ],
65
+ },
66
+ }
67
+
68
+
69
+ class TriviaAnswerButton(discord.ui.Button["TriviaView"]):
70
+ def __init__(self, label: str, index: int) -> None:
71
+ super().__init__(label=label[:80], style=discord.ButtonStyle.secondary)
72
+ self.index = index
73
+
74
+ async def callback(self, interaction: discord.Interaction) -> None:
75
+ await self.view.on_answer(interaction, self.index)
76
+
77
+
78
+ class FreeGameClaimView(discord.ui.View):
79
+ def __init__(self, url: str) -> None:
80
+ super().__init__(timeout=None)
81
+ self.add_item(
82
+ discord.ui.Button(
83
+ label=f"{ui('gift')} Claim Your Prize {ui('gift')}",
84
+ style=discord.ButtonStyle.link,
85
+ url=url,
86
+ )
87
+ )
88
+
89
+
90
+ class TriviaView(discord.ui.View):
91
+ def __init__(self, owner_id: int, correct_index: int, lang: str, cog: "Fun", guild_id: int | None) -> None:
92
+ super().__init__(timeout=None)
93
+ self.owner_id = owner_id
94
+ self.correct_index = correct_index
95
+ self.lang = lang
96
+ self.answered = False
97
+ self.cog = cog
98
+ self.guild_id = guild_id
99
+
100
+ async def on_answer(self, interaction: discord.Interaction, picked_index: int) -> None:
101
+ if interaction.user.id != self.owner_id:
102
+ msg = "❌ هذا السؤال لصاحب الأمر فقط." if self.lang == "ar" else "❌ This question is for the command author only."
103
+ await interaction.response.send_message(msg, ephemeral=True)
104
+ return
105
+ if self.answered:
106
+ msg = "⚠️ تمت الإجابة بالفعل." if self.lang == "ar" else "⚠️ Already answered."
107
+ await interaction.response.send_message(msg, ephemeral=True)
108
+ return
109
+
110
+ self.answered = True
111
+ is_correct = picked_index == self.correct_index
112
+ for idx, child in enumerate(self.children):
113
+ if isinstance(child, discord.ui.Button):
114
+ child.disabled = True
115
+ if idx == self.correct_index:
116
+ child.style = discord.ButtonStyle.success
117
+ elif idx == picked_index and not is_correct:
118
+ child.style = discord.ButtonStyle.danger
119
+
120
+ if is_correct:
121
+ msg = f"✅ إجابة صحيحة! {E_STAR}" if self.lang == "ar" else f"✅ Correct answer! {E_STAR}"
122
+ else:
123
+ msg = "❌ إجابة خاطئة." if self.lang == "ar" else "❌ Wrong answer."
124
+
125
+ await interaction.response.edit_message(view=self)
126
+ if is_correct and interaction.guild and self.guild_id:
127
+ reward = random.randint(20, 45)
128
+ await self.cog.bot.db.execute(
129
+ "INSERT INTO user_balance(guild_id, user_id, wallet, bank) VALUES (?, ?, ?, 0) "
130
+ "ON CONFLICT(guild_id, user_id) DO UPDATE SET wallet = wallet + excluded.wallet",
131
+ self.guild_id,
132
+ interaction.user.id,
133
+ reward,
134
+ )
135
+ if self.lang == "ar":
136
+ msg += f" 💰 **+{reward}** عملة!"
137
+ else:
138
+ msg += f" 💰 **+{reward}** coins!"
139
+ await interaction.followup.send(msg, ephemeral=True)
140
+
141
+
142
+ GAME_HUB_GAMES = [
143
+ {"id": "chess", "name": "Chess", "emoji": "♟️", "description": "Strategic board game"},
144
+ {"id": "checkers", "name": "Checkers", "emoji": "🔴", "description": "Classic draughts game"},
145
+ {"id": "connect4", "name": "Connect 4", "emoji": "🔴", "description": "Drop and connect"},
146
+ {"id": "othello", "name": "Othello", "emoji": "", "description": "Reversi strategy"},
147
+ {"id": "tictactoe", "name": "TicTacToe", "emoji": "", "description": "X and O battle"},
148
+ {"id": "rps", "name": "Rock Paper Scissors", "emoji": "✂️", "description": "Quick decision game"},
149
+ {"id": "trivia", "name": "Trivia Challenge", "emoji": "🧠", "description": "Knowledge contest"},
150
+ {"id": "guess", "name": "Number Guess", "emoji": "🔢", "description": "Guess 1-10"},
151
+ ]
152
+
153
+
154
+ class GameHubSelect(discord.ui.Select):
155
+ def __init__(self, cog: "Fun", invitee_id: int | None, lang: str = "en") -> None:
156
+ self.cog = cog
157
+ self.invitee_id = invitee_id
158
+ self.lang = lang
159
+
160
+ options = []
161
+ for game in GAME_HUB_GAMES:
162
+ if lang == "ar":
163
+ desc = game["description"]
164
+ else:
165
+ desc = game["description"]
166
+ options.append(
167
+ discord.SelectOption(
168
+ label=game["name"],
169
+ value=game["id"],
170
+ description=desc,
171
+ emoji=game["emoji"]
172
+ )
173
+ )
174
+ super().__init__(
175
+ placeholder="🎮 Choose a game",
176
+ min_values=1,
177
+ max_values=1,
178
+ options=options,
179
+ custom_id="gamehub_select",
180
+ )
181
+
182
+ async def callback(self, interaction: discord.Interaction) -> None:
183
+ if not interaction.guild:
184
+ await interaction.response.send_message("Server only.", ephemeral=True)
185
+ return
186
+
187
+ guild = interaction.guild
188
+ game_id = self.values[0]
189
+ invitee = guild.get_member(self.invitee_id) if self.invitee_id else None
190
+ guild_id = guild.id
191
+ lang = await self.cog.bot.get_guild_language(guild_id)
192
+
193
+ category = discord.utils.get(guild.categories, name="🕹️ Game Rooms")
194
+ if category is None:
195
+ category = await guild.create_category("🕹️ Game Rooms", reason="GameHub temporary rooms")
196
+
197
+ overwrites = {
198
+ guild.default_role: discord.PermissionOverwrite(view_channel=False),
199
+ interaction.user: discord.PermissionOverwrite(view_channel=True, send_messages=True, read_message_history=True),
200
+ }
201
+ if invitee:
202
+ overwrites[invitee] = discord.PermissionOverwrite(view_channel=True, send_messages=True, read_message_history=True)
203
+ if guild.me:
204
+ overwrites[guild.me] = discord.PermissionOverwrite(view_channel=True, send_messages=True, manage_channels=True)
205
+
206
+ room = await guild.create_text_channel(
207
+ name=f"game-{game_id}-{interaction.user.name[:8].lower()}",
208
+ category=category,
209
+ overwrites=overwrites,
210
+ reason=f"GameHub room for {interaction.user}",
211
+ )
212
+
213
+ board_cog = self.cog.bot.get_cog("BoardGames")
214
+ try:
215
+ if game_id == "tictactoe":
216
+ view = TicTacToeView(interaction.user, invitee)
217
+ first = interaction.user.mention
218
+ second = invitee.mention if invitee else "🤖 Bot"
219
+ if lang == "ar":
220
+ msg = await room.send(f"❎ تيك تاك تو: {first} (X) ضد {second} (O)\n🎮 الدور: {interaction.user.mention}", view=view)
221
+ else:
222
+ msg = await room.send(f"❎ TicTacToe: {first} (X) vs {second} (O)\n🎮 Turn: {interaction.user.mention}", view=view)
223
+ if invitee is None:
224
+ await view._maybe_bot(msg)
225
+ elif game_id == "trivia":
226
+ # Start trivia game
227
+ item = random.choice(TRIVIA_BANK.get(lang, TRIVIA_BANK["en"])["gaming"])
228
+ view = TriviaView(owner_id=interaction.user.id, correct_index=int(item["a"]), lang=lang, cog=self.cog, guild_id=guild_id)
229
+ for idx, choice in enumerate(item["c"]):
230
+ view.add_item(TriviaAnswerButton(str(choice), idx))
231
+
232
+ if lang == "ar":
233
+ q_title = "🧠 سؤال تراڤيا!"
234
+ else:
235
+ q_title = "🧠 Trivia Time!"
236
+
237
+ embed = discord.Embed(
238
+ title=q_title,
239
+ description=f"{panel_divider('blue')}\n❓ {str(item['q'])}\n{panel_divider('blue')}",
240
+ color=NEON_CYAN,
241
+ )
242
+ await room.send(embed=embed, view=view)
243
+ elif game_id == "rps":
244
+ # RPS game in channel
245
+ if lang == "ar":
246
+ await room.send(f"✂️ حجر ورقة مقص!\nاستخدم `rps rock/paper/scissors` للعب!")
247
+ else:
248
+ await room.send(f"✂️ Rock Paper Scissors!\nUse `rps rock/paper/scissors` to play!")
249
+ elif game_id == "guess":
250
+ # Number guessing game
251
+ secret = random.randint(1, 10)
252
+ if lang == "ar":
253
+ await room.send(f"🔢 خمن الرقم من 1 إلى 10!\nاستخدم `/guess <رقم>` للعب!")
254
+ else:
255
+ await room.send(f"🔢 Guess a number from 1 to 10!\nUse `/guess <number>` to play!")
256
+ elif board_cog and game_id in {"chess", "checkers", "connect4", "othello"}:
257
+ fake_ctx = type("_Ctx", (), {"guild": guild, "channel": room, "author": interaction.user, "reply": room.send})
258
+ opponent = invitee
259
+ ok = await board_cog.start_game_session(fake_ctx, game_id, opponent)
260
+ if not ok:
261
+ if lang == "ar":
262
+ await room.send("⚠️ تعذر تشغيل اللعبة. اختر لعبة أخرى.")
263
+ else:
264
+ await room.send("⚠️ Could not initialize selected board game. Choose another one.")
265
+ else:
266
+ game_name = next((g["name"] for g in GAME_HUB_GAMES if g["id"] == game_id), game_id.title())
267
+ if lang == "ar":
268
+ await room.send(f"🎮 **{game_name}** الغرفة جاهزة. العب الآن! {E_FIRE}")
269
+ else:
270
+ await room.send(f"🎮 **{game_name}** room is ready. Play now! {E_FIRE}")
271
+ except Exception:
272
+ if lang == "ar":
273
+ await room.send("⚠️ تعذر تشغيل هذه اللعبة. جرب إنشاء غرفة أخرى.")
274
+ else:
275
+ await room.send("⚠️ Could not start this game. Try creating another room.")
276
+
277
+ if lang == "ar":
278
+ invite_text = (
279
+ f"🎮 **{game_id.title()}** الغرفة جاهزة!\n"
280
+ f"{panel_divider('purple')}\n"
281
+ f"👤 **المضيف:** {interaction.user.mention}"
282
+ )
283
+ close_text = "🔒 أغلق الغرفة عند الانتهاء 👇"
284
+ else:
285
+ invite_text = (
286
+ f"🎮 **{game_id.title()}** Room Ready!\n"
287
+ f"{panel_divider('purple')}\n"
288
+ f"👤 **Host:** {interaction.user.mention}"
289
+ )
290
+ close_text = "🔒 Close the room when done 👇"
291
+
292
+ if invitee:
293
+ if lang == "ar":
294
+ invite_text += f"\n🎯 **مدعو:** {invitee.mention}"
295
+ else:
296
+ invite_text += f"\n🎯 **Invited:** {invitee.mention}"
297
+ invite_text += f"\n\n{close_text}"
298
+ await room.send(invite_text, view=RoomControlView(interaction.user.id))
299
+
300
+ if lang == "ar":
301
+ response_msg = f"✅ تم إنشاء الغرفة: {room.mention} 🎮"
302
+ if invitee:
303
+ response_msg += f" | تمت دعوة {invitee.mention}"
304
+ else:
305
+ response_msg = f"✅ Room created: {room.mention} 🎮"
306
+ if invitee:
307
+ response_msg += f" | Invited {invitee.mention}"
308
+ await interaction.response.send_message(response_msg, ephemeral=True)
309
+
310
+
311
+ class GameHubView(discord.ui.View):
312
+ def __init__(self, cog: "Fun", invitee_id: int | None = None, lang: str = "en") -> None:
313
+ super().__init__(timeout=None)
314
+ self.add_item(GameHubSelect(cog, invitee_id, lang))
315
+
316
+
317
+ class RoomControlView(discord.ui.View):
318
+ def __init__(self, owner_id: int) -> None:
319
+ super().__init__(timeout=None)
320
+ self.owner_id = owner_id
321
+
322
+ @discord.ui.button(label="End Game & Close Room", style=discord.ButtonStyle.danger, emoji=ui("lock"), custom_id="room_close")
323
+ async def close_room(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
324
+ if not isinstance(interaction.channel, discord.TextChannel):
325
+ await interaction.response.send_message("Text channel only.", ephemeral=True)
326
+ return
327
+ if interaction.user.id != self.owner_id and not interaction.user.guild_permissions.manage_channels:
328
+ await interaction.response.send_message("❌ Only room owner or admins can close this room.", ephemeral=True)
329
+ return
330
+ await interaction.response.send_message("🔒 Closing room...", ephemeral=True)
331
+ try:
332
+ await interaction.channel.delete(reason=f"Game room closed by {interaction.user}")
333
+ except Exception:
334
+ pass
335
+
336
+ @discord.ui.button(label="Invite Friend", style=discord.ButtonStyle.success, emoji=ui("plus"), custom_id="room_invite")
337
+ async def invite_friend(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
338
+ if interaction.user.id != self.owner_id:
339
+ await interaction.response.send_message("❌ Only room owner can invite.", ephemeral=True)
340
+ return
341
+ await interaction.response.send_message("➕ Use `/gamehub @user` to invite someone to a new game room!", ephemeral=True)
342
+
343
+
344
+ class TicTacToeButton(discord.ui.Button["TicTacToeView"]):
345
+ def __init__(self, index: int) -> None:
346
+ super().__init__(label=" ", style=discord.ButtonStyle.secondary, row=index // 3)
347
+ self.index = index
348
+
349
+ async def callback(self, interaction: discord.Interaction) -> None:
350
+ if self.view:
351
+ await self.view.play(interaction, self.index)
352
+
353
+
354
+ class TicTacToeView(discord.ui.View):
355
+ def __init__(self, player_x: discord.Member, player_o: discord.Member | None) -> None:
356
+ super().__init__(timeout=None)
357
+ self.player_x = player_x
358
+ self.player_o = player_o
359
+ self.turn_x = True
360
+ self.board = ["" for _ in range(9)]
361
+ for i in range(9):
362
+ self.add_item(TicTacToeButton(i))
363
+
364
+ def _check(self, mark: str) -> bool:
365
+ lines = [(0,1,2),(3,4,5),(6,7,8),(0,3,6),(1,4,7),(2,5,8),(0,4,8),(2,4,6)]
366
+ return any(self.board[a]==self.board[b]==self.board[c]==mark for a,b,c in lines)
367
+
368
+ def _free(self) -> list[int]:
369
+ return [i for i,v in enumerate(self.board) if not v]
370
+
371
+ async def _maybe_bot(self, message: discord.Message) -> None:
372
+ if self.player_o is not None or not self._free() or not self.turn_x:
373
+ return
374
+ move = random.choice(self._free())
375
+ self.board[move] = "O"
376
+ btn = self.children[move]
377
+ if isinstance(btn, discord.ui.Button):
378
+ btn.label = "O"
379
+ btn.style = discord.ButtonStyle.danger
380
+ btn.disabled = True
381
+ self.turn_x = True
382
+ if self._check("O"):
383
+ for child in self.children:
384
+ if isinstance(child, discord.ui.Button):
385
+ child.disabled = True
386
+ await message.edit(content=f"🤖 Bot won TicTacToe! {E_BOOM}", view=self)
387
+ return
388
+ if not self._free():
389
+ await message.edit(content="🤝 TicTacToe draw!", view=self)
390
+ return
391
+ await message.edit(content=f"❎ Turn: {self.player_x.mention}", view=self)
392
+
393
+ async def play(self, interaction: discord.Interaction, index: int) -> None:
394
+ if self.board[index]:
395
+ await interaction.response.send_message("This cell is already used.", ephemeral=True)
396
+ return
397
+ current = self.player_x if self.turn_x else self.player_o
398
+ if self.turn_x and interaction.user.id != self.player_x.id:
399
+ await interaction.response.send_message("It is X player's turn.", ephemeral=True)
400
+ return
401
+ if not self.turn_x and self.player_o and interaction.user.id != self.player_o.id:
402
+ await interaction.response.send_message("It is O player's turn.", ephemeral=True)
403
+ return
404
+ mark = "X" if self.turn_x else "O"
405
+ self.board[index] = mark
406
+ btn = self.children[index]
407
+ if isinstance(btn, discord.ui.Button):
408
+ btn.label = mark
409
+ btn.style = discord.ButtonStyle.success if mark == "X" else discord.ButtonStyle.danger
410
+ btn.disabled = True
411
+
412
+ if self._check(mark):
413
+ for child in self.children:
414
+ if isinstance(child, discord.ui.Button):
415
+ child.disabled = True
416
+ winner = interaction.user.mention if self.player_o else (self.player_x.mention if mark == "X" else "🤖 Bot")
417
+ await interaction.response.edit_message(content=f"🏆 TicTacToe Winner: {winner}! {E_TROPHY}", view=self)
418
+ return
419
+
420
+ if not self._free():
421
+ await interaction.response.edit_message(content="🤝 TicTacToe draw!", view=self)
422
+ return
423
+
424
+ self.turn_x = not self.turn_x
425
+ nxt = self.player_x.mention if self.turn_x else (self.player_o.mention if self.player_o else "🤖 Bot")
426
+ await interaction.response.edit_message(content=f"🎮 Turn: {nxt}", view=self)
427
+ if not self.turn_x and self.player_o is None and interaction.message:
428
+ await self._maybe_bot(interaction.message)
429
+
430
+
431
+ class Fun(commands.Cog):
432
+ """Games and entertainment with beautiful panels and multi-language support."""
433
+
434
+ def __init__(self, bot: commands.Bot) -> None:
435
+ self.bot = bot
436
+
437
+ async def cog_load(self) -> None:
438
+ self.bot.add_view(GameHubView(self))
439
+
440
+ async def _fetch_trivia_api(self) -> dict[str, object] | None:
441
+ url = "https://opentdb.com/api.php?amount=50"
442
+ async with aiohttp.ClientSession() as session:
443
+ async with session.get(url, timeout=15) as response:
444
+ data = await response.json(content_type=None)
445
+ results = data.get("results") if isinstance(data, dict) else None
446
+ if not isinstance(results, list) or not results:
447
+ return None
448
+ item = random.choice(results)
449
+ question = html.unescape(str(item.get("question", "")).strip())
450
+ correct = html.unescape(str(item.get("correct_answer", "")).strip())
451
+ wrong = [html.unescape(str(c).strip()) for c in item.get("incorrect_answers", []) if str(c).strip()]
452
+ if not question or not correct or not wrong:
453
+ return None
454
+ choices = wrong[:]
455
+ choices.append(correct)
456
+ random.shuffle(choices)
457
+ answer_index = choices.index(correct)
458
+ return {"q": question, "c": choices[:4], "a": answer_index}
459
+
460
+ @commands.hybrid_command(name="8ball", description=get_cmd_desc("commands.tools.8ball_desc"))
461
+ async def eightball(self, ctx: commands.Context, *, question: str) -> None:
462
+ guild_id = ctx.guild.id if ctx.guild else None
463
+ lang = await self.bot.get_guild_language(guild_id)
464
+
465
+ if lang == "ar":
466
+ answers = [
467
+ عم! ", "لا ", "ربما 🤔", "بالتأكيد! 💯",
468
+ "ليس الآن ⏰", "اسأل لاحقاً 🔮", "100% نعم! 🔥", "غير واضح 🌫️"
469
+ ]
470
+ embed = discord.Embed(
471
+ title="🎱 الكرة السحرية",
472
+ description=(
473
+ f"{panel_divider('pink')}\n"
474
+ f" **السؤال:** {question}\n"
475
+ f"🎱 **الجواب:** {random.choice(answers)}\n"
476
+ f"{panel_divider('pink')}"
477
+ ),
478
+ color=NEON_PINK,
479
+ )
480
+ else:
481
+ answers = [
482
+ "Yes! ", "No ", "Maybe 🤔", "Definitely! 💯",
483
+ "Not now ⏰", "Ask later 🔮", "100% Yes! 🔥", "Unclear 🌫️"
484
+ ]
485
+ embed = discord.Embed(
486
+ title="🎱 Magic 8 Ball",
487
+ description=(
488
+ f"{panel_divider('pink')}\n"
489
+ f" **Question:** {question}\n"
490
+ f"🎱 **Answer:** {random.choice(answers)}\n"
491
+ f"{panel_divider('pink')}"
492
+ ),
493
+ color=NEON_PINK,
494
+ )
495
+ await ctx.reply(embed=embed)
496
+
497
+ @commands.hybrid_command(name="meme", description=get_cmd_desc("commands.tools.meme_desc"))
498
+ async def meme(self, ctx: commands.Context) -> None:
499
+ guild_id = ctx.guild.id if ctx.guild else None
500
+ lang = await self.bot.get_guild_language(guild_id)
501
+
502
+ url = "https://meme-api.com/gimme"
503
+ try:
504
+ async with aiohttp.ClientSession() as session:
505
+ async with session.get(url, timeout=10) as response:
506
+ data = await response.json()
507
+ except Exception:
508
+ if lang == "ar":
509
+ await ctx.reply("⚠️ تعذر تحميل الميم الآن. جرب مرة أخرى! 🔄")
510
+ else:
511
+ await ctx.reply("⚠️ Could not load meme right now. Try again in a minute! 🔄")
512
+ return
513
+
514
+ embed = discord.Embed(
515
+ title=f"😂 {data.get('title', 'Random Meme')}",
516
+ color=NEON_CYAN,
517
+ url=data.get("postLink")
518
+ )
519
+ if data.get("url"):
520
+ embed.set_image(url=data["url"])
521
+ embed.set_footer(text=f"👍 {data.get('ups', 0)} | r/{data.get('subreddit', 'meme')}")
522
+ await ctx.reply(embed=embed)
523
+
524
+ @commands.hybrid_command(name="trivia", description=get_cmd_desc("commands.tools.trivia_desc"), hidden=True, with_app_command=False)
525
+ async def trivia(self, ctx: commands.Context, category: str = "", source: str = "local") -> None:
526
+ guild_id = ctx.guild.id if ctx.guild else None
527
+ lang = await self.bot.get_guild_language(guild_id)
528
+
529
+ normalized_source = source.strip().lower() or "local"
530
+ if normalized_source not in {"local", "api"}:
531
+ if lang == "ar":
532
+ await ctx.reply("❌ المصدر يجب أن يكون `local` أو `api`")
533
+ else:
534
+ await ctx.reply("❌ Source must be `local` or `api`")
535
+ return
536
+
537
+ normalized = category.strip().lower()
538
+ item: dict[str, object] | None = None
539
+ if lang == "ar":
540
+ topic_text = "الألعاب • الأفلام • المسلسلات"
541
+ else:
542
+ topic_text = "Gaming • Movies • Series"
543
+
544
+ if normalized_source == "api":
545
+ try:
546
+ item = await self._fetch_trivia_api()
547
+ topic_text = "OpenTDB (50 questions pool)"
548
+ except Exception:
549
+ item = None
550
+
551
+ if item is None:
552
+ aliases = {
553
+ "gaming": "gaming", "games": "gaming", "game": "gaming", "قيمز": "gaming", "العاب": "gaming",
554
+ "movies": "movies", "movie": "movies", "افلام": "movies", "أفلام": "movies",
555
+ "series": "series", "show": "series", "shows": "series", "مسلسلات": "series",
556
+ }
557
+ normalized = aliases.get(normalized, normalized) if normalized else random.choice(["gaming", "movies", "series"])
558
+ if normalized not in {"gaming", "movies", "series"}:
559
+ if lang == "ar":
560
+ await ctx.reply("❌ قسم غير مدعوم. استخدم: gaming أو movies أو series")
561
+ else:
562
+ await ctx.reply("❌ Unsupported category. Use: gaming, movies, or series")
563
+ return
564
+
565
+ # Get trivia from appropriate language bank
566
+ trivia_bank = TRIVIA_BANK.get(lang, TRIVIA_BANK["en"])
567
+ if normalized not in trivia_bank:
568
+ trivia_bank = TRIVIA_BANK["en"]
569
+ item = random.choice(trivia_bank[normalized])
570
+
571
+ view = TriviaView(owner_id=ctx.author.id, correct_index=int(item["a"]), lang=lang, cog=self, guild_id=guild_id)
572
+ for idx, choice in enumerate(item["c"]):
573
+ view.add_item(TriviaAnswerButton(str(choice), idx))
574
+
575
+ if lang == "ar":
576
+ title = "🧠 سؤال تراڤيا!"
577
+ cat_label = "📁 القسم"
578
+ topics_label = "📚 المواضيع"
579
+ else:
580
+ title = "🧠 Trivia Time!"
581
+ cat_label = "📁 Category"
582
+ topics_label = "📚 Topics"
583
+
584
+ embed = discord.Embed(
585
+ title=title,
586
+ description=f"{panel_divider('blue')}\n❓ {str(item['q'])}\n{panel_divider('blue')}",
587
+ color=NEON_CYAN,
588
+ )
589
+ cat_value = normalized.title() if normalized else ("General" if normalized_source == "api" else "Random")
590
+ embed.add_field(name=cat_label, value=cat_value, inline=True)
591
+ embed.add_field(name=topics_label, value=topic_text, inline=True)
592
+ await ctx.reply(embed=embed, view=view)
593
+
594
+ async def _fetch_gaming_news(self) -> list[dict[str, str]]:
595
+ return await fetch_gaming_news()
596
+
597
+ async def _fetch_free_games(self) -> list[dict[str, str]]:
598
+ return await fetch_free_games()
599
+
600
+ @staticmethod
601
+ def _store_icon(platform: str) -> str:
602
+ return store_icon(platform)
603
+
604
+ def _free_game_embed(self, item: dict[str, str]) -> tuple[discord.Embed, discord.ui.View]:
605
+ title = item["title"][:120]
606
+ link = item["link"]
607
+ embed = ImperialMotaz.craft_embed(
608
+ title=f"[FREE GAME] | {title}",
609
+ description=(
610
+ f"**{item.get('description', 'Limited-time free game offer!')}**\n\n"
611
+ "Claim it now from the official store page before it expires."
612
+ ),
613
+ color=NEON_PINK,
614
+ footer="Free Games Tracker",
615
+ )
616
+ embed.url = link
617
+ embed.add_field(name="Platform", value=f"「 {item.get('platform', 'Unknown')} 」", inline=True)
618
+ embed.add_field(name="Type", value=f"「 {item.get('game_type', 'Game')} 」", inline=True)
619
+ embed.add_field(name="Original Price", value=f"「 {item.get('original_price', 'N/A')} 」", inline=True)
620
+ embed.add_field(name="Ends On", value=f" {item.get('end_date', 'N/A')} 」", inline=True)
621
+ embed.add_field(name="Link", value=f"[Open Giveaway]({link})", inline=False)
622
+ if item.get("image"):
623
+ embed.set_image(url=item["image"])
624
+ embed.set_thumbnail(url=self._store_icon(item.get("platform", "")))
625
+ return embed, FreeGameClaimView(link)
626
+
627
+ @commands.hybrid_command(name="gaming_news", description=get_cmd_desc("commands.tools.gaming_news_desc"))
628
+ async def gaming_news(self, ctx: commands.Context) -> None:
629
+ guild_id = ctx.guild.id if ctx.guild else None
630
+ lang = await self.bot.get_guild_language(guild_id)
631
+
632
+ try:
633
+ items = await self._fetch_gaming_news()
634
+ except Exception:
635
+ if lang == "ar":
636
+ await ctx.reply("❌ تعذر جلب أخبار الألعاب الآن.")
637
+ else:
638
+ await ctx.reply("❌ Could not fetch gaming news right now.")
639
+ return
640
+
641
+ if not items:
642
+ if lang == "ar":
643
+ await ctx.reply("لا توجد أخبار متاحة الآن.")
644
+ else:
645
+ await ctx.reply("No gaming news available right now.")
646
+ return
647
+
648
+ if lang == "ar":
649
+ title = "🕹️ آخر أخبار الألعاب عالميًا"
650
+ else:
651
+ title = "🕹️ Latest Gaming News Worldwide"
652
+
653
+ embed = discord.Embed(
654
+ title=title,
655
+ description=f"{panel_divider('green')}",
656
+ color=NEON_CYAN,
657
+ )
658
+ for item in items:
659
+ date_hint = f"\n🗓️ {item['pub_date'][:16]}" if item.get("pub_date") else ""
660
+ if lang == "ar":
661
+ embed.add_field(name=item["title"][:256], value=f"📰 [اقرأ المقال]({item['link']}){date_hint}", inline=False)
662
+ else:
663
+ embed.add_field(name=item["title"][:256], value=f"📰 [Read Article]({item['link']}){date_hint}", inline=False)
664
+ await ctx.reply(embed=embed)
665
+
666
+ @commands.hybrid_command(name="freegames", description=get_cmd_desc("commands.tools.freegames_desc"), with_app_command=False)
667
+ async def freegames(self, ctx: commands.Context) -> None:
668
+ guild_id = ctx.guild.id if ctx.guild else None
669
+ lang = await self.bot.get_guild_language(guild_id)
670
+
671
+ try:
672
+ items = await self._fetch_free_games()
673
+ except Exception:
674
+ if lang == "ar":
675
+ await ctx.reply("❌ تعذر جلب العروض الآن.")
676
+ else:
677
+ await ctx.reply("❌ Could not fetch free game offers right now.")
678
+ return
679
+
680
+ if not items:
681
+ if lang == "ar":
682
+ await ctx.reply("لا توجد عروض مجانية نشطة الآن.")
683
+ else:
684
+ await ctx.reply("No active free game offers right now.")
685
+ return
686
+
687
+ for item in items:
688
+ embed, view = self._free_game_embed(item)
689
+ await ctx.reply(embed=embed, view=view)
690
+
691
+ @commands.hybrid_command(name="free_games", description=get_cmd_desc("commands.tools.free_games_desc"))
692
+ async def free_games(self, ctx: commands.Context) -> None:
693
+ await self.freegames(ctx)
694
+
695
+ @commands.hybrid_group(name="gamehub", fallback="panel", description="Interactive game hub with temporary private rooms")
696
+ async def gamehub(self, ctx: commands.Context, invite: discord.Member | None = None) -> None:
697
+ if not ctx.guild:
698
+ await ctx.reply("Server only.")
699
+ return
700
+
701
+ guild_id = ctx.guild.id
702
+ lang = await self.bot.get_guild_language(guild_id)
703
+
704
+ if invite and invite.bot:
705
+ if lang == "ar":
706
+ await ctx.reply("❌ ادعُ عضواً حقيقياً، ليس بوت.")
707
+ else:
708
+ await ctx.reply("❌ Invite a real member, not a bot.")
709
+ return
710
+ if invite and invite.id == ctx.author.id:
711
+ if lang == "ar":
712
+ await ctx.reply("❌ ادعُ عضواً آخر.")
713
+ else:
714
+ await ctx.reply("❌ Invite another member.")
715
+ return
716
+
717
+ games_list = " • ".join([f"{g['emoji']} {g['name']}" for g in GAME_HUB_GAMES])
718
+
719
+ if lang == "ar":
720
+ title = "🎮 مركز الألعاب"
721
+ desc = (
722
+ f"{panel_divider('pink')}\n"
723
+ f"🕹️ اختر لعبة أدناه لإنشاء **غرفة خاصة**.\n"
724
+ f"👥 ادعُ صديقاً أو العب ضد البوت.\n"
725
+ f"🗑️ الغرفة تُحذف تلقائياً عند إغلاقها.\n"
726
+ f"{panel_divider('pink')}\n\n"
727
+ f"**الألعاب:** {games_list}"
728
+ )
729
+ invited_label = "🎯 العضو المدعو"
730
+ else:
731
+ title = "🎮 Game Hub"
732
+ desc = (
733
+ f"{panel_divider('pink')}\n"
734
+ f"🕹️ Select a game below to create a **private room**.\n"
735
+ f"👥 Invite a friend or play solo vs the bot.\n"
736
+ f"🗑️ The room auto-deletes when you close it.\n"
737
+ f"{panel_divider('pink')}\n\n"
738
+ f"**Games:** {games_list}"
739
+ )
740
+ invited_label = "🎯 Invited Member"
741
+
742
+ embed = discord.Embed(
743
+ title=title,
744
+ description=desc,
745
+ color=NEON_CYAN,
746
+ )
747
+ if invite:
748
+ embed.add_field(name=invited_label, value=invite.mention, inline=False)
749
+ await ctx.reply(embed=embed, view=GameHubView(self, invite.id if invite else None, lang))
750
+
751
+ @gamehub.command(name="xo", description="TicTacToe vs member or bot")
752
+ async def gamehub_xo(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
753
+ await self.xo(ctx, opponent=opponent)
754
+
755
+ @gamehub.command(name="mario", description="Mario/Pac-Man arcade challenge")
756
+ async def gamehub_mario(self, ctx: commands.Context) -> None:
757
+ await self.mario(ctx)
758
+
759
+ @gamehub.command(name="dice", description="Roll dice")
760
+ async def gamehub_dice(self, ctx: commands.Context, sides: int = 6) -> None:
761
+ await self.dice(ctx, sides=sides)
762
+
763
+ @gamehub.command(name="slots", description="Slot machine game")
764
+ async def gamehub_slots(self, ctx: commands.Context, bet: int = 50) -> None:
765
+ await self.slots(ctx, bet=bet)
766
+
767
+ @gamehub.command(name="trivia", description="Gaming/Movies/Series trivia")
768
+ async def gamehub_trivia(self, ctx: commands.Context, category: str = "gaming", difficulty: str = "medium") -> None:
769
+ await self.trivia(ctx, category=category)
770
+
771
+ @commands.hybrid_command(name="xo", description=get_cmd_desc("commands.tools.xo_desc"), hidden=True, with_app_command=False)
772
+ async def xo(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
773
+ guild_id = ctx.guild.id if ctx.guild else None
774
+ lang = await self.bot.get_guild_language(guild_id)
775
+
776
+ if opponent and opponent.bot:
777
+ opponent = None
778
+ view = TicTacToeView(ctx.author, opponent)
779
+ second = opponent.mention if opponent else "🤖 Bot"
780
+
781
+ if lang == "ar":
782
+ msg = await ctx.reply(
783
+ f" تيك تاك تو: {ctx.author.mention} (X) ضد {second} (O)\n"
784
+ f"🎮 الدور: {ctx.author.mention}",
785
+ view=view
786
+ )
787
+ else:
788
+ msg = await ctx.reply(
789
+ f" TicTacToe: {ctx.author.mention} (X) vs {second} (O)\n"
790
+ f"🎮 Turn: {ctx.author.mention}",
791
+ view=view
792
+ )
793
+ if opponent is None:
794
+ await view._maybe_bot(msg)
795
+
796
+ @commands.hybrid_command(name="choose", hidden=True, with_app_command=False, description=get_cmd_desc("commands.tools.choose_desc"))
797
+ async def choose(self, ctx: commands.Context, *, options: str) -> None:
798
+ guild_id = ctx.guild.id if ctx.guild else None
799
+ lang = await self.bot.get_guild_language(guild_id)
800
+
801
+ parts = [p.strip() for p in options.split(",") if p.strip()]
802
+ if len(parts) < 2:
803
+ if lang == "ar":
804
+ await ctx.reply("❌ أدخل خيارين على الأقل مفصولين بفواصل.")
805
+ else:
806
+ await ctx.reply("❌ Provide at least 2 options separated by commas.")
807
+ return
808
+
809
+ choice = random.choice(parts)
810
+
811
+ if lang == "ar":
812
+ embed = discord.Embed(
813
+ title="🎲 اختيار عشوائي",
814
+ description=(
815
+ f"{panel_divider('purple')}\n"
816
+ f"📝 الخيارات: {', '.join(parts)}\n"
817
+ f"🎯 **المختار:** {choice}\n"
818
+ f"{panel_divider('purple')}"
819
+ ),
820
+ color=NEON_PINK,
821
+ )
822
+ else:
823
+ embed = discord.Embed(
824
+ title="🎲 Random Choice",
825
+ description=(
826
+ f"{panel_divider('purple')}\n"
827
+ f"📝 Options: {', '.join(parts)}\n"
828
+ f"🎯 **Selected:** {choice}\n"
829
+ f"{panel_divider('purple')}"
830
+ ),
831
+ color=NEON_PINK,
832
+ )
833
+ await ctx.reply(embed=embed)
834
+
835
+ @commands.hybrid_command(name="mario", description=get_cmd_desc("commands.tools.mario_desc"), hidden=True, with_app_command=False)
836
+ async def mario(self, ctx: commands.Context) -> None:
837
+ guild_id = ctx.guild.id if ctx.guild else None
838
+ lang = await self.bot.get_guild_language(guild_id)
839
+
840
+ coins = random.randint(5, 120)
841
+ level = random.randint(1, 8)
842
+
843
+ if lang == "ar":
844
+ embed = discord.Embed(
845
+ title="🍄 تحدي الآركيد",
846
+ description=(
847
+ f"{panel_divider('orange')}\n"
848
+ f"🍄 {ctx.author.mention} جمع **{coins}** عملة!\n"
849
+ f"✅ تجاوز المرحلة **{level}**!\n"
850
+ f"{panel_divider('orange')}"
851
+ ),
852
+ color=NEON_ORANGE,
853
+ )
854
+ embed.add_field(name="🎁 مكافأة", value="تم فتح combo باك مان! <:animatedarrowyellow:1477261257592668271>", inline=False)
855
+ else:
856
+ embed = discord.Embed(
857
+ title="🍄 Arcade Challenge",
858
+ description=(
859
+ f"{panel_divider('orange')}\n"
860
+ f"🍄 {ctx.author.mention} collected **{coins}** coins!\n"
861
+ f"✅ Cleared level **{level}**!\n"
862
+ f"{panel_divider('orange')}"
863
+ ),
864
+ color=NEON_ORANGE,
865
+ )
866
+ embed.add_field(name="🎁 Bonus", value="Pac-Man combo unlocked! <:animatedarrowyellow:1477261257592668271>", inline=False)
867
+ embed.set_thumbnail(url="https://upload.wikimedia.org/wikipedia/en/a/a9/MarioNSMBUDeluxe.png")
868
+ await ctx.reply(embed=embed)
869
+
870
+ @commands.hybrid_command(name="dice", description=get_cmd_desc("commands.tools.dice_desc"), hidden=True, with_app_command=False)
871
+ async def dice(self, ctx: commands.Context, sides: int = 6) -> None:
872
+ """Roll a dice with specified sides."""
873
+ guild_id = ctx.guild.id if ctx.guild else None
874
+ lang = await self.bot.get_guild_language(guild_id)
875
+
876
+ sides = max(2, min(sides, 100))
877
+ result = random.randint(1, sides)
878
+
879
+ if lang == "ar":
880
+ embed = gaming_embed(
881
+ "🎲 رمي النرد",
882
+ f"🎲 رميت نرد بـ **{sides}** أوجه\n🎯 النتيجة: **{result}**"
883
+ )
884
+ else:
885
+ embed = gaming_embed(
886
+ "🎲 Dice Roll",
887
+ f"🎲 Rolled a **{sides}**-sided dice\n🎯 Result: **{result}**"
888
+ )
889
+ await ctx.reply(embed=embed)
890
+
891
+ @commands.hybrid_command(name="slots", description=get_cmd_desc("commands.tools.slots_desc"), hidden=True, with_app_command=False)
892
+ async def slots(self, ctx: commands.Context, bet: int = 10) -> None:
893
+ """Play a slot machine game."""
894
+ guild_id = ctx.guild.id if ctx.guild else None
895
+ lang = await self.bot.get_guild_language(guild_id)
896
+
897
+ bet = max(10, bet)
898
+
899
+ # Check balance
900
+ row = await self.bot.db.fetchone(
901
+ "SELECT wallet FROM user_balance WHERE guild_id = ? AND user_id = ?",
902
+ guild_id,
903
+ ctx.author.id,
904
+ )
905
+ wallet = row[0] if row else 0
906
+
907
+ if wallet < bet:
908
+ if lang == "ar":
909
+ await ctx.reply(f"❌ ليس لديك رصيد كافي. محفظتك: **{wallet}** عملة")
910
+ else:
911
+ await ctx.reply(f"❌ Insufficient balance. Your wallet: **{wallet}** coins")
912
+ return
913
+
914
+ # Slot symbols
915
+ symbols = ["🍒", "🍋", "🍊", "🍇", "✅", "💎", "7️⃣"]
916
+ weights = [25, 20, 18, 15, 12, 7, 3] # Weighted probabilities
917
+
918
+ # Spin
919
+ results = random.choices(symbols, weights=weights, k=3)
920
+
921
+ # Calculate winnings
922
+ if results[0] == results[1] == results[2]:
923
+ # Jackpot!
924
+ if results[0] == "7️⃣":
925
+ multiplier = 10
926
+ elif results[0] == "💎":
927
+ multiplier = 7
928
+ elif results[0] == "✅":
929
+ multiplier = 5
930
+ else:
931
+ multiplier = 3
932
+ winnings = bet * multiplier
933
+ elif results[0] == results[1] or results[1] == results[2] or results[0] == results[2]:
934
+ winnings = bet * 2
935
+ else:
936
+ winnings = 0
937
+
938
+ # Update balance
939
+ if winnings > 0:
940
+ await self._add_coins(guild_id, ctx.author.id, winnings - bet)
941
+ else:
942
+ await self._add_coins(guild_id, ctx.author.id, -bet)
943
+
944
+ # Build display
945
+ slot_display = " | ".join(results)
946
+
947
+ if winnings >= bet * 5:
948
+ result_text = f"🎰 **{'جائزة كبرى!' if lang == 'ar' else 'JACKPOT!'}** {E_FIRE}"
949
+ elif winnings > 0:
950
+ result_text = f"✅ **{'فزت!' if lang == 'ar' else 'You won!'}**"
951
+ else:
952
+ result_text = f"❌ **{'حظ أوفر' if lang == 'ar' else 'Better luck next time'}**"
953
+
954
+ if lang == "ar":
955
+ embed = gaming_embed(
956
+ "🎰 ماكينة القمار",
957
+ f"┌───────┐\n│ {slot_display} │\n└───────┘\n\n{result_text}\n💰 **الرابح:** {winnings} عملة"
958
+ )
959
+ else:
960
+ embed = gaming_embed(
961
+ "🎰 Slot Machine",
962
+ f"┌───────┐\n│ {slot_display} │\n└───────┘\n\n{result_text}\n💰 **Won:** {winnings} coins"
963
+ )
964
+ await ctx.reply(embed=embed)
965
+
966
+ async def _add_coins(self, guild_id: int, user_id: int, amount: int) -> None:
967
+ """Helper to add/remove coins from user balance."""
968
+ if amount == 0:
969
+ return
970
+ await self.bot.db.execute(
971
+ "INSERT INTO user_balance(guild_id, user_id, wallet, bank) VALUES (?, ?, ?, 0) "
972
+ "ON CONFLICT(guild_id, user_id) DO UPDATE SET wallet = wallet + ?",
973
+ guild_id,
974
+ user_id,
975
+ amount if amount > 0 else 0,
976
+ amount,
977
+ )
978
+
979
+
980
+ async def setup(bot: commands.Bot) -> None:
981
+ await bot.add_cog(Fun(bot))
bot/cogs/gambling.py CHANGED
@@ -12,6 +12,7 @@ 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
 
@@ -282,7 +283,7 @@ class RouletteGameView(discord.ui.View):
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
 
@@ -386,7 +387,7 @@ class RPGView(discord.ui.View):
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")
@@ -443,7 +444,7 @@ class RPGView(discord.ui.View):
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")
@@ -620,11 +621,11 @@ class BlackjackBetModal(discord.ui.Modal, title="🃏 Place Your Bet"):
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)
@@ -650,13 +651,13 @@ class Gambling(commands.Cog):
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(
@@ -666,7 +667,7 @@ class Gambling(commands.Cog):
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(
 
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.i18n import get_cmd_desc
16
  from bot.emojis import ui
17
 
18
 
 
283
  await self.prompt_bet(interaction, "odd")
284
 
285
  async def prompt_bet(self, interaction: discord.Interaction, bet_type: str):
286
+ if self.user_id and interaction.user.id != self.user_id:
287
  return
288
  await interaction.response.send_modal(RouletteBetModal(self.cog, self.guild_id, self.user_id, bet_type))
289
 
 
387
 
388
  @discord.ui.button(label="⚔️ Fight Monster", emoji=ui("game"), style=discord.ButtonStyle.danger, custom_id="rpg_fight")
389
  async def fight_monster(self, interaction: discord.Interaction, button: discord.ui.Button):
390
+ if self.user_id and interaction.user.id != self.user_id:
391
  return
392
 
393
  engagement_cog = interaction.client.get_cog("Engagement")
 
444
 
445
  @discord.ui.button(label="🎁 Find Treasure", emoji=ui("gift"), style=discord.ButtonStyle.success, custom_id="rpg_treasure")
446
  async def find_treasure(self, interaction: discord.Interaction, button: discord.ui.Button):
447
+ if self.user_id and interaction.user.id != self.user_id:
448
  return
449
 
450
  engagement_cog = interaction.client.get_cog("Engagement")
 
621
  await interaction.response.send_message("✅ Blackjack game started!", ephemeral=True)
622
 
623
 
624
+ class Gambling(commands.Cog):
625
  def __init__(self, bot: commands.Bot) -> None:
626
  self.bot = bot
627
 
628
+ @commands.hybrid_command(name="blackjack", description=get_cmd_desc("commands.economy.blackjack_desc"))
629
  async def blackjack(self, ctx: commands.Context, bet: int) -> None:
630
  if bet < 10:
631
  await ctx.send("❌ Minimum bet is 10 coins.", ephemeral=True)
 
651
  message = await ctx.send(embed=embed, view=view)
652
  view.message = message
653
 
654
+ @commands.hybrid_command(name="roulette", description=get_cmd_desc("commands.economy.roulette_desc"))
655
  async def roulette(self, ctx: commands.Context) -> None:
656
  view = RouletteGameView(self, ctx.guild.id if ctx.guild else 0, ctx.author.id)
657
  embed = view.build_embed()
658
  await ctx.send(embed=embed, view=view)
659
 
660
+ @commands.hybrid_command(name="rpg", description=get_cmd_desc("commands.economy.rpg_desc"))
661
  async def rpg(self, ctx: commands.Context) -> None:
662
  view = RPGView(self, ctx.guild.id if ctx.guild else 0, ctx.author.id)
663
  embed = view.build_embed(
 
667
  )
668
  await ctx.send(embed=embed, view=view, ephemeral=True)
669
 
670
+ @commands.hybrid_command(name="gambling_panel", description=get_cmd_desc("commands.economy.gambling_panel_desc"))
671
  async def gambling_panel(self, ctx: commands.Context) -> None:
672
  view = GamblingPanelView(self, ctx.guild.id if ctx.guild else 0, ctx.author.id)
673
  embed = discord.Embed(
bot/cogs/language.py CHANGED
@@ -1,187 +1,188 @@
1
- """
2
- Language cog: Multi-language support for the bot.
3
- Provides a beautiful panel for changing server language.
4
- """
5
-
6
- import discord
7
- from discord.ext import commands
8
-
9
- from bot.i18n import SUPPORTED_LANGUAGES, translate
10
- from bot.theme import NEON_CYAN, NEON_LIME, panel_divider, success_embed, shimmer
11
-
12
- LANGUAGE_META: dict[str, tuple[str, str]] = {
13
- "ar": ("العربية", "🇸🇦"),
14
- "en": ("English", "🇬🇧"),
15
- "es": ("Español", "🇪🇸"),
16
- "fr": ("Français", "🇫🇷"),
17
- "de": ("Deutsch", "🇩🇪"),
18
- "tr": ("Türkçe", "🇹🇷"),
19
- "it": ("Italiano", "🇮🇹"),
20
- "pt": ("Português", "🇵🇹"),
21
- "ru": ("Русский", "🇷🇺"),
22
- "hi": ("हिन्दी", "🇮🇳"),
23
- "id": ("Indonesia", "🇮🇩"),
24
- "ja": ("日本語", "🇯🇵"),
25
- "zh": ("中文", "🇨🇳"),
26
- "he": ("עברית", "🇮🇱"),
27
- }
28
-
29
-
30
- class LanguageSelect(discord.ui.Select):
31
- def __init__(self, cog: "Language", guild_id: int, current_code: str) -> None:
32
- self.cog = cog
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(
43
- label=label,
44
- value=code,
45
- emoji=emoji,
46
- description=f"{code.upper()} language",
47
- default=code == current_code,
48
- )
49
- )
50
-
51
- super().__init__(
52
- placeholder="Choose server language",
53
- min_values=1,
54
- max_values=1,
55
- options=options[:25],
56
- custom_id="lang:select",
57
- )
58
-
59
- async def callback(self, interaction: discord.Interaction) -> None:
60
- if not interaction.guild:
61
- await interaction.response.send_message(translate("en", "common.server_only"), ephemeral=True)
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",
79
- interaction.guild.id,
80
- selected,
81
- )
82
-
83
- for option in self.options:
84
- option.default = option.value == selected
85
-
86
- embed = await self.cog._language_embed(interaction.guild.id, selected)
87
- await interaction.response.edit_message(embed=embed, view=self.view)
88
-
89
- # Send follow-up with hint to open /menu
90
- lang_name = LANGUAGE_META.get(selected, (selected.upper(), "🌐"))[0]
91
- hint_msg = translate(selected, "lang.updated")
92
-
93
- followup_embed = discord.Embed(
94
- title=f"✅ Language Changed!",
95
- description=f"{panel_divider('lime')}\n{hint_msg}\n\n💡 Use `/menu` to see all commands in your selected language!\n{panel_divider('lime')}",
96
- color=NEON_LIME,
97
- )
98
- await interaction.followup.send(embed=followup_embed, ephemeral=True)
99
-
100
-
101
- class LanguagePanelView(discord.ui.View):
102
- def __init__(self, cog: "Language", guild_id: int, current_code: str) -> None:
103
- super().__init__(timeout=None)
104
- self.add_item(LanguageSelect(cog, guild_id, current_code))
105
-
106
-
107
- class Language(commands.Cog):
108
- def __init__(self, bot: commands.Bot) -> None:
109
- self.bot = bot
110
-
111
- async def cog_load(self) -> None:
112
- self.bot.add_view(LanguagePanelView(self, 0, "ar"))
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})`")
131
-
132
- # Get translated content
133
- title = translate(current_code, "lang.current").replace("{globe}", "🌍")
134
-
135
- embed = discord.Embed(
136
- title="🌍 Language Control Panel",
137
- description=(
138
- f"{shimmer('Current Language')}: **{current_emoji} {current_name}** (`{current_code}`)\n"
139
- f"{panel_divider('cyan')}\n"
140
- f"Pick your server language from the dropdown below.\n"
141
- f"All bot messages and panels will use the selected language!"
142
- ),
143
- color=NEON_CYAN,
144
- )
145
- embed.add_field(name="🌐 Supported Languages", value="\n".join(lines[:20]), inline=False)
146
- embed.add_field(
147
- name="💡 Tips",
148
- value=(
149
- "• Language changes apply instantly\n"
150
- "• Use `/menu` to see commands in your language\n"
151
- "• Available full locales: Arabic, English, Spanish, French, German"
152
- ),
153
- inline=False
154
- )
155
- embed.set_footer(text="Changes apply instantly across bot messages and panels")
156
- return embed
157
-
158
- @commands.hybrid_command(name="language", description="Open language panel for this server")
159
- @commands.has_permissions(manage_guild=True)
160
- async def language(self, ctx: commands.Context) -> None:
161
- if not ctx.guild:
162
- await ctx.reply(translate("en", "common.server_only"))
163
- return
164
-
165
- current_code = await self._current_code(ctx.guild.id)
166
- embed = await self._language_embed(ctx.guild.id, current_code)
167
- view = LanguagePanelView(self, ctx.guild.id, current_code)
168
- await ctx.reply(embed=embed, view=view)
169
-
170
- @commands.hybrid_command(name="languages", description="List supported bot languages", hidden=True, with_app_command=False)
171
- async def languages(self, ctx: commands.Context) -> None:
172
- guild_id = ctx.guild.id if ctx.guild else None
173
- lang = await self.bot.get_guild_language(guild_id)
174
-
175
- pretty = [f"{LANGUAGE_META.get(code, (code.upper(), '🌐'))[1]} **{LANGUAGE_META.get(code, (code.upper(), '🌐'))[0]}** (`{code}`)" for code in LANGUAGE_META]
176
-
177
- embed = discord.Embed(
178
- title="🌍 Supported Languages",
179
- description=f"{panel_divider('cyan')}\n" + "\n".join(pretty) + f"\n{panel_divider('cyan')}",
180
- color=NEON_CYAN,
181
- )
182
- embed.set_footer(text=f"Current: {LANGUAGE_META.get(lang, (lang.upper(), '🌐'))[0]}")
183
- await ctx.reply(embed=embed)
184
-
185
-
186
- async def setup(bot: commands.Bot) -> None:
187
- await bot.add_cog(Language(bot))
 
 
1
+ from bot.i18n import get_cmd_desc
2
+ """
3
+ Language cog: Multi-language support for the bot.
4
+ Provides a beautiful panel for changing server language.
5
+ """
6
+
7
+ import discord
8
+ from discord.ext import commands
9
+
10
+ from bot.i18n import SUPPORTED_LANGUAGES, translate
11
+ from bot.theme import NEON_CYAN, NEON_LIME, panel_divider, success_embed, shimmer
12
+
13
+ LANGUAGE_META: dict[str, tuple[str, str]] = {
14
+ "ar": ("العربية", "🇸🇦"),
15
+ "en": ("English", "🇬🇧"),
16
+ "es": ("Español", "🇪🇸"),
17
+ "fr": ("Français", "🇫🇷"),
18
+ "de": ("Deutsch", "🇩🇪"),
19
+ "tr": ("Türkçe", "🇹🇷"),
20
+ "it": ("Italiano", "🇮🇹"),
21
+ "pt": ("Português", "🇵🇹"),
22
+ "ru": ("Русский", "🇷🇺"),
23
+ "hi": ("हिन्दी", "🇮🇳"),
24
+ "id": ("Indonesia", "🇮🇩"),
25
+ "ja": ("日本語", "🇯🇵"),
26
+ "zh": ("中文", "🇨🇳"),
27
+ "he": ("עברית", "🇮🇱"),
28
+ }
29
+
30
+
31
+ class LanguageSelect(discord.ui.Select):
32
+ def __init__(self, cog: "Language", guild_id: int, current_code: str) -> None:
33
+ self.cog = cog
34
+ self.guild_id = guild_id
35
+
36
+ options: list[discord.SelectOption] = []
37
+ dynamic_supported = sorted(
38
+ getattr(getattr(cog.bot, "translator", None), "supported_languages", set(SUPPORTED_LANGUAGES))
39
+ )
40
+ for code in dynamic_supported:
41
+ label, emoji = LANGUAGE_META.get(code, (code.upper(), "🌐"))
42
+ options.append(
43
+ discord.SelectOption(
44
+ label=label,
45
+ value=code,
46
+ emoji=emoji,
47
+ description=f"{code.upper()} language",
48
+ default=code == current_code,
49
+ )
50
+ )
51
+
52
+ super().__init__(
53
+ placeholder="Choose server language",
54
+ min_values=1,
55
+ max_values=1,
56
+ options=options[:25],
57
+ custom_id="lang:select",
58
+ )
59
+
60
+ async def callback(self, interaction: discord.Interaction) -> None:
61
+ if not interaction.guild:
62
+ await interaction.response.send_message(translate("en", "common.server_only"), ephemeral=True)
63
+ return
64
+
65
+ selected = self.values[0]
66
+
67
+ # Special rule requested by owner: Hebrew is visible but cannot be set.
68
+ if selected == "he":
69
+ current_code = await self.cog._current_code(interaction.guild.id)
70
+ for option in self.options:
71
+ option.default = option.value == current_code
72
+ embed = await self.cog._language_embed(interaction.guild.id, current_code)
73
+ await interaction.response.edit_message(embed=embed, view=self.view)
74
+ await interaction.followup.send("only you know this language", ephemeral=True)
75
+ return
76
+
77
+ await self.cog.bot.db.execute(
78
+ "INSERT INTO guild_config(guild_id, guild_language) VALUES (?, ?) "
79
+ "ON CONFLICT(guild_id) DO UPDATE SET guild_language = excluded.guild_language",
80
+ interaction.guild.id,
81
+ selected,
82
+ )
83
+
84
+ for option in self.options:
85
+ option.default = option.value == selected
86
+
87
+ embed = await self.cog._language_embed(interaction.guild.id, selected)
88
+ await interaction.response.edit_message(embed=embed, view=self.view)
89
+
90
+ # Send follow-up with hint to open /menu
91
+ lang_name = LANGUAGE_META.get(selected, (selected.upper(), "🌐"))[0]
92
+ hint_msg = translate(selected, "lang.updated")
93
+
94
+ followup_embed = discord.Embed(
95
+ title=f" Language Changed!",
96
+ description=f"{panel_divider('lime')}\n{hint_msg}\n\n💡 Use `/menu` to see all commands in your selected language!\n{panel_divider('lime')}",
97
+ color=NEON_LIME,
98
+ )
99
+ await interaction.followup.send(embed=followup_embed, ephemeral=True)
100
+
101
+
102
+ class LanguagePanelView(discord.ui.View):
103
+ def __init__(self, cog: "Language", guild_id: int, current_code: str) -> None:
104
+ super().__init__(timeout=None)
105
+ self.add_item(LanguageSelect(cog, guild_id, current_code))
106
+
107
+
108
+ class Language(commands.Cog):
109
+ def __init__(self, bot: commands.Bot) -> None:
110
+ self.bot = bot
111
+
112
+ async def cog_load(self) -> None:
113
+ self.bot.add_view(LanguagePanelView(self, 0, "ar"))
114
+
115
+ async def _current_code(self, guild_id: int) -> str:
116
+ row = await self.bot.db.fetchone("SELECT guild_language FROM guild_config WHERE guild_id = ?", guild_id)
117
+ supported = set(getattr(getattr(self.bot, "translator", None), "supported_languages", set(SUPPORTED_LANGUAGES)))
118
+ return row[0] if row and row[0] in supported else "ar"
119
+
120
+ async def _language_embed(self, guild_id: int, current_code: str) -> discord.Embed:
121
+ current_name = LANGUAGE_META.get(current_code, (current_code.upper(), "🌐"))[0]
122
+ current_emoji = LANGUAGE_META.get(current_code, (current_code.upper(), "🌐"))[1]
123
+
124
+ lines = []
125
+ supported = sorted(
126
+ getattr(getattr(self.bot, "translator", None), "supported_languages", set(SUPPORTED_LANGUAGES))
127
+ )
128
+ for code in supported:
129
+ name, emoji = LANGUAGE_META.get(code, (code.upper(), "🌐"))
130
+ marker = "✅" if code == current_code else "▫️"
131
+ lines.append(f"{marker} {emoji} **{name}** `({code})`")
132
+
133
+ # Get translated content
134
+ title = translate(current_code, "lang.current").replace("{globe}", "🌍")
135
+
136
+ embed = discord.Embed(
137
+ title="🌍 Language Control Panel",
138
+ description=(
139
+ f"{shimmer('Current Language')}: **{current_emoji} {current_name}** (`{current_code}`)\n"
140
+ f"{panel_divider('cyan')}\n"
141
+ f"Pick your server language from the dropdown below.\n"
142
+ f"All bot messages and panels will use the selected language!"
143
+ ),
144
+ color=NEON_CYAN,
145
+ )
146
+ embed.add_field(name="🌐 Supported Languages", value="\n".join(lines[:20]), inline=False)
147
+ embed.add_field(
148
+ name="💡 Tips",
149
+ value=(
150
+ "• Language changes apply instantly\n"
151
+ "• Use `/menu` to see commands in your language\n"
152
+ "• Available full locales: Arabic, English, Spanish, French, German"
153
+ ),
154
+ inline=False
155
+ )
156
+ embed.set_footer(text="Changes apply instantly across bot messages and panels")
157
+ return embed
158
+
159
+ @commands.hybrid_command(name="language", description=get_cmd_desc("commands.tools.language_desc"))
160
+ @commands.has_permissions(manage_guild=True)
161
+ async def language(self, ctx: commands.Context) -> None:
162
+ if not ctx.guild:
163
+ await ctx.reply(translate("en", "common.server_only"))
164
+ return
165
+
166
+ current_code = await self._current_code(ctx.guild.id)
167
+ embed = await self._language_embed(ctx.guild.id, current_code)
168
+ view = LanguagePanelView(self, ctx.guild.id, current_code)
169
+ await ctx.reply(embed=embed, view=view)
170
+
171
+ @commands.hybrid_command(name="languages", description=get_cmd_desc("commands.tools.languages_desc"), hidden=True, with_app_command=False)
172
+ async def languages(self, ctx: commands.Context) -> None:
173
+ guild_id = ctx.guild.id if ctx.guild else None
174
+ lang = await self.bot.get_guild_language(guild_id)
175
+
176
+ pretty = [f"{LANGUAGE_META.get(code, (code.upper(), '🌐'))[1]} **{LANGUAGE_META.get(code, (code.upper(), '🌐'))[0]}** (`{code}`)" for code in LANGUAGE_META]
177
+
178
+ embed = discord.Embed(
179
+ title="🌍 Supported Languages",
180
+ description=f"{panel_divider('cyan')}\n" + "\n".join(pretty) + f"\n{panel_divider('cyan')}",
181
+ color=NEON_CYAN,
182
+ )
183
+ embed.set_footer(text=f"Current: {LANGUAGE_META.get(lang, (lang.upper(), '🌐'))[0]}")
184
+ await ctx.reply(embed=embed)
185
+
186
+
187
+ async def setup(bot: commands.Bot) -> None:
188
+ await bot.add_cog(Language(bot))
bot/cogs/media.py CHANGED
The diff for this file is too large to render. See raw diff
 
bot/cogs/media_helpers.py CHANGED
@@ -362,9 +362,9 @@ class AutoRefreshMixin:
362
 
363
  class FiltersPanelView(discord.ui.View, AutoRefreshMixin):
364
  """Separate Filters Panel with auto-refresh."""
365
-
366
  def __init__(self, cog: "Media", guild_id: int) -> None:
367
- discord.ui.View.__init__(self, timeout=300)
368
  AutoRefreshMixin.__init__(self)
369
  self.cog = cog
370
  self.guild_id = guild_id
@@ -747,10 +747,15 @@ class MusicPanelView(discord.ui.View, AutoRefreshMixin):
747
  async def _build_refresh_embed(self) -> discord.Embed:
748
  """Build refresh embed."""
749
  return await self.cog._music_panel_embed(self.guild_id)
750
-
751
  def _build_refresh_view(self) -> None:
752
- """Rebuild view for refresh."""
753
- self._build_view()
 
 
 
 
 
754
 
755
  @safe_interaction
756
  async def _join(self, interaction: discord.Interaction) -> None:
 
362
 
363
  class FiltersPanelView(discord.ui.View, AutoRefreshMixin):
364
  """Separate Filters Panel with auto-refresh."""
365
+
366
  def __init__(self, cog: "Media", guild_id: int) -> None:
367
+ discord.ui.View.__init__(self, timeout=None)
368
  AutoRefreshMixin.__init__(self)
369
  self.cog = cog
370
  self.guild_id = guild_id
 
747
  async def _build_refresh_embed(self) -> discord.Embed:
748
  """Build refresh embed."""
749
  return await self.cog._music_panel_embed(self.guild_id)
750
+
751
  def _build_refresh_view(self) -> None:
752
+ """Skip rebuilding view on auto-refresh to prevent 'unknown view' errors.
753
+
754
+ The buttons are static and don't need rebuilding during auto-refresh.
755
+ Rebuilding clears items and creates new button instances which breaks
756
+ Discord's view caching and causes 'interaction referencing unknown view'.
757
+ """
758
+ pass
759
 
760
  @safe_interaction
761
  async def _join(self, interaction: discord.Interaction) -> None:
bot/cogs/menu.py CHANGED
@@ -9,6 +9,7 @@ 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,
@@ -20,78 +21,93 @@ from bot.theme import (
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],
@@ -121,23 +137,25 @@ class CommandsMenuSelect(discord.ui.Select):
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,
@@ -184,7 +202,7 @@ class CommandsMenuView(discord.ui.View):
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(
@@ -203,13 +221,13 @@ class CommandsMenuView(discord.ui.View):
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):
@@ -348,6 +366,8 @@ class CommandsMenuView(discord.ui.View):
348
  "reload": "menu.cmd.reload",
349
  "sync": "menu.cmd.sync",
350
  "shutdown": "menu.cmd.shutdown",
 
 
351
 
352
  # Verification commands
353
  "verify": "menu.cmd.verify",
@@ -400,55 +420,55 @@ class CommandsMenuView(discord.ui.View):
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:
@@ -471,13 +491,13 @@ class CommandsMenuView(discord.ui.View):
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:
@@ -488,12 +508,12 @@ class CommandsMenuView(discord.ui.View):
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)
@@ -527,16 +547,16 @@ class CommandsMenuView(discord.ui.View):
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
 
@@ -553,17 +573,17 @@ class CommandsMenuView(discord.ui.View):
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
 
@@ -622,21 +642,21 @@ class CommandsMenuView(discord.ui.View):
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")
@@ -662,10 +682,10 @@ class CommandsMenuView(discord.ui.View):
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
  )
@@ -677,7 +697,7 @@ class CommandsMenuView(discord.ui.View):
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}",
@@ -690,7 +710,7 @@ class CommandsMenuView(discord.ui.View):
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
 
@@ -724,8 +744,8 @@ class RefreshButton(discord.ui.Button):
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)
@@ -757,7 +777,7 @@ class PageButton(discord.ui.Button):
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:
@@ -778,10 +798,10 @@ class Menu(commands.Cog):
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():
@@ -795,16 +815,16 @@ class Menu(commands.Cog):
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)
 
9
  from discord.ext import commands
10
 
11
  from bot.cogs.ai_suite import ImperialMotaz
12
+ from bot.i18n import get_cmd_desc
13
  from bot.emojis import ui, E_DIAMOND, E_STAR, E_FIRE, E_SPARKLE, E_GEM, E_CROWN
14
  from bot.theme import (
15
  fancy_header, pick_neon_color, progress_bar, shimmer, panel_divider,
 
21
 
22
 
23
  # Beautiful unicode emojis for select menu categories
24
+ _CATEGORY_EMOJIS: dict[str, str] = {
25
+ "Music": "🎵",
26
+ "Admin": "🛡️",
27
+ "Fun": "🎮",
28
+ "AI": "🤖",
29
+ "Utility": "🔧",
30
+ "Config": "⚙️",
31
+ "Economy": "💰",
32
+ "Moderation": "⚔️",
33
+ "Tickets": "🎫",
34
+ "Welcome": "👋",
35
+ "Giveaway": "🎁",
36
+ "Verification": "",
37
+ "Tournament": "🏆",
38
+ "Games": "🎲",
39
+ "Level": "📊",
40
+ "AutoMod": "🤖",
41
+ "Logs": "📋",
42
+ "DJ": "🎧",
43
+ "Developer": "💻",
44
+ "default": "📂",
45
  }
46
 
47
  # Category descriptions with emojis
48
+ _CATEGORY_DESCRIPTIONS: dict[str, str] = {
49
+ "Music": "🎵 Music commands for playback control",
50
+ "Admin": "🛡️ Server administration tools",
51
+ "Fun": "🎮 Fun games and entertainment",
52
+ "AI": "🤖 AI-powered features",
53
+ "Utility": "🔧 Useful utility commands",
54
+ "Config": "⚙️ Bot configuration settings",
55
+ "Economy": "💰 Economy and currency system",
56
+ "Moderation": "⚔️ Moderation commands",
57
+ "Tickets": "🎫 Ticket support system",
58
+ "Welcome": "👋 Welcome and goodbye messages",
59
+ "Giveaway": "🎁 Giveaway management",
60
+ "Verification": " Member verification",
61
+ "Tournament": "🏆 Tournament brackets",
62
+ "Games": "🎲 Mini games and fun",
63
+ "Level": "📊 XP and leveling system",
64
+ "AutoMod": "🤖 Auto-moderation features",
65
+ "Logs": "📋 Logging configuration",
66
+ "DJ": "🎧 DJ music controls",
67
+ "Developer": "💻 Developer tools",
68
  }
69
 
70
  _CATEGORY_BILINGUAL = {
71
+ "__all__": "📚 All Commands | جميع الأوامر",
72
+ "__ai__": "🤖 AI | الذكاء الاصطناعي",
73
+ "Music": "🎵 Music | الموسيقى",
74
+ "Admin": "🛡️ Admin | الإدارة",
75
+ "Fun": "🎮 Games | الألعاب",
76
+ "AI": "🤖 AI | الذكاء الاصطناعي",
77
+ "Utility": "🔧 Utility | الأدوات",
78
+ "Config": "⚙️ Config | الإعدادات",
79
+ "Economy": "💰 Economy | الاقتصاد",
80
+ "Moderation": "⚔️ Moderation | الإشراف",
81
+ "Community": "💡 Community | المجتمع",
82
+ "AISuite": "🤖 AI Suite | الذكاء",
83
+ "Configuration": "⚙️ Configuration | الإعدادات",
84
+ "Events": "📋 Events | الأحداث",
85
+ "Verification": " Verification | التحقق",
86
  }
87
 
88
 
89
  def _bilingual_category(name: str) -> str:
90
+ return _CATEGORY_BILINGUAL.get(name, f"📂 {name} | {name}")
91
+
92
+
93
+ def _safe_emoji(value: str) -> "discord.Emoji | discord.PartialEmoji | str | None":
94
+ """Safely resolve an emoji value for use in Discord SelectOptions."""
95
+ if not value:
96
+ return None
97
+ value = value.strip()
98
+ if not value:
99
+ return None
100
+ if value.startswith("<") and value.endswith(">"):
101
+ try:
102
+ return discord.PartialEmoji.from_str(value)
103
+ except Exception:
104
+ return None
105
+ return value
106
 
107
 
108
  class CommandsMenuSelect(discord.ui.Select):
109
  """Beautiful dropdown menu for selecting command categories."""
110
+
111
  def __init__(
112
  self,
113
  cog_names: list[str],
 
137
  default=selected_cog == "__ai__",
138
  ),
139
  ]
140
+
141
  # Add cog options with beautiful emojis
142
  for name in sorted(cog_names):
143
+ raw_emoji = _CATEGORY_EMOJIS.get(name, _CATEGORY_EMOJIS["default"])
144
+ desc = cog_desc_map.get(name, _CATEGORY_DESCRIPTIONS.get(name, f"{name} commands"))
145
  # Truncate description to fit Discord's limits
146
  desc = desc[:100]
147
+ # Safely resolve emoji - use PartialEmoji for custom emoji strings
148
+ resolved_emoji = _safe_emoji(raw_emoji)
149
  options.append(
150
  discord.SelectOption(
151
  label=_bilingual_category(name)[:100],
152
  description=desc,
153
+ emoji=resolved_emoji,
154
  value=name,
155
  default=selected_cog == name,
156
  )
157
  )
158
+
159
  super().__init__(
160
  placeholder=placeholder,
161
  min_values=1,
 
202
  key = f"menu.category_desc.{name.lower()}"
203
  desc = await self.bot.get_text(self.guild_id, key)
204
  if desc == key:
205
+ desc = _CATEGORY_DESCRIPTIONS.get(name, f"📂 {name} commands")
206
  cog_desc_map[name] = desc
207
  self.clear_items()
208
  self.add_item(
 
221
  refresh_label = await self.bot.get_text(self.guild_id, "menu.refresh")
222
  invite_label = await self.bot.get_text(self.guild_id, "menu.invite_button")
223
  self.add_item(RefreshButton(self, refresh_label))
224
+ self.add_item(QuickCategoryButton(self, "Economy | الاقتصاد", "Economy", "💰", row=1))
225
+ self.add_item(QuickCategoryButton(self, "Music | الموسيقى", "Music", "🎵", row=1))
226
+ self.add_item(QuickCategoryButton(self, "Admin | الإدارة", "Admin", "🛡️", row=1))
227
+ self.add_item(QuickCategoryButton(self, "Utility | الأدوات", "Utility", "ℹ️", row=1))
228
+ self.add_item(QuickCategoryButton(self, "Community | المجتمع", "Community", "💡", row=2))
229
+ self.add_item(QuickCategoryButton(self, "AI | الذكاء", "AISuite", "🤖", row=2))
230
+ self.add_item(QuickCategoryButton(self, "Config | الإعداد", "Configuration", "⚙️", row=2))
231
  if self.page > 0:
232
  self.add_item(PageButton(self, "prev"))
233
  if self._has_next_page(self.selected_cog):
 
366
  "reload": "menu.cmd.reload",
367
  "sync": "menu.cmd.sync",
368
  "shutdown": "menu.cmd.shutdown",
369
+ "bot_emojis": "menu.cmd.emoji_scan",
370
+ "emoji_scan": "menu.cmd.emoji_scan",
371
 
372
  # Verification commands
373
  "verify": "menu.cmd.verify",
 
420
  "roulette": "menu.cmd.roulette",
421
  "rpg": "menu.cmd.rpg",
422
 
423
+ # AI Admin commands
424
+ "ai_admin": "menu.cmd.ai_admin",
425
+ "ai_help": "menu.cmd.ai_help",
426
+ # Additional commands/groups to keep menu fully mapped
427
+ "admin": "menu.cmd.admin_panel",
428
+ "admin_panel": "menu.cmd.admin_panel",
429
+ "shield_level": "menu.cmd.shield_level",
430
+ "shield_state": "menu.cmd.shield_state",
431
+ "econ_admin": "menu.cmd.econ_admin",
432
+ "econadmin": "menu.cmd.econ_admin",
433
+ "economy_admin": "menu.cmd.econ_admin",
434
+ "giveaway": "menu.cmd.giveaway",
435
+ "ticket": "menu.cmd.ticket",
436
+ "poll_legacy": "menu.cmd.poll",
437
+ "setpollchannel": "menu.cmd.set_pollchannel",
438
+ "setsuggestionchannel": "menu.cmd.set_suggestions",
439
+ "set_freegame": "menu.cmd.set_freegames",
440
+ "setupserver": "menu.cmd.setupserver",
441
+ "organizechannels": "menu.cmd.organizechannels",
442
+ "backup_panel": "menu.cmd.backup_panel",
443
+ "system_audit": "menu.cmd.system_audit",
444
+ "wisdom_today": "menu.cmd.wisdom_today",
445
+ "freegames": "menu.cmd.free_games",
446
+ "playlists": "menu.cmd.music_playlist",
447
+ "music": "menu.cmd.music_panel",
448
+ "profile": "menu.cmd.profile",
449
+ "rps": "menu.cmd.rps",
450
+ "guess": "menu.cmd.guess",
451
+ "make_event": "menu.cmd.make_event",
452
+ "gambling_panel": "menu.cmd.gambling_panel",
453
+ "add_scam_image": "menu.cmd.add_scam_image",
454
+ "set_banner": "menu.cmd.set_banner",
455
+ "view_banner": "menu.cmd.view_banner",
456
+ "remove_banner": "menu.cmd.remove_banner",
457
+ "banner_help": "menu.cmd.banner_help",
458
+ "boardgames": "menu.cmd.boardgames",
459
+ "board_start": "menu.cmd.board_start",
460
+ "board_move": "menu.cmd.board_move",
461
+ "board_forfeit": "menu.cmd.board_forfeit",
462
+ "games_panel": "menu.cmd.games_panel",
463
+ "chess": "menu.cmd.chess",
464
+ "checkers": "menu.cmd.checkers",
465
+ "connect4": "menu.cmd.connect4",
466
+ "othello": "menu.cmd.othello",
467
+ "start": "menu.cmd.menu",
468
+ "command_fill": "menu.cmd.menu",
469
+ "tournament_lb": "menu.cmd.tournament_lb",
470
+ "aisetup": "menu.cmd.ai",
471
+ }
472
  return key_mapping.get(norm, f"menu.cmd.{norm}")
473
 
474
  async def _format_commands(self, guild_id: int | None, cmds: list[commands.Command], max_lines: int = 20, max_chars: int = 980) -> str:
 
491
 
492
  if translated_desc == desc_key or (json_key and translated_desc == json_key):
493
  fallback_desc = (cmd.description or cmd.help or "").strip()
494
+ desc = fallback_desc if fallback_desc else "-"
495
  else:
496
  desc = translated_desc
497
 
498
  is_hybrid = isinstance(cmd, commands.HybridCommand)
499
  invoke = f"`/{cmd.qualified_name}`" if is_hybrid else f"`!{cmd.qualified_name}`"
500
+ line = f"- {invoke} - {desc[:78]}"
501
  projected = used + len(line) + (1 if lines else 0)
502
 
503
  if projected > max_chars:
 
508
  if len(lines) >= max_lines:
509
  break
510
 
511
+ if len(lines) < len([c for c in cmds if not c.hidden]):
512
+ suffix = "\n- ------------"
513
+ if used + len(suffix) <= max_chars:
514
+ lines.append("- ------------")
515
+
516
+ return "\n".join(lines)
517
 
518
  def _visible_commands_page(self, selected_cog: str) -> tuple[list[commands.Command], int]:
519
  commands_list = sorted(self._selected_commands(selected_cog), key=lambda c: c.qualified_name)
 
547
 
548
  lines: list[str] = []
549
  for index, (name, count) in enumerate(stats[:8], start=1):
550
+ badge = {1: "🥇", 2: "🥈", 3: "🥉"}.get(index, f"#{index}")
551
+ emoji = _CATEGORY_EMOJIS.get(name, "📂")
552
 
553
  if lang == "ar":
554
+ lines.append(f"{badge} {emoji} **{name}** `{count}` أمر")
555
  else:
556
+ lines.append(f"{badge} {emoji} **{name}** `{count}` cmds")
557
 
558
  if len(stats) > 8:
559
+ lines.append(" ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈")
560
 
561
  return "\n".join(lines)
562
 
 
573
 
574
  if lang == "ar":
575
  pieces = [
576
+ f"📊 **إجمالي الأوامر:** `{total_commands}`",
577
+ f"📂 **الفئات:** `{category_count}`",
578
+ f" **المحدد:** {selection_label} (`{visible_commands}` أمر)",
579
+ f"📈 {progress_bar(visible_commands, total_commands, size=14)}",
580
  ]
581
  else:
582
  pieces = [
583
+ f"📊 **Total Commands:** `{total_commands}`",
584
+ f"📂 **Categories:** `{category_count}`",
585
+ f" **Selected:** {selection_label} (`{visible_commands}` cmds)",
586
+ f"📈 {progress_bar(visible_commands, total_commands, size=14)}",
587
  ]
588
  return "\n".join(pieces)
589
 
 
642
  f"? {await self.bot.get_text(guild_id, 'menu.tip_line_2')}\n"
643
  f"? {await self.bot.get_text(guild_id, 'menu.tip_line_3')}"
644
  )
645
+ quick = (
646
+ f"- `/music_panel` - {await self.bot.get_text(guild_id, 'menu.quick_music')}\n"
647
+ f"- `/gamehub` - {await self.bot.get_text(guild_id, 'menu.quick_gamehub')}\n"
648
+ f"- `/tournament panel` - {await self.bot.get_text(guild_id, 'menu.quick_tournament')}\n"
649
+ f"- `/economy` - {await self.bot.get_text(guild_id, 'menu.quick_economy')}"
650
+ )
651
+ quick_ai = await self.bot.get_text(guild_id, 'menu.quick_ai')
652
+ updates = (
653
+ f"- `/admin emoji clone` - {await self.bot.get_text(guild_id, 'menu.update_cloneemoji')}\n"
654
+ f"- `/admin shield add_image` - {await self.bot.get_text(guild_id, 'menu.update_shield_image')}\n"
655
+ f"- `/poll create` - {await self.bot.get_text(guild_id, 'menu.update_poll_group')}\n"
656
+ f"- `/economy deposit` - {await self.bot.get_text(guild_id, 'menu.update_economy_group')}\n"
657
+ f"- `/ai execute` - {quick_ai if quick_ai != 'menu.quick_ai' else 'AI admin request'}"
658
+ )
659
+ footer = "Powered by BOT- AI Suite"
660
 
661
  top_divider = panel_divider("cyan")
662
  mid_divider = panel_divider("purple")
 
682
  slash_budget = f"{slash_used}/100"
683
  stats_grid = quick_stats_grid(
684
  [
685
+ ("Guilds", str(total_guilds), "G"),
686
+ ("Members", f"{total_members:,}", "M"),
687
+ ("Latency", latency_ms, "L"),
688
+ ("Slash", slash_budget, "S"),
689
  ],
690
  columns=2,
691
  )
 
697
  inline=True,
698
  )
699
 
700
+ emoji = _CATEGORY_EMOJIS.get(selected_cog, "C")
701
  none_text = await self.bot.get_text(guild_id, "menu.none")
702
  embed.add_field(
703
  name=f"{emoji} {selected_label}",
 
710
  embed.add_field(name=updates_title, value=updates, inline=False)
711
 
712
  embed.set_footer(text=footer)
713
+ embed.description = f"{embed.description}\n\nPage {self.page + 1}/{total_pages}"
714
  return embed
715
 
716
 
 
744
 
745
  async def callback(self, interaction: discord.Interaction) -> None:
746
  await interaction.response.defer()
747
+ guild_id = interaction.guild.id if interaction.guild else None
748
+ self.label = await self.parent_view.bot.get_text(guild_id, "menu.refresh")
749
  await self.parent_view.setup_items()
750
  embed = await self.parent_view.build_embed(guild_id, "__all__")
751
  await interaction.followup.edit_message(interaction.message.id, embed=embed, view=self.parent_view)
 
777
  self.parent_view = parent
778
  self.direction = direction
779
  label = "Previous" if direction == "prev" else "Next"
780
+ emoji = "⬅️" if direction == "prev" else "➡️"
781
  super().__init__(label=label, emoji=emoji, style=discord.ButtonStyle.secondary)
782
 
783
  async def callback(self, interaction: discord.Interaction) -> None:
 
798
  def __init__(self, bot: commands.Bot) -> None:
799
  self.bot = bot
800
 
801
+ async def cog_load(self) -> None:
802
+ self.bot.add_view(CommandsMenuView(self.bot))
803
 
804
+ @commands.hybrid_command(name="menu", description=get_cmd_desc("commands.tools.menu_desc"))
805
  async def menu(self, ctx: commands.Context) -> None:
806
  """Display the beautiful command menu."""
807
  if ctx.interaction and not ctx.interaction.response.is_done():
 
815
  else:
816
  await ctx.reply(embed=embed, view=view)
817
 
818
+ @commands.hybrid_command(name="start", description=get_cmd_desc("commands.tools.start_desc"), with_app_command=False)
819
  async def start_menu(self, ctx: commands.Context) -> None:
820
  await self.menu(ctx)
821
 
822
  async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None:
823
  embed = ImperialMotaz.craft_embed(
824
+ title="⚠️ Command Error | خطأ في الأمر",
825
+ description=f" {str(error)[:1000]} ",
826
  color=discord.Color(0x2B2D31),
827
+ footer="🏮 Powered by BOT- AI Suite 🏮",
828
  )
829
  try:
830
  await ctx.reply(embed=embed)
bot/cogs/observability.py CHANGED
@@ -1,16 +1,17 @@
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
 
@@ -46,17 +47,17 @@ class Observability(commands.Cog):
46
  names = sorted({c.qualified_name for c in self.bot.commands} | {c.name for c in self.bot.commands})
47
  return difflib.get_close_matches(name, names, n=5, cutoff=0.4)
48
 
49
- async def _safe_reply(self, ctx: commands.Context, message: str) -> None:
50
- try:
51
- await ctx.reply(message)
52
- except (discord.NotFound, discord.InteractionResponded):
53
- if ctx.channel:
54
- await ctx.channel.send(message)
55
- except discord.HTTPException as exc:
56
- if exc.code not in {10062, 40060}:
57
- raise
58
- if ctx.channel:
59
- await ctx.channel.send(message)
60
 
61
  @commands.Cog.listener()
62
  async def on_command_error(self, ctx: commands.Context, error: Exception) -> None:
@@ -124,8 +125,8 @@ class Observability(commands.Cog):
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,70 +144,70 @@ class Observability(commands.Cog):
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:
 
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
+ from bot.i18n import get_cmd_desc
10
  import discord
11
  from discord.ext import commands
12
 
13
 
14
+ class Observability(commands.Cog):
15
  def __init__(self, bot: commands.Bot) -> None:
16
  self.bot = bot
17
 
 
47
  names = sorted({c.qualified_name for c in self.bot.commands} | {c.name for c in self.bot.commands})
48
  return difflib.get_close_matches(name, names, n=5, cutoff=0.4)
49
 
50
+ async def _safe_reply(self, ctx: commands.Context, message: str) -> None:
51
+ try:
52
+ await ctx.reply(message)
53
+ except (discord.NotFound, discord.InteractionResponded):
54
+ if ctx.channel:
55
+ await ctx.channel.send(message)
56
+ except discord.HTTPException as exc:
57
+ if exc.code not in {10062, 40060}:
58
+ raise
59
+ if ctx.channel:
60
+ await ctx.channel.send(message)
61
 
62
  @commands.Cog.listener()
63
  async def on_command_error(self, ctx: commands.Context, error: Exception) -> None:
 
125
  embed.add_field(name="Error", value=f"```py\n{tb[-900:]}\n```", inline=False)
126
  await channel.send(embed=embed)
127
 
128
+ @commands.hybrid_command(name="command_fill", description=get_cmd_desc("commands.tools.command_fill_desc"))
129
+ async def command_fill(self, ctx: commands.Context, *, command_name: str) -> None:
130
  command = self.bot.get_command(command_name.strip())
131
  if not command:
132
  close = self._closest_commands(command_name.strip())
 
144
  usage = f"{ctx.prefix}{command.qualified_name} " + " ".join(pieces)
145
  embed = discord.Embed(title="🧩 Command Fill Helper", color=discord.Color.blurple())
146
  embed.add_field(name="Usage", value=f"`{usage.strip()}`", inline=False)
147
+ embed.add_field(name="Argument hints", value="\n".join(details) if details else "No arguments", inline=False)
148
+ await ctx.reply(embed=embed)
149
+
150
+ @commands.hybrid_command(name="system_audit", description=get_cmd_desc("commands.tools.system_audit_desc"))
151
+ @commands.has_permissions(manage_guild=True)
152
+ async def system_audit(self, ctx: commands.Context) -> None:
153
+ await self._safe_reply(ctx, "Running system audit...")
154
+
155
+ total_commands = len(self.bot.commands)
156
+ loaded_cogs = len(self.bot.cogs)
157
+
158
+ row = await self.bot.db.fetchone("PRAGMA table_info(guild_config)")
159
+ has_guild_config = bool(row)
160
+ cols = await self.bot.db.fetchall("PRAGMA table_info(guild_config)")
161
+ col_names = {str(r[1]) for r in cols} if cols else set()
162
+ has_banner_col = "custom_banner_url" in col_names
163
+ has_lang_col = "guild_language" in col_names
164
+
165
+ locale_dir = Path("bot/locales")
166
+ locale_files = sorted(p.name for p in locale_dir.glob("*.json")) if locale_dir.exists() else []
167
+ locale_issues = 0
168
+ if locale_files:
169
+ try:
170
+ base = json.loads((locale_dir / "en.json").read_text(encoding="utf-8"))
171
+ def flatten(obj, prefix=""):
172
+ out = {}
173
+ if isinstance(obj, dict):
174
+ for k, v in obj.items():
175
+ key = f"{prefix}.{k}" if prefix else k
176
+ out.update(flatten(v, key))
177
+ else:
178
+ out[prefix] = obj
179
+ return out
180
+ base_keys = set(flatten(base).keys())
181
+ for name in locale_files:
182
+ data = json.loads((locale_dir / name).read_text(encoding="utf-8"))
183
+ keys = set(flatten(data).keys())
184
+ if base_keys - keys:
185
+ locale_issues += 1
186
+ except Exception:
187
+ locale_issues += 1
188
+
189
+ checks = [
190
+ ("Commands registered", total_commands >= 50, str(total_commands)),
191
+ ("Cogs loaded", loaded_cogs >= 8, str(loaded_cogs)),
192
+ ("guild_config table", has_guild_config, "ok" if has_guild_config else "missing"),
193
+ ("guild_language column", has_lang_col, "ok" if has_lang_col else "missing"),
194
+ ("custom_banner_url column", has_banner_col, "ok" if has_banner_col else "missing"),
195
+ ("Locale files", bool(locale_files), f"{len(locale_files)} files"),
196
+ ("Locale consistency", locale_issues == 0, "ok" if locale_issues == 0 else f"{locale_issues} issue(s)"),
197
+ ]
198
+
199
+ passed = sum(1 for _, ok, _ in checks if ok)
200
+ status = "HEALTHY" if passed == len(checks) else "NEEDS ATTENTION"
201
+ color = discord.Color.green() if passed == len(checks) else discord.Color.orange()
202
+ lines = [f"{'✅' if ok else '⚠️'} {name}: `{detail}`" for name, ok, detail in checks]
203
+
204
+ embed = discord.Embed(
205
+ title=f"System Audit: {status}",
206
+ description="\n".join(lines),
207
+ color=color,
208
+ )
209
+ embed.set_footer(text=f"Passed {passed}/{len(checks)} checks")
210
+ await ctx.reply(embed=embed)
211
 
212
 
213
  async def setup(bot: commands.Bot) -> None:
bot/cogs/server_manager.py CHANGED
@@ -3,6 +3,8 @@ from __future__ import annotations
3
  import discord
4
  from discord.ext import commands
5
 
 
 
6
 
7
  class ServerManager(commands.Cog):
8
  def __init__(self, bot: commands.Bot) -> None:
@@ -30,10 +32,10 @@ class ServerManager(commands.Cog):
30
  await channel.edit(category=category)
31
  return channel
32
 
33
- @commands.hybrid_command(name="setupserver", description="Create an organized professional gaming server structure")
34
- @commands.has_permissions(administrator=True)
35
- async def setupserver_cmd(self, ctx: commands.Context) -> None:
36
- guild = ctx.guild
37
 
38
  category_general = await self.ensure_category(guild, "📌・الإدارة")
39
  category_community = await self.ensure_category(guild, "💬・المجتمع")
@@ -98,7 +100,7 @@ class ServerManager(commands.Cog):
98
  embed.add_field(name="Core", value=f"{welcome.mention} | {logs.mention} | {daily.mention} | {suggestions.mention}", inline=False)
99
  await ctx.reply(embed=embed)
100
 
101
- @commands.hybrid_command(name="organizechannels", description="Move current channels under suitable categories")
102
  @commands.has_permissions(administrator=True)
103
  async def organize_channels(self, ctx: commands.Context) -> None:
104
  guild = ctx.guild
 
3
  import discord
4
  from discord.ext import commands
5
 
6
+ from bot.i18n import get_cmd_desc
7
+
8
 
9
  class ServerManager(commands.Cog):
10
  def __init__(self, bot: commands.Bot) -> None:
 
32
  await channel.edit(category=category)
33
  return channel
34
 
35
+ @commands.hybrid_command(name="setupserver", description=get_cmd_desc("commands.tools.setupserver_desc"))
36
+ @commands.has_permissions(administrator=True)
37
+ async def setupserver_cmd(self, ctx: commands.Context) -> None:
38
+ guild = ctx.guild
39
 
40
  category_general = await self.ensure_category(guild, "📌・الإدارة")
41
  category_community = await self.ensure_category(guild, "💬・المجتمع")
 
100
  embed.add_field(name="Core", value=f"{welcome.mention} | {logs.mention} | {daily.mention} | {suggestions.mention}", inline=False)
101
  await ctx.reply(embed=embed)
102
 
103
+ @commands.hybrid_command(name="organizechannels", description=get_cmd_desc("commands.tools.organizechannels_desc"))
104
  @commands.has_permissions(administrator=True)
105
  async def organize_channels(self, ctx: commands.Context) -> None:
106
  guild = ctx.guild
bot/cogs/utility.py CHANGED
@@ -1,358 +1,556 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import time
5
-
6
- import discord
7
- import psutil
8
- from discord.ext import commands
9
-
10
- from bot.theme import NEON_CYAN, NEON_PINK, NEON_PURPLE, progress_bar, add_banner_to_embed
11
- from bot.emojis import ui
12
- from bot.emojis import ui
13
-
14
-
15
- # ═══════════════════════════════════════════════════════════════════════════════
16
- # AUTO REFRESH MIXIN - ميزة التحديث التلقائي
17
- # ═══════════════════════════════════════════════════════════════════════════════
18
-
19
- class AutoRefreshMixin:
20
- """Mixin class for auto-refresh functionality."""
21
-
22
- _refresh_interval: int = 4
23
- _refresh_task: asyncio.Task = None
24
- _message: discord.Message = None
25
- _stopped: bool = False
26
- _consecutive_failures: int = 0
27
-
28
- async def start_auto_refresh(self, message: discord.Message) -> None:
29
- """Start auto-refresh task."""
30
- self._message = message
31
- self._stopped = False
32
- self._refresh_task = asyncio.create_task(self._auto_refresh_loop())
33
-
34
- async def _auto_refresh_loop(self) -> None:
35
- """Auto refresh loop."""
36
- while not self._stopped:
37
- try:
38
- await asyncio.sleep(self._refresh_interval)
39
- if self._stopped or not self._message:
40
- break
41
- # Build new embed
42
- embed = await self._build_refresh_embed()
43
- await self._message.edit(embed=embed, view=self)
44
- self._consecutive_failures = 0
45
- except discord.NotFound:
46
- break
47
- except discord.HTTPException:
48
- self._consecutive_failures += 1
49
- if self._consecutive_failures >= 5:
50
- break
51
- await asyncio.sleep(2)
52
- continue
53
- except asyncio.CancelledError:
54
- break
55
- except Exception:
56
- self._consecutive_failures += 1
57
- if self._consecutive_failures >= 5:
58
- break
59
- await asyncio.sleep(2)
60
-
61
- def stop_refresh(self) -> None:
62
- """Stop auto-refresh."""
63
- self._stopped = True
64
- if self._refresh_task and not self._refresh_task.done():
65
- self._refresh_task.cancel()
66
-
67
- async def _build_refresh_embed(self) -> discord.Embed:
68
- """Override this method to build refresh embed."""
69
- raise NotImplementedError
70
-
71
-
72
- class ServerInfoView(discord.ui.View, AutoRefreshMixin):
73
- def __init__(self, cog: "Utility") -> None:
74
- discord.ui.View.__init__(self, timeout=None)
75
- AutoRefreshMixin.__init__(self)
76
- self.cog = cog
77
-
78
- @discord.ui.button(label="Refresh", emoji=ui("refresh"), style=discord.ButtonStyle.blurple, custom_id="utility:serverinfo:refresh")
79
- async def refresh(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
80
- if not interaction.guild:
81
- await interaction.response.send_message("Server only.", ephemeral=True)
82
- return
83
- self._message = interaction.message
84
- self.refresh.label = await self.cog.bot.tr(interaction.guild.id, "utility.refresh")
85
- embed = await self.cog.build_serverinfo_embed(interaction.guild)
86
- try:
87
- await interaction.response.edit_message(embed=embed, view=self)
88
- except (discord.NotFound, discord.InteractionResponded):
89
- if interaction.message:
90
- await interaction.message.edit(embed=embed, view=self)
91
- except discord.HTTPException:
92
- if interaction.message:
93
- await interaction.message.edit(embed=embed, view=self)
94
-
95
- async def _build_refresh_embed(self) -> discord.Embed:
96
- if self._message and self._message.guild:
97
- return await self.cog.build_serverinfo_embed(self._message.guild)
98
- return discord.Embed(title="Server Info", description="Server panel is ready.")
99
-
100
-
101
- class UserInfoView(discord.ui.View):
102
- def __init__(self, cog: "Utility", member_id: int) -> None:
103
- super().__init__(timeout=None)
104
- self.cog = cog
105
- self.member_id = member_id
106
-
107
- @discord.ui.button(label="Refresh", emoji=ui("refresh"), style=discord.ButtonStyle.blurple)
108
- async def refresh(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
109
- if not interaction.guild:
110
- await interaction.response.send_message("Server only.", ephemeral=True)
111
- return
112
- member = interaction.guild.get_member(self.member_id)
113
- if not member:
114
- await interaction.response.send_message("Member not found.", ephemeral=True)
115
- return
116
- self.refresh.label = await self.cog.bot.tr(interaction.guild.id, "utility.refresh")
117
- embed = await self.cog.build_userinfo_embed(interaction.guild.id, member)
118
- await interaction.response.edit_message(embed=embed, view=self)
119
-
120
-
121
-
122
-
123
- class BotStatsView(discord.ui.View, AutoRefreshMixin):
124
- def __init__(self, cog: "Utility", guild_id: int | None = None) -> None:
125
- discord.ui.View.__init__(self, timeout=None)
126
- AutoRefreshMixin.__init__(self)
127
- self.cog = cog
128
- self.guild_id = guild_id
129
-
130
- @discord.ui.button(label="Refresh", emoji="⛩️", style=discord.ButtonStyle.blurple, custom_id="utility:botstats:refresh")
131
- async def refresh(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
132
- guild_id = interaction.guild.id if interaction.guild else None
133
- self.refresh.label = await self.cog.bot.tr(guild_id, "utility.refresh")
134
- self.guild_id = guild_id
135
- embed = await self._build_refresh_embed()
136
- try:
137
- await interaction.response.edit_message(embed=embed, view=self)
138
- except (discord.NotFound, discord.InteractionResponded):
139
- if interaction.message:
140
- await interaction.message.edit(embed=embed, view=self)
141
- except discord.HTTPException:
142
- if interaction.message:
143
- await interaction.message.edit(embed=embed, view=self)
144
-
145
- async def _build_refresh_embed(self) -> discord.Embed:
146
- return await self.cog.build_botstats_embed(self.guild_id)
147
-
148
-
149
-
150
-
151
- class UtilityPollVoteView(discord.ui.View):
152
- def __init__(self, *, question: str, options: list[str], author_id: int) -> None:
153
- super().__init__(timeout=None)
154
- self.question = question
155
- self.options = options
156
- self.author_id = author_id
157
- self.voters: dict[int, int] = {}
158
- for idx, option in enumerate(options):
159
- btn = discord.ui.Button(label=option[:80], style=discord.ButtonStyle.secondary, custom_id=f"poll:{idx}")
160
-
161
- async def _callback(interaction: discord.Interaction, i: int = idx) -> None:
162
- self.voters[interaction.user.id] = i
163
- await interaction.response.send_message(f"✅ Vote saved: **{self.options[i]}**", ephemeral=True)
164
- if self.message:
165
- await self.message.edit(embed=self._build_embed(), view=self)
166
-
167
- btn.callback = _callback
168
- self.add_item(btn)
169
- self.message: discord.Message | None = None
170
-
171
- def _build_embed(self) -> discord.Embed:
172
- total = len(self.voters)
173
- embed = discord.Embed(title="🗳️ Community Poll", description=f"### {self.question}", color=NEON_CYAN)
174
- for idx, option in enumerate(self.options):
175
- count = sum(1 for choice in self.voters.values() if choice == idx)
176
- ratio = 0 if total == 0 else int((count / total) * 100)
177
- bar = "█" * max(1, ratio // 10) if total else "-"
178
- embed.add_field(name=option, value=f"{count} votes {ratio}%\n`{bar}`", inline=False)
179
- embed.set_footer(text=f"Total voters: {total} • Poll by <@{self.author_id}> • You can change your vote any time")
180
- return embed
181
-
182
- class Utility(commands.Cog):
183
- def __init__(self, bot: commands.Bot) -> None:
184
- self.bot = bot
185
- self.started_at = time.time()
186
-
187
- async def cog_load(self) -> None:
188
- # Register persistent views for static panels.
189
- self.bot.add_view(ServerInfoView(self))
190
- self.bot.add_view(BotStatsView(self))
191
-
192
- async def build_serverinfo_embed(self, guild: discord.Guild) -> discord.Embed:
193
- text_channels = len(guild.text_channels)
194
- voice_channels = len(guild.voice_channels)
195
- categories = len(guild.categories)
196
- bots = sum(1 for m in guild.members if m.bot)
197
- humans = (guild.member_count or 0) - bots
198
- owner = guild.owner.mention if guild.owner else "Unknown"
199
- created = f"<t:{int(guild.created_at.timestamp())}:F>\n<t:{int(guild.created_at.timestamp())}:R>"
200
-
201
- embed = discord.Embed(
202
- title=f"╔════╗ {await self.bot.tr(guild.id, 'utility.serverinfo.title')} ╗════",
203
- description=f"**{guild.name}**\nOwner: {owner}",
204
- color=NEON_CYAN,
205
- )
206
- embed.add_field(name=await self.bot.tr(guild.id, "utility.serverinfo.members"), value=f"Total: {guild.member_count}\nHumans: {humans}\nBots: {bots}", inline=True)
207
- embed.add_field(name=await self.bot.tr(guild.id, "utility.serverinfo.channels"), value=f"Total: {len(guild.channels)}\nText: {text_channels}\nVoice: {voice_channels}\nCategories: {categories}", inline=True)
208
- embed.add_field(name=await self.bot.tr(guild.id, "utility.serverinfo.roles"), value=str(len(guild.roles)), inline=True)
209
- embed.add_field(name=await self.bot.tr(guild.id, "utility.serverinfo.boost"), value=f"Tier {guild.premium_tier}\nBoosts: {guild.premium_subscription_count or 0}", inline=True)
210
- embed.add_field(name="Created", value=created, inline=False)
211
- embed.add_field(name="Server ID", value=str(guild.id), inline=False)
212
-
213
- # Add server banner if available
214
- if guild.banner:
215
- embed.set_image(url=guild.banner.url)
216
-
217
- if guild.icon:
218
- embed.set_thumbnail(url=guild.icon.url)
219
- embed.timestamp = discord.utils.utcnow()
220
- return embed
221
-
222
- async def build_userinfo_embed(self, guild_id: int | None, member: discord.Member) -> discord.Embed:
223
- embed = discord.Embed(title=await self.bot.tr(guild_id, "utility.userinfo.title"), color=NEON_PINK)
224
- embed.add_field(name=await self.bot.tr(guild_id, "utility.userinfo.id"), value=str(member.id), inline=False)
225
- embed.add_field(name=await self.bot.tr(guild_id, "utility.userinfo.joined"), value=str(member.joined_at), inline=False)
226
- embed.add_field(name=await self.bot.tr(guild_id, "utility.userinfo.created"), value=str(member.created_at), inline=False)
227
- if guild_id:
228
- row = await self.bot.db.fetchone(
229
- "SELECT xp, level FROM user_xp WHERE guild_id = ? AND user_id = ?",
230
- guild_id,
231
- member.id,
232
- )
233
- xp, level = row if row else (0, 1)
234
- target = max(1, level * 150)
235
- remaining = max(0, target - xp)
236
- embed.add_field(name="Level", value=str(level), inline=True)
237
- embed.add_field(name="XP", value=f"{xp}/{target}", inline=True)
238
- embed.add_field(name="XP Progress", value=progress_bar(xp, target), inline=False)
239
- embed.add_field(name="XP to next level", value=str(remaining), inline=True)
240
- embed.set_thumbnail(url=member.display_avatar.url)
241
- embed.timestamp = discord.utils.utcnow()
242
- return embed
243
-
244
- async def build_botstats_embed(self, guild_id: int | None) -> discord.Embed:
245
- process = psutil.Process()
246
- mem = process.memory_info().rss / (1024 * 1024)
247
- uptime_seconds = int(time.time() - self.started_at)
248
- days, rem = divmod(uptime_seconds, 86400)
249
- hours, rem = divmod(rem, 3600)
250
- minutes, seconds = divmod(rem, 60)
251
- uptime = f"{days}d {hours}h {minutes}m {seconds}s"
252
- latency_ms = round(self.bot.latency * 1000)
253
-
254
- embed = discord.Embed(
255
- title=f"BOT- AI System\n╔════╗ {await self.bot.tr(guild_id, 'botstats.title')} ╗════",
256
- color=NEON_PURPLE,
257
- )
258
- embed.add_field(name=await self.bot.tr(guild_id, "botstats.servers"), value=str(len(self.bot.guilds)), inline=True)
259
- embed.add_field(name=await self.bot.tr(guild_id, "botstats.users"), value=str(len(self.bot.users)), inline=True)
260
- embed.add_field(name=await self.bot.tr(guild_id, "botstats.latency"), value=f"{latency_ms}ms", inline=True)
261
- embed.add_field(name=await self.bot.tr(guild_id, "botstats.cpu"), value=f"{psutil.cpu_percent()}%", inline=True)
262
- embed.add_field(name=await self.bot.tr(guild_id, "botstats.ram"), value=f"{mem:.1f}MB", inline=True)
263
- embed.add_field(name=await self.bot.tr(guild_id, "botstats.uptime"), value=uptime, inline=True)
264
- embed.timestamp = discord.utils.utcnow()
265
- return embed
266
-
267
- @commands.hybrid_command(name="serverinfo", description="Server info panel | لوحة معلومات السيرفر")
268
- async def serverinfo(self, ctx: commands.Context) -> None:
269
- g = ctx.guild
270
- if not g:
271
- await ctx.reply("Server only.")
272
- return
273
- embed = await self.build_serverinfo_embed(g)
274
- view = ServerInfoView(self)
275
- view.refresh.label = await self.bot.tr(g.id, "utility.refresh")
276
- msg = await ctx.reply(embed=embed, view=view)
277
- if msg is None and ctx.interaction:
278
- try:
279
- msg = await ctx.interaction.original_response()
280
- except Exception:
281
- msg = None
282
- view._message = msg
283
- if msg:
284
- await view.start_auto_refresh(msg)
285
-
286
- @commands.hybrid_command(name="userinfo", description="User info panel | لوحة معلومات العضو")
287
- async def userinfo(self, ctx: commands.Context, member: discord.Member | None = None) -> None:
288
- member = member or ctx.author
289
- embed = await self.build_userinfo_embed(ctx.guild.id if ctx.guild else None, member)
290
- view = UserInfoView(self, member.id)
291
- if ctx.guild:
292
- view.refresh.label = await self.bot.tr(ctx.guild.id, "utility.refresh")
293
- await ctx.reply(embed=embed, view=view)
294
-
295
- @commands.hybrid_command(name="poll_legacy", description="Create poll | إنشاء تصويت", hidden=True, with_app_command=False)
296
- async def poll(self, ctx: commands.Context, question: str, options: str = "") -> None:
297
- parts = [p.strip() for p in options.split("|") if p.strip()]
298
- if not parts:
299
- parts = ["Yes", "No", "Maybe", "Skip", "Later"]
300
- if len(parts) == 1:
301
- parts.extend(["Option 2", "Option 3", "Option 4"])
302
- elif len(parts) == 2:
303
- parts.extend(["Option 3", "Option 4", "Option 5"])
304
- parts = parts[:10]
305
- row = await self.bot.db.fetchone("SELECT poll_channel_id FROM guild_config WHERE guild_id = ?", ctx.guild.id) if ctx.guild else None
306
- channel = ctx.guild.get_channel(row[0]) if (ctx.guild and row and row[0]) else ctx.channel
307
- view = PollVoteView(question=question, options=parts, author_id=ctx.author.id)
308
- msg = await channel.send(embed=view._build_embed(), view=view)
309
- view.message = msg
310
- await ctx.reply(f"✅ Poll published in {channel.mention} with {len(parts)} options by {ctx.author.mention}.")
311
-
312
- @commands.hybrid_command(name="remind", description="Set reminder | تعيين تذكير")
313
- async def remind(self, ctx: commands.Context, seconds: int, *, text: str) -> None:
314
- seconds = max(5, min(seconds, 604800))
315
- due = int(time.time()) + seconds
316
- await self.bot.db.execute(
317
- "INSERT INTO reminders(user_id, channel_id, message, due_unix) VALUES (?, ?, ?, ?)",
318
- ctx.author.id,
319
- ctx.channel.id,
320
- text,
321
- due,
322
- )
323
- await ctx.reply(f"⏰ Reminder set in {seconds} seconds.")
324
-
325
- @commands.Cog.listener()
326
- async def on_message(self, message: discord.Message) -> None:
327
- if message.author.bot:
328
- return
329
- now = int(time.time())
330
- due_rows = await self.bot.db.fetchall(
331
- "SELECT id, user_id, channel_id, message FROM reminders WHERE due_unix <= ? LIMIT 20",
332
- now,
333
- )
334
- for rid, user_id, channel_id, msg in due_rows:
335
- channel = self.bot.get_channel(channel_id)
336
- if channel:
337
- await channel.send(f"⏰ <@{user_id}> reminder: {msg}")
338
- await self.bot.db.execute("DELETE FROM reminders WHERE id = ?", rid)
339
-
340
- @commands.hybrid_command(name="botstats", description="Bot stats | إحصائيات البوت")
341
- async def botstats(self, ctx: commands.Context) -> None:
342
- guild_id = ctx.guild.id if ctx.guild else None
343
- embed = await self.build_botstats_embed(guild_id)
344
- view = BotStatsView(self, guild_id)
345
- view.refresh.label = await self.bot.tr(guild_id, "utility.refresh")
346
- msg = await ctx.reply(embed=embed, view=view)
347
- if msg is None and ctx.interaction:
348
- try:
349
- msg = await ctx.interaction.original_response()
350
- except Exception:
351
- msg = None
352
- view._message = msg
353
- if msg:
354
- await view.start_auto_refresh(msg)
355
-
356
-
357
- async def setup(bot: commands.Bot) -> None:
358
- await bot.add_cog(Utility(bot))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+
6
+ import discord
7
+ import psutil
8
+ from discord.ext import commands
9
+
10
+ from bot.theme import NEON_CYAN, NEON_PINK, NEON_PURPLE, progress_bar, add_banner_to_embed
11
+ from bot.i18n import get_cmd_desc
12
+ from bot.emojis import ui
13
+ from bot.emojis import ui
14
+
15
+
16
+ # ═══════════════════════════════════════════════════════════════════════════════
17
+ # AUTO REFRESH MIXIN - ميزة التحديث التلقائي
18
+ # ═══════════════════════════════════════════════════════════════════════════════
19
+
20
+ class AutoRefreshMixin:
21
+ """Mixin class for auto-refresh functionality."""
22
+
23
+ _refresh_interval: int = 4
24
+ _refresh_task: asyncio.Task = None
25
+ _message: discord.Message = None
26
+ _stopped: bool = False
27
+ _consecutive_failures: int = 0
28
+
29
+ async def start_auto_refresh(self, message: discord.Message) -> None:
30
+ """Start auto-refresh task."""
31
+ self._message = message
32
+ self._stopped = False
33
+ self._refresh_task = asyncio.create_task(self._auto_refresh_loop())
34
+
35
+ async def _auto_refresh_loop(self) -> None:
36
+ """Auto refresh loop."""
37
+ while not self._stopped:
38
+ try:
39
+ await asyncio.sleep(self._refresh_interval)
40
+ if self._stopped or not self._message:
41
+ break
42
+ # Build new embed
43
+ embed = await self._build_refresh_embed()
44
+ await self._message.edit(embed=embed, view=self)
45
+ self._consecutive_failures = 0
46
+ except discord.NotFound:
47
+ break
48
+ except discord.HTTPException:
49
+ self._consecutive_failures += 1
50
+ if self._consecutive_failures >= 5:
51
+ break
52
+ await asyncio.sleep(2)
53
+ continue
54
+ except asyncio.CancelledError:
55
+ break
56
+ except Exception:
57
+ self._consecutive_failures += 1
58
+ if self._consecutive_failures >= 5:
59
+ break
60
+ await asyncio.sleep(2)
61
+
62
+ def stop_refresh(self) -> None:
63
+ """Stop auto-refresh."""
64
+ self._stopped = True
65
+ if self._refresh_task and not self._refresh_task.done():
66
+ self._refresh_task.cancel()
67
+
68
+ async def _build_refresh_embed(self) -> discord.Embed:
69
+ """Override this method to build refresh embed."""
70
+ raise NotImplementedError
71
+
72
+
73
+ class ServerInfoView(discord.ui.View, AutoRefreshMixin):
74
+ def __init__(self, cog: "Utility") -> None:
75
+ discord.ui.View.__init__(self, timeout=None)
76
+ AutoRefreshMixin.__init__(self)
77
+ self.cog = cog
78
+
79
+ @discord.ui.button(label="Refresh", emoji=ui("refresh"), style=discord.ButtonStyle.blurple, custom_id="utility:serverinfo:refresh")
80
+ async def refresh(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
81
+ if not interaction.guild:
82
+ await interaction.response.send_message("Server only.", ephemeral=True)
83
+ return
84
+ self._message = interaction.message
85
+ self.refresh.label = await self.cog.bot.tr(interaction.guild.id, "utility.refresh")
86
+ embed = await self.cog.build_serverinfo_embed(interaction.guild)
87
+ try:
88
+ await interaction.response.edit_message(embed=embed, view=self)
89
+ except (discord.NotFound, discord.InteractionResponded):
90
+ if interaction.message:
91
+ await interaction.message.edit(embed=embed, view=self)
92
+ except discord.HTTPException:
93
+ if interaction.message:
94
+ await interaction.message.edit(embed=embed, view=self)
95
+
96
+ async def _build_refresh_embed(self) -> discord.Embed:
97
+ if self._message and self._message.guild:
98
+ return await self.cog.build_serverinfo_embed(self._message.guild)
99
+ return discord.Embed(title="Server Info", description="Server panel is ready.")
100
+
101
+
102
+ class UserInfoView(discord.ui.View):
103
+ def __init__(self, cog: "Utility", member_id: int) -> None:
104
+ super().__init__(timeout=None)
105
+ self.cog = cog
106
+ self.member_id = member_id
107
+
108
+ @discord.ui.button(label="Refresh", emoji=ui("refresh"), style=discord.ButtonStyle.blurple)
109
+ async def refresh(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
110
+ if not interaction.guild:
111
+ await interaction.response.send_message("Server only.", ephemeral=True)
112
+ return
113
+ member = interaction.guild.get_member(self.member_id)
114
+ if not member:
115
+ await interaction.response.send_message("Member not found.", ephemeral=True)
116
+ return
117
+ self.refresh.label = await self.cog.bot.tr(interaction.guild.id, "utility.refresh")
118
+ embed = await self.cog.build_userinfo_embed(interaction.guild.id, member)
119
+ await interaction.response.edit_message(embed=embed, view=self)
120
+
121
+
122
+
123
+
124
+ class BotStatsView(discord.ui.View, AutoRefreshMixin):
125
+ def __init__(self, cog: "Utility", guild_id: int | None = None) -> None:
126
+ discord.ui.View.__init__(self, timeout=None)
127
+ AutoRefreshMixin.__init__(self)
128
+ self.cog = cog
129
+ self.guild_id = guild_id
130
+
131
+ @discord.ui.button(label="Refresh", emoji="⛩️", style=discord.ButtonStyle.blurple, custom_id="utility:botstats:refresh")
132
+ async def refresh(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
133
+ guild_id = interaction.guild.id if interaction.guild else None
134
+ self.refresh.label = await self.cog.bot.tr(guild_id, "utility.refresh")
135
+ self.guild_id = guild_id
136
+ embed = await self._build_refresh_embed()
137
+ try:
138
+ await interaction.response.edit_message(embed=embed, view=self)
139
+ except (discord.NotFound, discord.InteractionResponded):
140
+ if interaction.message:
141
+ await interaction.message.edit(embed=embed, view=self)
142
+ except discord.HTTPException:
143
+ if interaction.message:
144
+ await interaction.message.edit(embed=embed, view=self)
145
+
146
+ async def _build_refresh_embed(self) -> discord.Embed:
147
+ return await self.cog.build_botstats_embed(self.guild_id)
148
+
149
+
150
+
151
+
152
+ class UtilityPollVoteView(discord.ui.View):
153
+ def __init__(self, *, question: str, options: list[str], author_id: int) -> None:
154
+ super().__init__(timeout=None)
155
+ self.question = question
156
+ self.options = options
157
+ self.author_id = author_id
158
+ self.voters: dict[int, int] = {}
159
+ for idx, option in enumerate(options):
160
+ btn = discord.ui.Button(label=option[:80], style=discord.ButtonStyle.secondary, custom_id=f"poll:{idx}")
161
+
162
+ async def _callback(interaction: discord.Interaction, i: int = idx) -> None:
163
+ self.voters[interaction.user.id] = i
164
+ await interaction.response.send_message(f"✅ Vote saved: **{self.options[i]}**", ephemeral=True)
165
+ if self.message:
166
+ await self.message.edit(embed=self._build_embed(), view=self)
167
+
168
+ btn.callback = _callback
169
+ self.add_item(btn)
170
+ self.message: discord.Message | None = None
171
+
172
+ def _build_embed(self) -> discord.Embed:
173
+ total = len(self.voters)
174
+ embed = discord.Embed(title="🗳️ Community Poll", description=f"### {self.question}", color=NEON_CYAN)
175
+ for idx, option in enumerate(self.options):
176
+ count = sum(1 for choice in self.voters.values() if choice == idx)
177
+ ratio = 0 if total == 0 else int((count / total) * 100)
178
+ bar = "█" * max(1, ratio // 10) if total else "-"
179
+ embed.add_field(name=option, value=f"{count} votes • {ratio}%\n`{bar}`", inline=False)
180
+ embed.set_footer(text=f"Total voters: {total} • Poll by <@{self.author_id}> • You can change your vote any time")
181
+ return embed
182
+
183
+ class Utility(commands.Cog):
184
+ def __init__(self, bot: commands.Bot) -> None:
185
+ self.bot = bot
186
+ self.started_at = time.time()
187
+
188
+ async def cog_load(self) -> None:
189
+ # Register persistent views for static panels.
190
+ self.bot.add_view(ServerInfoView(self))
191
+ self.bot.add_view(BotStatsView(self))
192
+
193
+ async def build_serverinfo_embed(self, guild: discord.Guild) -> discord.Embed:
194
+ text_channels = len(guild.text_channels)
195
+ voice_channels = len(guild.voice_channels)
196
+ categories = len(guild.categories)
197
+ bots = sum(1 for m in guild.members if m.bot)
198
+ humans = (guild.member_count or 0) - bots
199
+ owner = guild.owner.mention if guild.owner else "Unknown"
200
+ created = f"<t:{int(guild.created_at.timestamp())}:F>\n<t:{int(guild.created_at.timestamp())}:R>"
201
+
202
+ embed = discord.Embed(
203
+ title=f"╔════╗ {await self.bot.tr(guild.id, 'utility.serverinfo.title')} ╗════",
204
+ description=f"**{guild.name}**\nOwner: {owner}",
205
+ color=NEON_CYAN,
206
+ )
207
+ embed.add_field(name=await self.bot.tr(guild.id, "utility.serverinfo.members"), value=f"Total: {guild.member_count}\nHumans: {humans}\nBots: {bots}", inline=True)
208
+ embed.add_field(name=await self.bot.tr(guild.id, "utility.serverinfo.channels"), value=f"Total: {len(guild.channels)}\nText: {text_channels}\nVoice: {voice_channels}\nCategories: {categories}", inline=True)
209
+ embed.add_field(name=await self.bot.tr(guild.id, "utility.serverinfo.roles"), value=str(len(guild.roles)), inline=True)
210
+ embed.add_field(name=await self.bot.tr(guild.id, "utility.serverinfo.boost"), value=f"Tier {guild.premium_tier}\nBoosts: {guild.premium_subscription_count or 0}", inline=True)
211
+ embed.add_field(name="Created", value=created, inline=False)
212
+ embed.add_field(name="Server ID", value=str(guild.id), inline=False)
213
+
214
+ # Add server banner if available
215
+ if guild.banner:
216
+ embed.set_image(url=guild.banner.url)
217
+
218
+ if guild.icon:
219
+ embed.set_thumbnail(url=guild.icon.url)
220
+ embed.timestamp = discord.utils.utcnow()
221
+ return embed
222
+
223
+ async def build_userinfo_embed(self, guild_id: int | None, member: discord.Member) -> discord.Embed:
224
+ embed = discord.Embed(title=await self.bot.tr(guild_id, "utility.userinfo.title"), color=NEON_PINK)
225
+ embed.add_field(name=await self.bot.tr(guild_id, "utility.userinfo.id"), value=str(member.id), inline=False)
226
+ embed.add_field(name=await self.bot.tr(guild_id, "utility.userinfo.joined"), value=str(member.joined_at), inline=False)
227
+ embed.add_field(name=await self.bot.tr(guild_id, "utility.userinfo.created"), value=str(member.created_at), inline=False)
228
+ if guild_id:
229
+ row = await self.bot.db.fetchone(
230
+ "SELECT xp, level FROM user_xp WHERE guild_id = ? AND user_id = ?",
231
+ guild_id,
232
+ member.id,
233
+ )
234
+ xp, level = row if row else (0, 1)
235
+ target = max(1, level * 150)
236
+ remaining = max(0, target - xp)
237
+ embed.add_field(name="Level", value=str(level), inline=True)
238
+ embed.add_field(name="XP", value=f"{xp}/{target}", inline=True)
239
+ embed.add_field(name="XP Progress", value=progress_bar(xp, target), inline=False)
240
+ embed.add_field(name="XP to next level", value=str(remaining), inline=True)
241
+ embed.set_thumbnail(url=member.display_avatar.url)
242
+ embed.timestamp = discord.utils.utcnow()
243
+ return embed
244
+
245
+ async def build_botstats_embed(self, guild_id: int | None) -> discord.Embed:
246
+ process = psutil.Process()
247
+ mem = process.memory_info().rss / (1024 * 1024)
248
+ uptime_seconds = int(time.time() - self.started_at)
249
+ days, rem = divmod(uptime_seconds, 86400)
250
+ hours, rem = divmod(rem, 3600)
251
+ minutes, seconds = divmod(rem, 60)
252
+ uptime = f"{days}d {hours}h {minutes}m {seconds}s"
253
+ latency_ms = round(self.bot.latency * 1000)
254
+
255
+ embed = discord.Embed(
256
+ title=f"BOT- AI System\n╔════╗ {await self.bot.tr(guild_id, 'botstats.title')} ╗════",
257
+ color=NEON_PURPLE,
258
+ )
259
+ embed.add_field(name=await self.bot.tr(guild_id, "botstats.servers"), value=str(len(self.bot.guilds)), inline=True)
260
+ embed.add_field(name=await self.bot.tr(guild_id, "botstats.users"), value=str(len(self.bot.users)), inline=True)
261
+ embed.add_field(name=await self.bot.tr(guild_id, "botstats.latency"), value=f"{latency_ms}ms", inline=True)
262
+ embed.add_field(name=await self.bot.tr(guild_id, "botstats.cpu"), value=f"{psutil.cpu_percent()}%", inline=True)
263
+ embed.add_field(name=await self.bot.tr(guild_id, "botstats.ram"), value=f"{mem:.1f}MB", inline=True)
264
+ embed.add_field(name=await self.bot.tr(guild_id, "botstats.uptime"), value=uptime, inline=True)
265
+ embed.timestamp = discord.utils.utcnow()
266
+ return embed
267
+
268
+ @commands.hybrid_command(name="serverinfo", description=get_cmd_desc("commands.tools.serverinfo_desc"))
269
+ async def serverinfo(self, ctx: commands.Context) -> None:
270
+ g = ctx.guild
271
+ if not g:
272
+ await ctx.reply("Server only.")
273
+ return
274
+ embed = await self.build_serverinfo_embed(g)
275
+ view = ServerInfoView(self)
276
+ view.refresh.label = await self.bot.tr(g.id, "utility.refresh")
277
+ msg = await ctx.reply(embed=embed, view=view)
278
+ if msg is None and ctx.interaction:
279
+ try:
280
+ msg = await ctx.interaction.original_response()
281
+ except Exception:
282
+ msg = None
283
+ view._message = msg
284
+ if msg:
285
+ await view.start_auto_refresh(msg)
286
+
287
+ @commands.hybrid_command(name="userinfo", description=get_cmd_desc("commands.tools.userinfo_desc"))
288
+ async def userinfo(self, ctx: commands.Context, member: discord.Member | None = None) -> None:
289
+ member = member or ctx.author
290
+ embed = await self.build_userinfo_embed(ctx.guild.id if ctx.guild else None, member)
291
+ view = UserInfoView(self, member.id)
292
+ if ctx.guild:
293
+ view.refresh.label = await self.bot.tr(ctx.guild.id, "utility.refresh")
294
+ await ctx.reply(embed=embed, view=view)
295
+
296
+ @commands.hybrid_command(name="poll_legacy", description=get_cmd_desc("commands.tools.poll_legacy_desc"), hidden=True, with_app_command=False)
297
+ async def poll(self, ctx: commands.Context, question: str, options: str = "") -> None:
298
+ parts = [p.strip() for p in options.split("|") if p.strip()]
299
+ if not parts:
300
+ parts = ["Yes", "No", "Maybe", "Skip", "Later"]
301
+ if len(parts) == 1:
302
+ parts.extend(["Option 2", "Option 3", "Option 4"])
303
+ elif len(parts) == 2:
304
+ parts.extend(["Option 3", "Option 4", "Option 5"])
305
+ parts = parts[:10]
306
+ row = await self.bot.db.fetchone("SELECT poll_channel_id FROM guild_config WHERE guild_id = ?", ctx.guild.id) if ctx.guild else None
307
+ channel = ctx.guild.get_channel(row[0]) if (ctx.guild and row and row[0]) else ctx.channel
308
+ view = PollVoteView(question=question, options=parts, author_id=ctx.author.id)
309
+ msg = await channel.send(embed=view._build_embed(), view=view)
310
+ view.message = msg
311
+ await ctx.reply(f"✅ Poll published in {channel.mention} with {len(parts)} options by {ctx.author.mention}.")
312
+
313
+ @commands.hybrid_command(name="remind", description=get_cmd_desc("commands.tools.remind_desc"))
314
+ async def remind(self, ctx: commands.Context, seconds: int, *, text: str) -> None:
315
+ seconds = max(5, min(seconds, 604800))
316
+ due = int(time.time()) + seconds
317
+ await self.bot.db.execute(
318
+ "INSERT INTO reminders(user_id, channel_id, message, due_unix) VALUES (?, ?, ?, ?)",
319
+ ctx.author.id,
320
+ ctx.channel.id,
321
+ text,
322
+ due,
323
+ )
324
+ await ctx.reply(f"⏰ Reminder set in {seconds} seconds.")
325
+
326
+ @commands.Cog.listener()
327
+ async def on_message(self, message: discord.Message) -> None:
328
+ if message.author.bot:
329
+ return
330
+ now = int(time.time())
331
+ due_rows = await self.bot.db.fetchall(
332
+ "SELECT id, user_id, channel_id, message FROM reminders WHERE due_unix <= ? LIMIT 20",
333
+ now,
334
+ )
335
+ for rid, user_id, channel_id, msg in due_rows:
336
+ channel = self.bot.get_channel(channel_id)
337
+ if channel:
338
+ await channel.send(f" <@{user_id}> reminder: {msg}")
339
+ await self.bot.db.execute("DELETE FROM reminders WHERE id = ?", rid)
340
+
341
+ @commands.hybrid_command(name="search", description=get_cmd_desc("commands.tools.search_desc"))
342
+ @discord.app_commands.describe(query="Search query | بحث")
343
+ async def search(self, ctx: commands.Context, query: str) -> None:
344
+ """Search YouTube and show results with preview."""
345
+ if ctx.interaction and not ctx.interaction.response.is_done():
346
+ await ctx.interaction.response.defer()
347
+
348
+ loading_embed = discord.Embed(
349
+ title="🔎 Searching YouTube...",
350
+ description=f"🔍 **{query}**\n⏳ Please wait...",
351
+ color=discord.Color.orange(),
352
+ )
353
+ loading_msg = await ctx.send(embed=loading_embed)
354
+
355
+ results = await _yt_search(query, max_results=25)
356
+
357
+ if not results:
358
+ error_embed = discord.Embed(
359
+ title="❌ No Results Found",
360
+ description=f"No videos found for **{query}**.\nTry a different search term.",
361
+ color=discord.Color.red(),
362
+ )
363
+ try:
364
+ await loading_msg.edit(embed=error_embed, view=None)
365
+ except discord.NotFound:
366
+ await ctx.send(embed=error_embed)
367
+ return
368
+
369
+ view = SearchView(self, results, query)
370
+ first = results[0]
371
+ preview_embed = discord.Embed(
372
+ title=f"📺 {first.get('title', 'Untitled')}",
373
+ description=(
374
+ f"**📺 Channel:** {first.get('channel', 'Unknown')}\n"
375
+ f"**👁️ Views:** {first.get('views', 'N/A')}\n"
376
+ f"**⏱️ Duration:** {first.get('duration', 'N/A')}\n\n"
377
+ f"🔗 **[Watch Video]({first.get('url', '')})**\n\n"
378
+ f"📋 **{len(results)}** results found — select one below:"
379
+ ),
380
+ url=first.get("url", ""),
381
+ color=discord.Color.red(),
382
+ )
383
+ thumbnail = first.get("thumbnail")
384
+ if thumbnail:
385
+ preview_embed.set_image(url=thumbnail)
386
+ preview_embed.set_footer(text=f"YouTube Search | صفحة 1 من {min(len(results), 25)}")
387
+
388
+ try:
389
+ await loading_msg.edit(embed=preview_embed, view=view)
390
+ except discord.NotFound:
391
+ await ctx.send(embed=preview_embed, view=view)
392
+
393
+ # ═══════════════════════════════════════════════════════════════════════════════
394
+ # YOUTUBE SEARCH COMMAND
395
+ # ═══════════════════════════════════════════════════════════════════════════════
396
+
397
+ _YT_AUTOCOMPLETE_CACHE: dict[str, list[str]] = {}
398
+
399
+
400
+ class SearchSelect(discord.ui.Select):
401
+ """Select a YouTube video from search results."""
402
+
403
+ def __init__(self, cog, results: list, query: str) -> None:
404
+ self.cog = cog
405
+ self.results = results
406
+ self.query = query
407
+ options = []
408
+ for i, vid in enumerate(results[:25]):
409
+ title = vid.get("title", "Untitled")[:100]
410
+ channel = vid.get("channel", "")[:50]
411
+ label = f"{title}"
412
+ desc = f"📺 {channel}" if channel else None
413
+ options.append(discord.SelectOption(label=label, description=desc, value=str(i), emoji="▶️"))
414
+ super().__init__(
415
+ placeholder="🔎 Select a video to preview...",
416
+ options=options,
417
+ )
418
+
419
+ async def callback(self, interaction: discord.Interaction) -> None:
420
+ idx = int(self.values[0])
421
+ vid = self.results[idx]
422
+ embed = discord.Embed(
423
+ title=f"📺 {vid.get('title', 'Untitled')}",
424
+ description=f"**Channel:** {vid.get('channel', 'Unknown')}\n**Views:** {vid.get('views', 'N/A')}\n**Duration:** {vid.get('duration', 'N/A')}\n\n🔗 **Link:** {vid.get('url', '')}",
425
+ url=vid.get("url", ""),
426
+ color=discord.Color.red(),
427
+ )
428
+ thumbnail = vid.get("thumbnail")
429
+ if thumbnail:
430
+ embed.set_image(url=thumbnail)
431
+ embed.set_footer(text="YouTube Search | ابحث في يوتيوب")
432
+ await interaction.response.send_message(embed=embed)
433
+
434
+
435
+ class SearchView(discord.ui.View):
436
+ def __init__(self, cog, results: list, query: str) -> None:
437
+ super().__init__(timeout=120)
438
+ self.add_item(SearchSelect(cog, results, query))
439
+
440
+
441
+ class YouTubeAutocomplete(discord.app_commands.Transformer):
442
+ """Transformer that provides YouTube search suggestions."""
443
+
444
+ async def transform(self, interaction: discord.Interaction, value: str) -> str:
445
+ return value
446
+
447
+ async def autocomplete(self, interaction: discord.Interaction, current: str) -> list[discord.app_commands.Choice[str]]:
448
+ if not current or len(current) < 2:
449
+ return [discord.app_commands.Choice(name="🔎 Type to search YouTube...", value="")]
450
+ cache_key = current.lower()[:50]
451
+ if cache_key in _YT_AUTOCOMPLETE_CACHE:
452
+ suggestions = _YT_AUTOCOMPLETE_CACHE[cache_key]
453
+ else:
454
+ suggestions = await _yt_autocomplete_fetch(cache_key)
455
+ _YT_AUTOCOMPLETE_CACHE[cache_key] = suggestions
456
+ choices = []
457
+ for s in suggestions[:25]:
458
+ name = s[:100]
459
+ if len(s) > 100:
460
+ name = s[:97] + "..."
461
+ choices.append(discord.app_commands.Choice(name=name, value=s))
462
+ if not choices:
463
+ choices.append(discord.app_commands.Choice(name=f"🔎 Search: {current}", value=current))
464
+ return choices
465
+
466
+
467
+ async def _yt_autocomplete_fetch(query: str) -> list[str]:
468
+ """Fetch YouTube search suggestions using the suggest endpoint."""
469
+ import aiohttp
470
+ url = f"http://suggestqueries.google.com/complete/search?client=firefox&ds=yt&q={query}"
471
+ try:
472
+ async with aiohttp.ClientSession() as session:
473
+ async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
474
+ if resp.status == 200:
475
+ data = await resp.json()
476
+ if len(data) >= 2:
477
+ return [str(s) for s in data[1] if isinstance(s, str)]
478
+ except Exception:
479
+ pass
480
+ return []
481
+
482
+
483
+ async def _yt_search(query: str, max_results: int = 25) -> list[dict]:
484
+ """Search YouTube and return video results."""
485
+ import aiohttp
486
+ import json
487
+ import re
488
+ from urllib.parse import quote
489
+
490
+ results = []
491
+ search_url = f"https://www.youtube.com/results?search_query={quote(query)}&hl=en"
492
+ try:
493
+ async with aiohttp.ClientSession() as session:
494
+ async with session.get(search_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
495
+ if resp.status == 200:
496
+ html = await resp.text()
497
+ match = re.search(r'var ytInitialData\s*=\s*(\{.*?\});', html, re.DOTALL)
498
+ if match:
499
+ data = json.loads(match.group(1))
500
+ contents = data.get("contents", {}).get("twoColumnSearchResultsRenderer", {}).get(
501
+ "primaryContents", {}).get("sectionListRenderer", {}).get("contents", [{}])[0].get(
502
+ "itemSectionRenderer", {}).get("contents", [])
503
+ for item in contents:
504
+ vr = item.get("videoRenderer", {})
505
+ if not vr:
506
+ continue
507
+ video_id = vr.get("videoId", "")
508
+ title_runs = vr.get("title", {}).get("runs", [])
509
+ title = title_runs[0].get("text", "Untitled") if title_runs else "Untitled"
510
+ channel_runs = vr.get("ownerText", {}).get("runs", [])
511
+ channel = channel_runs[0].get("text", "Unknown") if channel_runs else "Unknown"
512
+ view_text = vr.get("viewCountText", {}).get("simpleText", "")
513
+ length_text = vr.get("lengthText", {}).get("simpleText", "")
514
+ thumbnails = vr.get("thumbnail", {}).get("thumbnails", [])
515
+ thumbnail = thumbnails[-1].get("url", "") if thumbnails else ""
516
+ results.append({
517
+ "title": title,
518
+ "channel": channel,
519
+ "views": view_text,
520
+ "duration": length_text,
521
+ "url": f"https://www.youtube.com/watch?v={video_id}",
522
+ "thumbnail": thumbnail,
523
+ "video_id": video_id,
524
+ })
525
+ if len(results) >= max_results:
526
+ break
527
+ except Exception:
528
+ pass
529
+ return results
530
+
531
+
532
+ class Utility(commands.Cog):
533
+ """Utility commands including YouTube search."""
534
+
535
+ def __init__(self, bot: commands.Bot) -> None:
536
+ self.bot = bot
537
+
538
+ @commands.hybrid_command(name="botstats", description=get_cmd_desc("commands.tools.botstats_desc"))
539
+ async def botstats(self, ctx: commands.Context) -> None:
540
+ guild_id = ctx.guild.id if ctx.guild else None
541
+ embed = await self.build_botstats_embed(guild_id)
542
+ view = BotStatsView(self, guild_id)
543
+ view.refresh.label = await self.bot.tr(guild_id, "utility.refresh")
544
+ msg = await ctx.reply(embed=embed, view=view)
545
+ if msg is None and ctx.interaction:
546
+ try:
547
+ msg = await ctx.interaction.original_response()
548
+ except Exception:
549
+ msg = None
550
+ view._message = msg
551
+ if msg:
552
+ await view.start_auto_refresh(msg)
553
+
554
+
555
+ async def setup(bot: commands.Bot) -> None:
556
+ await bot.add_cog(Utility(bot))
bot/cogs/verification.py CHANGED
@@ -1,177 +1,178 @@
1
- from __future__ import annotations
2
-
3
- import discord
4
- from discord.ext import commands
5
- from bot.emojis import ui
6
- from bot.emojis import ui
7
-
8
-
9
- class VerifyView(discord.ui.View):
10
- def __init__(self, bot: commands.Bot) -> None:
11
- super().__init__(timeout=None)
12
- self.bot = bot
13
-
14
- @discord.ui.button(label="Verify", emoji=ui("lock"), style=discord.ButtonStyle.success, custom_id="verify:member")
15
- async def verify_member(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
16
- guild_id = interaction.guild.id if interaction.guild else None
17
- if not interaction.response.is_done():
18
- try:
19
- await interaction.response.defer(ephemeral=True, thinking=False)
20
- except (discord.NotFound, discord.HTTPException, discord.InteractionResponded):
21
- return
22
- if not interaction.guild:
23
- await interaction.followup.send(await self.bot.get_text(guild_id, "common.server_only"), ephemeral=True)
24
- return
25
-
26
- row = await self.bot.db.fetchone(
27
- "SELECT verify_role_id, welcome_channel_id FROM guild_config WHERE guild_id = ?",
28
- interaction.guild.id,
29
- )
30
- if not row or not row[0]:
31
- await interaction.followup.send(await self.bot.get_text(guild_id, "welcome.verify_role_missing"), ephemeral=True)
32
- return
33
-
34
- role = interaction.guild.get_role(row[0])
35
- if not role:
36
- await interaction.followup.send(await self.bot.get_text(guild_id, "welcome.verify_role_deleted"), ephemeral=True)
37
- return
38
-
39
- member = interaction.user if isinstance(interaction.user, discord.Member) else interaction.guild.get_member(interaction.user.id)
40
- if not member:
41
- await interaction.followup.send(await self.bot.get_text(guild_id, "welcome.verify_member_missing"), ephemeral=True)
42
- return
43
-
44
- if role in member.roles:
45
- await interaction.followup.send(await self.bot.get_text(guild_id, "welcome.verify_already_done"), ephemeral=True)
46
- return
47
-
48
- await member.add_roles(role, reason="Member completed verification")
49
- await interaction.followup.send(await self.bot.get_text(guild_id, "welcome.verify_success"), ephemeral=True)
50
-
51
- await self.bot.log_to_guild(
52
- interaction.guild,
53
- await self.bot.get_text(guild_id, "welcome.verify_log_title"),
54
- await self.bot.get_text(guild_id, "welcome.verify_log_desc", member=member.mention, role=role.mention),
55
- color=discord.Color.green(),
56
- )
57
-
58
- async def localize(self, guild_id: int | None) -> "VerifyView":
59
- for item in self.children:
60
- if isinstance(item, discord.ui.Button) and item.custom_id == "verify:member":
61
- item.label = await self.bot.get_text(guild_id, "welcome.verify_button")
62
- item.emoji = self.bot.get_custom_emoji("admin", fallback="🔐")
63
- return self
64
-
65
-
66
- class Verification(commands.Cog):
67
- def __init__(self, bot: commands.Bot) -> None:
68
- self.bot = bot
69
-
70
- async def cog_load(self) -> None:
71
- self.bot.add_view(VerifyView(self))
72
-
73
- @commands.hybrid_command(name="verify", description="Open the verification panel")
74
- async def verify(self, ctx: commands.Context) -> None:
75
- await self.verify_config(ctx)
76
-
77
- @commands.hybrid_group(name="verify_config", fallback="panel", description="Verification system controls")
78
- async def verify_config(self, ctx: commands.Context) -> None:
79
- """Open the verification panel."""
80
- if not ctx.guild:
81
- await ctx.reply(await self.bot.get_text(None, "common.server_only"))
82
- return
83
- embed = await self._build_verify_panel(ctx.guild.id)
84
- await ctx.reply(embed=embed, view=await VerifyView(self.bot).localize(ctx.guild.id))
85
-
86
- @verify_config.command(name="setup", description="Configure verification system and lock channels")
87
- @commands.has_permissions(administrator=True)
88
- async def verify_setup(
89
- self,
90
- ctx: commands.Context,
91
- role: discord.Role,
92
- verify_channel: discord.TextChannel,
93
- welcome_channel: discord.TextChannel,
94
- ) -> None:
95
- guild = ctx.guild
96
-
97
- await self.bot.db.execute(
98
- """
99
- INSERT INTO guild_config(guild_id, verify_role_id, verify_channel_id, welcome_channel_id)
100
- VALUES (?, ?, ?, ?)
101
- ON CONFLICT(guild_id) DO UPDATE SET
102
- verify_role_id = excluded.verify_role_id,
103
- verify_channel_id = excluded.verify_channel_id,
104
- welcome_channel_id = excluded.welcome_channel_id
105
- """,
106
- guild.id,
107
- role.id,
108
- verify_channel.id,
109
- welcome_channel.id,
110
- )
111
-
112
- everyone = guild.default_role
113
- for channel in guild.channels:
114
- if channel.id == verify_channel.id:
115
- await channel.set_permissions(everyone, view_channel=True, send_messages=False)
116
- await channel.set_permissions(role, view_channel=True, send_messages=False)
117
- else:
118
- await channel.set_permissions(everyone, view_channel=False)
119
- await channel.set_permissions(role, view_channel=True)
120
-
121
- embed = discord.Embed(
122
- title="🔐 Verification Enabled",
123
- description=(
124
- "تم تفعيل نظام التحقق الاحترافي.\n"
125
- f"- قناة التحقق: {verify_channel.mention}\n"
126
- f"- قناة الترحيب: {welcome_channel.mention}\n"
127
- f"- رتبة التحقق: {role.mention}\n"
128
- "كل القنوات أصبحت مخفية حتى يقوم العضو بالتحقق."
129
- ),
130
- color=discord.Color.blurple(),
131
- )
132
- panel = discord.Embed(
133
- title=await self.bot.get_text(guild.id, "welcome.verify_title"),
134
- description=await self.bot.get_text(guild.id, "welcome.verify_body"),
135
- color=discord.Color.green(),
136
- )
137
- panel.add_field(name=await self.bot.get_text(guild.id, "welcome.verify_benefits_title"), value=await self.bot.get_text(guild.id, "welcome.verify_benefits_body"), inline=False)
138
- panel.set_footer(text=await self.bot.get_text(guild.id, "welcome.verify_footer"))
139
- await verify_channel.send(embed=panel, view=await VerifyView(self.bot).localize(guild.id))
140
- await ctx.reply(embed=embed)
141
-
142
- @commands.hybrid_command(name="verifysetup", description="Configure verification system and lock channels", with_app_command=False)
143
- @commands.has_permissions(administrator=True)
144
- async def verify_setup_legacy(
145
- self,
146
- ctx: commands.Context,
147
- role: discord.Role,
148
- verify_channel: discord.TextChannel,
149
- welcome_channel: discord.TextChannel,
150
- ) -> None:
151
- await self.verify_setup(
152
- ctx,
153
- role=role,
154
- verify_channel=verify_channel,
155
- welcome_channel=welcome_channel,
156
- )
157
-
158
- async def _build_verify_panel(self, guild_id: int) -> discord.Embed:
159
- lock = self.bot.get_custom_emoji("admin", fallback="🔐")
160
- unlock = self.bot.get_custom_emoji("admin", fallback="🔓")
161
- divider = "⛩️ ━━━ 🏮 ━━━ ⛩️"
162
- embed = discord.Embed(
163
- title=f"{lock} {await self.bot.get_text(guild_id, 'welcome.verify_title')}",
164
- description=f"{divider}\n{await self.bot.get_text(guild_id, 'welcome.verify_body')}\n{divider}",
165
- color=discord.Color.green(),
166
- )
167
- embed.add_field(
168
- name=f"{unlock} {await self.bot.get_text(guild_id, 'welcome.verify_benefits_title')}",
169
- value=await self.bot.get_text(guild_id, "welcome.verify_benefits_body"),
170
- inline=False,
171
- )
172
- embed.set_footer(text=await self.bot.get_text(guild_id, "welcome.verify_footer"))
173
- return embed
174
-
175
-
176
- async def setup(bot: commands.Bot) -> None:
177
- await bot.add_cog(Verification(bot))
 
 
1
+ from __future__ import annotations
2
+
3
+ import discord
4
+ from discord.ext import commands
5
+ from bot.i18n import get_cmd_desc
6
+ from bot.emojis import ui
7
+ from bot.emojis import ui
8
+
9
+
10
+ class VerifyView(discord.ui.View):
11
+ def __init__(self, bot: commands.Bot) -> None:
12
+ super().__init__(timeout=None)
13
+ self.bot = bot
14
+
15
+ @discord.ui.button(label="Verify", emoji=ui("lock"), style=discord.ButtonStyle.success, custom_id="verify:member")
16
+ async def verify_member(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
17
+ guild_id = interaction.guild.id if interaction.guild else None
18
+ if not interaction.response.is_done():
19
+ try:
20
+ await interaction.response.defer(ephemeral=True, thinking=False)
21
+ except (discord.NotFound, discord.HTTPException, discord.InteractionResponded):
22
+ return
23
+ if not interaction.guild:
24
+ await interaction.followup.send(await self.bot.get_text(guild_id, "common.server_only"), ephemeral=True)
25
+ return
26
+
27
+ row = await self.bot.db.fetchone(
28
+ "SELECT verify_role_id, welcome_channel_id FROM guild_config WHERE guild_id = ?",
29
+ interaction.guild.id,
30
+ )
31
+ if not row or not row[0]:
32
+ await interaction.followup.send(await self.bot.get_text(guild_id, "welcome.verify_role_missing"), ephemeral=True)
33
+ return
34
+
35
+ role = interaction.guild.get_role(row[0])
36
+ if not role:
37
+ await interaction.followup.send(await self.bot.get_text(guild_id, "welcome.verify_role_deleted"), ephemeral=True)
38
+ return
39
+
40
+ member = interaction.user if isinstance(interaction.user, discord.Member) else interaction.guild.get_member(interaction.user.id)
41
+ if not member:
42
+ await interaction.followup.send(await self.bot.get_text(guild_id, "welcome.verify_member_missing"), ephemeral=True)
43
+ return
44
+
45
+ if role in member.roles:
46
+ await interaction.followup.send(await self.bot.get_text(guild_id, "welcome.verify_already_done"), ephemeral=True)
47
+ return
48
+
49
+ await member.add_roles(role, reason="Member completed verification")
50
+ await interaction.followup.send(await self.bot.get_text(guild_id, "welcome.verify_success"), ephemeral=True)
51
+
52
+ await self.bot.log_to_guild(
53
+ interaction.guild,
54
+ await self.bot.get_text(guild_id, "welcome.verify_log_title"),
55
+ await self.bot.get_text(guild_id, "welcome.verify_log_desc", member=member.mention, role=role.mention),
56
+ color=discord.Color.green(),
57
+ )
58
+
59
+ async def localize(self, guild_id: int | None) -> "VerifyView":
60
+ for item in self.children:
61
+ if isinstance(item, discord.ui.Button) and item.custom_id == "verify:member":
62
+ item.label = await self.bot.get_text(guild_id, "welcome.verify_button")
63
+ item.emoji = self.bot.get_custom_emoji("admin", fallback="🔐")
64
+ return self
65
+
66
+
67
+ class Verification(commands.Cog):
68
+ def __init__(self, bot: commands.Bot) -> None:
69
+ self.bot = bot
70
+
71
+ async def cog_load(self) -> None:
72
+ self.bot.add_view(VerifyView(self))
73
+
74
+ @commands.hybrid_command(name="verify", description=get_cmd_desc("commands.tools.verify_desc"))
75
+ async def verify(self, ctx: commands.Context) -> None:
76
+ await self.verify_config(ctx)
77
+
78
+ @commands.hybrid_group(name="verify_config", fallback="panel", description="Verification system controls")
79
+ async def verify_config(self, ctx: commands.Context) -> None:
80
+ """Open the verification panel."""
81
+ if not ctx.guild:
82
+ await ctx.reply(await self.bot.get_text(None, "common.server_only"))
83
+ return
84
+ embed = await self._build_verify_panel(ctx.guild.id)
85
+ await ctx.reply(embed=embed, view=await VerifyView(self.bot).localize(ctx.guild.id))
86
+
87
+ @verify_config.command(name="setup", description="Configure verification system and lock channels")
88
+ @commands.has_permissions(administrator=True)
89
+ async def verify_setup(
90
+ self,
91
+ ctx: commands.Context,
92
+ role: discord.Role,
93
+ verify_channel: discord.TextChannel,
94
+ welcome_channel: discord.TextChannel,
95
+ ) -> None:
96
+ guild = ctx.guild
97
+
98
+ await self.bot.db.execute(
99
+ """
100
+ INSERT INTO guild_config(guild_id, verify_role_id, verify_channel_id, welcome_channel_id)
101
+ VALUES (?, ?, ?, ?)
102
+ ON CONFLICT(guild_id) DO UPDATE SET
103
+ verify_role_id = excluded.verify_role_id,
104
+ verify_channel_id = excluded.verify_channel_id,
105
+ welcome_channel_id = excluded.welcome_channel_id
106
+ """,
107
+ guild.id,
108
+ role.id,
109
+ verify_channel.id,
110
+ welcome_channel.id,
111
+ )
112
+
113
+ everyone = guild.default_role
114
+ for channel in guild.channels:
115
+ if channel.id == verify_channel.id:
116
+ await channel.set_permissions(everyone, view_channel=True, send_messages=False)
117
+ await channel.set_permissions(role, view_channel=True, send_messages=False)
118
+ else:
119
+ await channel.set_permissions(everyone, view_channel=False)
120
+ await channel.set_permissions(role, view_channel=True)
121
+
122
+ embed = discord.Embed(
123
+ title="🔐 Verification Enabled",
124
+ description=(
125
+ "تم تفعيل نظام التحقق الاحترافي.\n"
126
+ f"- قناة التحقق: {verify_channel.mention}\n"
127
+ f"- قناة الترحيب: {welcome_channel.mention}\n"
128
+ f"- رتبة التحقق: {role.mention}\n"
129
+ "كل القنوات أصبحت مخفية حتى يقوم العضو بالتحقق."
130
+ ),
131
+ color=discord.Color.blurple(),
132
+ )
133
+ panel = discord.Embed(
134
+ title=await self.bot.get_text(guild.id, "welcome.verify_title"),
135
+ description=await self.bot.get_text(guild.id, "welcome.verify_body"),
136
+ color=discord.Color.green(),
137
+ )
138
+ panel.add_field(name=await self.bot.get_text(guild.id, "welcome.verify_benefits_title"), value=await self.bot.get_text(guild.id, "welcome.verify_benefits_body"), inline=False)
139
+ panel.set_footer(text=await self.bot.get_text(guild.id, "welcome.verify_footer"))
140
+ await verify_channel.send(embed=panel, view=await VerifyView(self.bot).localize(guild.id))
141
+ await ctx.reply(embed=embed)
142
+
143
+ @commands.hybrid_command(name="verifysetup", description=get_cmd_desc("commands.tools.verifysetup_desc"), with_app_command=False)
144
+ @commands.has_permissions(administrator=True)
145
+ async def verify_setup_legacy(
146
+ self,
147
+ ctx: commands.Context,
148
+ role: discord.Role,
149
+ verify_channel: discord.TextChannel,
150
+ welcome_channel: discord.TextChannel,
151
+ ) -> None:
152
+ await self.verify_setup(
153
+ ctx,
154
+ role=role,
155
+ verify_channel=verify_channel,
156
+ welcome_channel=welcome_channel,
157
+ )
158
+
159
+ async def _build_verify_panel(self, guild_id: int) -> discord.Embed:
160
+ lock = self.bot.get_custom_emoji("admin", fallback="🔐")
161
+ unlock = self.bot.get_custom_emoji("admin", fallback="🔓")
162
+ divider = "⛩️ ━━━ 🏮 ━━━ ⛩️"
163
+ embed = discord.Embed(
164
+ title=f"{lock} {await self.bot.get_text(guild_id, 'welcome.verify_title')}",
165
+ description=f"{divider}\n{await self.bot.get_text(guild_id, 'welcome.verify_body')}\n{divider}",
166
+ color=discord.Color.green(),
167
+ )
168
+ embed.add_field(
169
+ name=f"{unlock} {await self.bot.get_text(guild_id, 'welcome.verify_benefits_title')}",
170
+ value=await self.bot.get_text(guild_id, "welcome.verify_benefits_body"),
171
+ inline=False,
172
+ )
173
+ embed.set_footer(text=await self.bot.get_text(guild_id, "welcome.verify_footer"))
174
+ return embed
175
+
176
+
177
+ async def setup(bot: commands.Bot) -> None:
178
+ await bot.add_cog(Verification(bot))
bot/config.py CHANGED
@@ -1,10 +1,14 @@
1
  from __future__ import annotations
2
 
 
3
  import os
4
  from dataclasses import dataclass
5
  from dotenv import load_dotenv
6
 
7
 
 
 
 
8
  @dataclass(slots=True)
9
  class Settings:
10
  token: str
@@ -16,16 +20,44 @@ class Settings:
16
  gemini_api_key: str = ""
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  def load_settings() -> Settings:
20
  load_dotenv()
21
  token = os.getenv("DISCORD_TOKEN", "").strip()
22
  prefix = os.getenv("PREFIX", "!").strip() or "!"
23
  raw_owners = os.getenv("OWNER_IDS", "")
24
- owner_ids: set[int] = {
25
- int(owner.strip())
26
- for owner in raw_owners.split(",")
27
- if owner.strip().isdigit()
28
- }
29
  raw_db_path = os.getenv("DB_PATH", "").strip()
30
  if raw_db_path:
31
  db_path = raw_db_path
 
1
  from __future__ import annotations
2
 
3
+ import logging
4
  import os
5
  from dataclasses import dataclass
6
  from dotenv import load_dotenv
7
 
8
 
9
+ logger = logging.getLogger("mega-bot.config")
10
+
11
+
12
  @dataclass(slots=True)
13
  class Settings:
14
  token: str
 
20
  gemini_api_key: str = ""
21
 
22
 
23
+ def _parse_owner_ids(raw: str) -> set[int]:
24
+ """Parse and validate OWNER_IDS from environment variable."""
25
+ if not raw or not raw.strip():
26
+ logger.warning("OWNER_IDS is empty. No owners configured.")
27
+ return set()
28
+
29
+ owner_ids: set[int] = set()
30
+ for part in raw.split(","):
31
+ cleaned = part.strip()
32
+ if not cleaned:
33
+ continue
34
+ if not cleaned.isdigit():
35
+ logger.warning("Invalid OWNER_IDS value skipped: %r", cleaned)
36
+ continue
37
+ value = int(cleaned)
38
+ # Discord snowflake IDs are positive integers, typically 17-19 digits
39
+ if value <= 0:
40
+ logger.warning("Invalid OWNER_IDS value (non-positive): %d", value)
41
+ continue
42
+ if value > 9_999_999_999_999_999_999:
43
+ logger.warning("Suspiciously large OWNER_IDS value: %d", value)
44
+ continue
45
+ owner_ids.add(value)
46
+
47
+ if not owner_ids:
48
+ logger.warning("No valid OWNER_IDS found after validation.")
49
+ else:
50
+ logger.info("Loaded %d owner ID(s).", len(owner_ids))
51
+
52
+ return owner_ids
53
+
54
+
55
  def load_settings() -> Settings:
56
  load_dotenv()
57
  token = os.getenv("DISCORD_TOKEN", "").strip()
58
  prefix = os.getenv("PREFIX", "!").strip() or "!"
59
  raw_owners = os.getenv("OWNER_IDS", "")
60
+ owner_ids = _parse_owner_ids(raw_owners)
 
 
 
 
61
  raw_db_path = os.getenv("DB_PATH", "").strip()
62
  if raw_db_path:
63
  db_path = raw_db_path
bot/database.py CHANGED
@@ -6,6 +6,7 @@ Provides async SQLite operations with schema migrations and persistent connectio
6
  import asyncio
7
  import os
8
  import shutil
 
9
  from pathlib import Path
10
 
11
  import aiosqlite
@@ -17,6 +18,10 @@ except Exception: # pragma: no cover
17
  hf_hub_download = None
18
 
19
 
 
 
 
 
20
  class Database:
21
  def __init__(self, path: str = "database.db") -> None:
22
  self.path = path
@@ -37,6 +42,7 @@ class Database:
37
  and HfApi
38
  and hf_hub_download
39
  )
 
40
 
41
  def _prepare_path(self) -> None:
42
  """Ensure the sqlite parent directory exists when a directory is provided."""
@@ -66,6 +72,11 @@ class Database:
66
  async def _push_remote_db(self) -> None:
67
  if not self._hf_sync_enabled:
68
  return
 
 
 
 
 
69
  try:
70
  api = HfApi(token=self._hf_token)
71
  await asyncio.to_thread(
@@ -76,6 +87,7 @@ class Database:
76
  repo_type=self._hf_repo_type,
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
@@ -350,6 +362,17 @@ class Database:
350
  updated_by INTEGER,
351
  updated_at TEXT DEFAULT CURRENT_TIMESTAMP
352
  );
 
 
 
 
 
 
 
 
 
 
 
353
  """
354
  )
355
 
 
6
  import asyncio
7
  import os
8
  import shutil
9
+ import time
10
  from pathlib import Path
11
 
12
  import aiosqlite
 
18
  hf_hub_download = None
19
 
20
 
21
+ # Minimum seconds between HuggingFace DB sync pushes to avoid rate limits
22
+ _HF_SYNC_INTERVAL_SECONDS = 60
23
+
24
+
25
  class Database:
26
  def __init__(self, path: str = "database.db") -> None:
27
  self.path = path
 
42
  and HfApi
43
  and hf_hub_download
44
  )
45
+ self._last_hf_push: float = 0.0
46
 
47
  def _prepare_path(self) -> None:
48
  """Ensure the sqlite parent directory exists when a directory is provided."""
 
72
  async def _push_remote_db(self) -> None:
73
  if not self._hf_sync_enabled:
74
  return
75
+ # Rate limit: avoid pushing too frequently
76
+ now = time.monotonic()
77
+ elapsed = now - self._last_hf_push
78
+ if elapsed < _HF_SYNC_INTERVAL_SECONDS:
79
+ return
80
  try:
81
  api = HfApi(token=self._hf_token)
82
  await asyncio.to_thread(
 
87
  repo_type=self._hf_repo_type,
88
  commit_message="Bot DB sync",
89
  )
90
+ self._last_hf_push = time.monotonic()
91
  except Exception:
92
  # Disable further sync attempts to avoid repeated noisy auth/network failures.
93
  self._hf_sync_enabled = False
 
362
  updated_by INTEGER,
363
  updated_at TEXT DEFAULT CURRENT_TIMESTAMP
364
  );
365
+
366
+ CREATE TABLE IF NOT EXISTS ai_scheduled_tasks (
367
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
368
+ guild_id INTEGER,
369
+ run_at TEXT,
370
+ action_json TEXT,
371
+ reason TEXT,
372
+ created_by INTEGER,
373
+ executed INTEGER DEFAULT 0,
374
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
375
+ );
376
  """
377
  )
378
 
bot/emojis.py CHANGED
@@ -3,6 +3,7 @@ Enhanced Emoji system for the bot.
3
  Loads custom emojis from emojies.txt and provides UI emoji aliases with rich decorations.
4
  """
5
 
 
6
  from pathlib import Path
7
  import re
8
 
@@ -79,8 +80,9 @@ FALLBACK_EMOJIS: dict[str, str] = {
79
  _EMOJI_FILES = (
80
  Path("emojies.txt"),
81
  Path("bot/emojies.txt"),
82
- Path("/home/z/my-project/discord-bot/emojies.txt"),
83
  )
 
84
  _EMOJI_ID_RE = re.compile(r"\d{6,}")
85
  _EMOJI_TAG_RE = re.compile(r"^<(a?):([\w~]+):(\d{6,})>$")
86
  _EMOJI_BOT: discord.Client | None = None
@@ -139,7 +141,11 @@ def _ensure_unescaped_emoji(value: str) -> str:
139
 
140
 
141
  def resolve_emoji_value(value: str, fallback: str = "✨", *, bot: discord.Client | None = None) -> str:
142
- """Resolve emoji config with a hybrid resolver (cache first, then manual tag build)."""
 
 
 
 
143
  parsed = _parse_custom_emoji_config(value)
144
  if parsed is not None:
145
  config_name, emoji_id, animated = parsed
@@ -148,7 +154,8 @@ def resolve_emoji_value(value: str, fallback: str = "✨", *, bot: discord.Clien
148
  resolved = active_bot.get_emoji(emoji_id)
149
  if resolved is not None:
150
  return _ensure_unescaped_emoji(_format_custom_emoji(resolved))
151
- return _ensure_unescaped_emoji(_build_custom_emoji_code(config_name, emoji_id, animated))
 
152
 
153
  emoji_id = _extract_emoji_id(value)
154
  active_bot = bot or _EMOJI_BOT
@@ -156,6 +163,8 @@ def resolve_emoji_value(value: str, fallback: str = "✨", *, bot: discord.Clien
156
  resolved = active_bot.get_emoji(emoji_id)
157
  if resolved is not None:
158
  return _ensure_unescaped_emoji(_format_custom_emoji(resolved))
 
 
159
  return _ensure_unescaped_emoji(value or fallback)
160
 
161
 
@@ -488,7 +497,6 @@ COMMAND_EMOJIS: dict[str, str] = {
488
  "seek": "⏩",
489
  "rewind": "⏪",
490
  "forward": "⏩",
491
- "rewind": "⏪",
492
  "filter": "🎛️",
493
  "clear": ui("trash"),
494
  "remove": ui("spotify_remove"),
 
3
  Loads custom emojis from emojies.txt and provides UI emoji aliases with rich decorations.
4
  """
5
 
6
+ import os
7
  from pathlib import Path
8
  import re
9
 
 
80
  _EMOJI_FILES = (
81
  Path("emojies.txt"),
82
  Path("bot/emojies.txt"),
83
+ Path(os.getenv("EMOJI_FILE_PATH", "/opt/bot/emojies.txt")) if os.getenv("EMOJI_FILE_PATH") else None,
84
  )
85
+ _EMOJI_FILES = tuple(p for p in _EMOJI_FILES if p is not None)
86
  _EMOJI_ID_RE = re.compile(r"\d{6,}")
87
  _EMOJI_TAG_RE = re.compile(r"^<(a?):([\w~]+):(\d{6,})>$")
88
  _EMOJI_BOT: discord.Client | None = None
 
141
 
142
 
143
  def resolve_emoji_value(value: str, fallback: str = "✨", *, bot: discord.Client | None = None) -> str:
144
+ """Resolve emoji config with a hybrid resolver (cache first, then fallback to unicode).
145
+
146
+ If the custom emoji is available in the bot's cache, it returns the full tag.
147
+ Otherwise it falls back to the unicode fallback to avoid showing raw :name: text.
148
+ """
149
  parsed = _parse_custom_emoji_config(value)
150
  if parsed is not None:
151
  config_name, emoji_id, animated = parsed
 
154
  resolved = active_bot.get_emoji(emoji_id)
155
  if resolved is not None:
156
  return _ensure_unescaped_emoji(_format_custom_emoji(resolved))
157
+ # Emoji not in cache — return fallback unicode to avoid raw :name: display
158
+ return fallback
159
 
160
  emoji_id = _extract_emoji_id(value)
161
  active_bot = bot or _EMOJI_BOT
 
163
  resolved = active_bot.get_emoji(emoji_id)
164
  if resolved is not None:
165
  return _ensure_unescaped_emoji(_format_custom_emoji(resolved))
166
+ return fallback
167
+ # Value might be a plain unicode emoji or already-resolved string
168
  return _ensure_unescaped_emoji(value or fallback)
169
 
170
 
 
497
  "seek": "⏩",
498
  "rewind": "⏪",
499
  "forward": "⏩",
 
500
  "filter": "🎛️",
501
  "clear": ui("trash"),
502
  "remove": ui("spotify_remove"),