Bjo53 commited on
Commit
e505892
ยท
verified ยท
1 Parent(s): b1fa966

Upload bot.py

Browse files
Files changed (1) hide show
  1. bot.py +1312 -0
bot.py ADDED
@@ -0,0 +1,1312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import re
5
+ import textwrap
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Optional, List, Dict, Any
8
+
9
+ import aiosqlite
10
+ from aiogram import Bot, Dispatcher, types
11
+ from aiogram.filters import Command, CommandStart
12
+ from aiogram.types import (
13
+ InlineKeyboardMarkup,
14
+ InlineKeyboardButton,
15
+ ReplyKeyboardMarkup,
16
+ KeyboardButton,
17
+ ReplyKeyboardRemove,
18
+ )
19
+ from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
20
+
21
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
22
+ from apscheduler.triggers.date import DateTrigger
23
+
24
+ logging.basicConfig(level=logging.INFO)
25
+ logger = logging.getLogger(__name__)
26
+
27
+ BOT_TOKEN = os.getenv("BOT_TOKEN")
28
+ if not BOT_TOKEN:
29
+ raise RuntimeError("BOT_TOKEN env var is required")
30
+
31
+ ADMIN_USERNAME = "@nameofbless"
32
+
33
+ DB_PATH = os.getenv("DB_PATH", "bot.db")
34
+
35
+ # ---- PROMO / CHANNEL LOCK SETTINGS ----
36
+ # Admin will set these via /set_promo_channel command after deployment
37
+ # Stored in DB table bot_settings
38
+
39
+ # ---- AI CHAT / PROVIDERS ----
40
+ # Admin defines AI profiles with:
41
+ # - name
42
+ # - api_base (e.g. https://api.openai.com/v1)
43
+ # - api_key (stored securely in DB, not printed)
44
+ # - model (e.g. gpt-4o-mini)
45
+ # - system_prompt
46
+ # We implement a simple "OpenAI-compatible" /chat/completions-style call.
47
+ import aiohttp
48
+
49
+ scheduler = AsyncIOScheduler(timezone=timezone.utc)
50
+
51
+
52
+ # ============ DB INIT ============
53
+
54
+ CREATE_TABLES_SQL = """
55
+ PRAGMA journal_mode=WAL;
56
+
57
+ CREATE TABLE IF NOT EXISTS bot_settings (
58
+ key TEXT PRIMARY KEY,
59
+ value TEXT
60
+ );
61
+
62
+ CREATE TABLE IF NOT EXISTS users (
63
+ user_id INTEGER PRIMARY KEY,
64
+ username TEXT,
65
+ first_name TEXT,
66
+ last_name TEXT
67
+ );
68
+
69
+ -- AI profiles managed by admin
70
+ CREATE TABLE IF NOT EXISTS ai_profiles (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ name TEXT NOT NULL UNIQUE,
73
+ api_base TEXT NOT NULL,
74
+ api_key TEXT NOT NULL,
75
+ model TEXT NOT NULL,
76
+ system_prompt TEXT NOT NULL,
77
+ description TEXT DEFAULT '',
78
+ active INTEGER DEFAULT 1
79
+ );
80
+
81
+ -- Conversation history (20 messages max per (user, profile))
82
+ CREATE TABLE IF NOT EXISTS ai_conversations (
83
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ user_id INTEGER NOT NULL,
85
+ profile_id INTEGER NOT NULL,
86
+ role TEXT NOT NULL, -- 'user' or 'assistant'
87
+ content TEXT NOT NULL,
88
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
89
+ );
90
+
91
+ -- Quiz files (text) stored by Telegram file_id
92
+ CREATE TABLE IF NOT EXISTS quiz_files (
93
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ file_id TEXT NOT NULL,
95
+ subject TEXT NOT NULL,
96
+ title TEXT NOT NULL,
97
+ added_by INTEGER NOT NULL,
98
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
99
+ );
100
+
101
+ -- Questions parsed from quiz_files
102
+ CREATE TABLE IF NOT EXISTS questions (
103
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
104
+ quiz_file_id INTEGER NOT NULL,
105
+ question TEXT NOT NULL,
106
+ option_a TEXT NOT NULL,
107
+ option_b TEXT NOT NULL,
108
+ option_c TEXT NOT NULL,
109
+ option_d TEXT NOT NULL,
110
+ correct_option TEXT NOT NULL, -- 'A','B','C','D'
111
+ explanation TEXT NOT NULL,
112
+ hint TEXT DEFAULT '',
113
+ FOREIGN KEY (quiz_file_id) REFERENCES quiz_files(id) ON DELETE CASCADE
114
+ );
115
+
116
+ -- GIF / video timer resources
117
+ CREATE TABLE IF NOT EXISTS timer_videos (
118
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
119
+ file_id TEXT NOT NULL,
120
+ duration_seconds INTEGER NOT NULL,
121
+ scope TEXT NOT NULL CHECK(scope IN ('single','multi','tournament')),
122
+ label TEXT NOT NULL
123
+ );
124
+
125
+ -- Reference books (PDFs etc) by file_id
126
+ CREATE TABLE IF NOT EXISTS reference_books (
127
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
128
+ file_id TEXT NOT NULL,
129
+ title TEXT NOT NULL,
130
+ added_by INTEGER NOT NULL,
131
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
132
+ );
133
+
134
+ -- Single-player quiz sessions
135
+ CREATE TABLE IF NOT EXISTS single_sessions (
136
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
137
+ user_id INTEGER NOT NULL,
138
+ quiz_file_id INTEGER NOT NULL,
139
+ current_question_index INTEGER NOT NULL DEFAULT 0,
140
+ correct_count INTEGER NOT NULL DEFAULT 0,
141
+ total_time_seconds INTEGER NOT NULL DEFAULT 0,
142
+ started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
143
+ ended_at TIMESTAMP,
144
+ active INTEGER DEFAULT 1,
145
+ timer_video_id INTEGER,
146
+ FOREIGN KEY (quiz_file_id) REFERENCES quiz_files(id),
147
+ FOREIGN KEY (timer_video_id) REFERENCES timer_videos(id)
148
+ );
149
+
150
+ CREATE TABLE IF NOT EXISTS single_answers (
151
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
152
+ session_id INTEGER NOT NULL,
153
+ question_id INTEGER NOT NULL,
154
+ chosen_option TEXT,
155
+ is_correct INTEGER NOT NULL DEFAULT 0,
156
+ time_spent_seconds INTEGER NOT NULL DEFAULT 0,
157
+ FOREIGN KEY (session_id) REFERENCES single_sessions(id),
158
+ FOREIGN KEY (question_id) REFERENCES questions(id)
159
+ );
160
+
161
+ -- Multiplayer rooms
162
+ CREATE TABLE IF NOT EXISTS multi_rooms (
163
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
164
+ code TEXT NOT NULL UNIQUE,
165
+ host_user_id INTEGER NOT NULL,
166
+ chat_id INTEGER NOT NULL,
167
+ quiz_file_id INTEGER NOT NULL,
168
+ timer_video_id INTEGER,
169
+ status TEXT NOT NULL DEFAULT 'lobby', -- 'lobby','running','finished'
170
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
171
+ started_at TIMESTAMP,
172
+ ended_at TIMESTAMP,
173
+ FOREIGN KEY (quiz_file_id) REFERENCES quiz_files(id),
174
+ FOREIGN KEY (timer_video_id) REFERENCES timer_videos(id)
175
+ );
176
+
177
+ CREATE TABLE IF NOT EXISTS multi_players (
178
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
179
+ room_id INTEGER NOT NULL,
180
+ user_id INTEGER NOT NULL,
181
+ score INTEGER NOT NULL DEFAULT 0,
182
+ total_time_seconds INTEGER NOT NULL DEFAULT 0,
183
+ joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
184
+ UNIQUE(room_id, user_id),
185
+ FOREIGN KEY (room_id) REFERENCES multi_rooms(id)
186
+ );
187
+
188
+ CREATE TABLE IF NOT EXISTS multi_answers (
189
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
190
+ room_id INTEGER NOT NULL,
191
+ user_id INTEGER NOT NULL,
192
+ question_id INTEGER NOT NULL,
193
+ chosen_option TEXT,
194
+ is_correct INTEGER NOT NULL DEFAULT 0,
195
+ time_spent_seconds INTEGER NOT NULL DEFAULT 0,
196
+ FOREIGN KEY (room_id) REFERENCES multi_rooms(id),
197
+ FOREIGN KEY (question_id) REFERENCES questions(id)
198
+ );
199
+
200
+ -- Tournament
201
+ CREATE TABLE IF NOT EXISTS tournaments (
202
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
203
+ code TEXT NOT NULL UNIQUE,
204
+ admin_user_id INTEGER NOT NULL,
205
+ group_chat_id INTEGER NOT NULL,
206
+ quiz_file_id INTEGER NOT NULL,
207
+ timer_video_id INTEGER,
208
+ status TEXT NOT NULL DEFAULT 'scheduled', -- 'scheduled','running','finished'
209
+ scheduled_start TIMESTAMP NOT NULL,
210
+ started_at TIMESTAMP,
211
+ ended_at TIMESTAMP,
212
+ FOREIGN KEY (quiz_file_id) REFERENCES quiz_files(id),
213
+ FOREIGN KEY (timer_video_id) REFERENCES timer_videos(id)
214
+ );
215
+
216
+ CREATE TABLE IF NOT EXISTS tournament_players (
217
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
218
+ tournament_id INTEGER NOT NULL,
219
+ user_id INTEGER NOT NULL,
220
+ score INTEGER NOT NULL DEFAULT 0,
221
+ total_time_seconds INTEGER NOT NULL DEFAULT 0,
222
+ joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
223
+ UNIQUE(tournament_id, user_id),
224
+ FOREIGN KEY (tournament_id) REFERENCES tournaments(id)
225
+ );
226
+
227
+ CREATE TABLE IF NOT EXISTS tournament_answers (
228
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
229
+ tournament_id INTEGER NOT NULL,
230
+ user_id INTEGER NOT NULL,
231
+ question_id INTEGER NOT NULL,
232
+ chosen_option TEXT,
233
+ is_correct INTEGER NOT NULL DEFAULT 0,
234
+ time_spent_seconds INTEGER NOT NULL DEFAULT 0,
235
+ FOREIGN KEY (tournament_id) REFERENCES tournaments(id),
236
+ FOREIGN KEY (question_id) REFERENCES questions(id)
237
+ );
238
+ """
239
+
240
+
241
+ async def init_db():
242
+ async with aiosqlite.connect(DB_PATH) as db:
243
+ await db.executescript(CREATE_TABLES_SQL)
244
+ await db.commit()
245
+
246
+
247
+ # ============ HELPERS ============
248
+
249
+ async def get_db():
250
+ return await aiosqlite.connect(DB_PATH)
251
+
252
+
253
+ def is_admin(user: types.User) -> bool:
254
+ return user.username and f"@{user.username}" == ADMIN_USERNAME
255
+
256
+
257
+ async def get_setting(db: aiosqlite.Connection, key: str) -> Optional[str]:
258
+ cursor = await db.execute("SELECT value FROM bot_settings WHERE key = ?", (key,))
259
+ row = await cursor.fetchone()
260
+ await cursor.close()
261
+ return row[0] if row else None
262
+
263
+
264
+ async def set_setting(db: aiosqlite.Connection, key: str, value: str):
265
+ await db.execute(
266
+ "INSERT INTO bot_settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
267
+ (key, value),
268
+ )
269
+ await db.commit()
270
+
271
+
272
+ async def ensure_user(db: aiosqlite.Connection, user: types.User):
273
+ await db.execute(
274
+ """
275
+ INSERT INTO users(user_id, username, first_name, last_name)
276
+ VALUES(?,?,?,?)
277
+ ON CONFLICT(user_id) DO UPDATE SET
278
+ username=excluded.username,
279
+ first_name=excluded.first_name,
280
+ last_name=excluded.last_name
281
+ """,
282
+ (user.id, user.username, user.first_name, user.last_name),
283
+ )
284
+ await db.commit()
285
+
286
+
287
+ async def check_promo_membership(bot: Bot, db: aiosqlite.Connection, user: types.User) -> bool:
288
+ promo_channel_id = await get_setting(db, "promo_channel_id")
289
+ promo_channel_link = await get_setting(db, "promo_channel_link")
290
+ if not promo_channel_id:
291
+ return True # no lock set
292
+
293
+ try:
294
+ member = await bot.get_chat_member(int(promo_channel_id), user.id)
295
+ if member.status in ("member", "administrator", "creator"):
296
+ return True
297
+ except Exception as e:
298
+ logger.warning("Failed to check promo membership: %s", e)
299
+
300
+ # Not a member: send join message
301
+ kb = InlineKeyboardBuilder()
302
+ if promo_channel_link:
303
+ kb.button(text="๐Ÿ“ข Join Channel", url=promo_channel_link)
304
+ kb.button(text="โœ… I Joined", callback_data="promo_recheck")
305
+ await bot.send_message(
306
+ user.id,
307
+ "๐Ÿšซ To use this bot you must join our promotion channel first.",
308
+ reply_markup=kb.as_markup(),
309
+ )
310
+ return False
311
+
312
+
313
+ def main_menu_kb() -> ReplyKeyboardMarkup:
314
+ kb = ReplyKeyboardBuilder()
315
+ kb.button(text="๐ŸŽฎ Single Quiz")
316
+ kb.button(text="๐Ÿ‘ฅ Multiplayer")
317
+ kb.button(text="๏ฟฝ๏ฟฝ๏ฟฝ๏ฟฝ Tournament")
318
+ kb.button(text="๐Ÿ“š Reference Books")
319
+ kb.button(text="๐Ÿค– Ask AI")
320
+ return kb.as_markup(resize_keyboard=True)
321
+
322
+
323
+ def medal_for_position(idx: int) -> str:
324
+ if idx == 0:
325
+ return "๐Ÿฅ‡"
326
+ if idx == 1:
327
+ return "๐Ÿฅˆ"
328
+ if idx == 2:
329
+ return "๐Ÿฅ‰"
330
+ return f"{idx+1}."
331
+
332
+
333
+ # ============ QUIZ PARSING (FROM TEXT FILE) ============
334
+
335
+ QUIZ_BLOCK_RE = re.compile(
336
+ r"Question:\s*(?P<q>.+?)\n"
337
+ r"A\)\s*(?P<a>.+?)\n"
338
+ r"B\)\s*(?P<b>.+?)\n"
339
+ r"C\)\s*(?P<c>.+?)\n"
340
+ r"D\)\s*(?P<d>.+?)\n"
341
+ r"Answer:\s*(?P<ans>[ABCD])\s*\n"
342
+ r"Explanation:\s*(?P<exp>.+?)\n"
343
+ r"Hint:\s*(?P<hint>.+?)\n?",
344
+ re.DOTALL | re.IGNORECASE,
345
+ )
346
+
347
+
348
+ async def parse_and_store_quiz(
349
+ db: aiosqlite.Connection,
350
+ file_id: str,
351
+ text: str,
352
+ subject: str,
353
+ title: str,
354
+ admin_id: int,
355
+ ) -> int:
356
+ cursor = await db.execute(
357
+ """
358
+ INSERT INTO quiz_files(file_id,subject,title,added_by)
359
+ VALUES(?,?,?,?)
360
+ """,
361
+ (file_id, subject.strip(), title.strip(), admin_id),
362
+ )
363
+ quiz_file_id = cursor.lastrowid
364
+
365
+ matches = list(QUIZ_BLOCK_RE.finditer(text))
366
+ if not matches:
367
+ raise ValueError("No questions found in file. Make sure format is correct.")
368
+
369
+ for m in matches:
370
+ q = m.group("q").strip()
371
+ a = m.group("a").strip()
372
+ b = m.group("b").strip()
373
+ c = m.group("c").strip()
374
+ d = m.group("d").strip()
375
+ ans = m.group("ans").strip().upper()
376
+ exp = m.group("exp").strip()
377
+ hint = m.group("hint").strip()
378
+ await db.execute(
379
+ """
380
+ INSERT INTO questions(quiz_file_id,question,option_a,option_b,option_c,option_d,correct_option,explanation,hint)
381
+ VALUES(?,?,?,?,?,?,?,?,?)
382
+ """,
383
+ (quiz_file_id, q, a, b, c, d, ans, exp, hint),
384
+ )
385
+
386
+ await db.commit()
387
+ return quiz_file_id
388
+
389
+
390
+ # ============ AI CHAT ============
391
+
392
+ async def list_ai_profiles(db: aiosqlite.Connection) -> List[Dict[str, Any]]:
393
+ cur = await db.execute(
394
+ "SELECT id,name,description,active FROM ai_profiles ORDER BY id"
395
+ )
396
+ rows = await cur.fetchall()
397
+ await cur.close()
398
+ return [
399
+ {
400
+ "id": r[0],
401
+ "name": r[1],
402
+ "description": r[2],
403
+ "active": bool(r[3]),
404
+ }
405
+ for r in rows
406
+ ]
407
+
408
+
409
+ async def get_ai_profile(db: aiosqlite.Connection, profile_id: int) -> Optional[Dict[str, Any]]:
410
+ cur = await db.execute(
411
+ """
412
+ SELECT id,name,api_base,api_key,model,system_prompt,description,active
413
+ FROM ai_profiles WHERE id=?
414
+ """,
415
+ (profile_id,),
416
+ )
417
+ row = await cur.fetchone()
418
+ await cur.close()
419
+ if not row:
420
+ return None
421
+ return {
422
+ "id": row[0],
423
+ "name": row[1],
424
+ "api_base": row[2],
425
+ "api_key": row[3],
426
+ "model": row[4],
427
+ "system_prompt": row[5],
428
+ "description": row[6],
429
+ "active": bool(row[7]),
430
+ }
431
+
432
+
433
+ async def get_conversation_history(
434
+ db: aiosqlite.Connection, user_id: int, profile_id: int
435
+ ) -> List[Dict[str, str]]:
436
+ cur = await db.execute(
437
+ """
438
+ SELECT role,content FROM ai_conversations
439
+ WHERE user_id=? AND profile_id=?
440
+ ORDER BY id ASC
441
+ """,
442
+ (user_id, profile_id),
443
+ )
444
+ rows = await cur.fetchall()
445
+ await cur.close()
446
+ return [{"role": r[0], "content": r[1]} for r in rows]
447
+
448
+
449
+ async def append_message(
450
+ db: aiosqlite.Connection,
451
+ user_id: int,
452
+ profile_id: int,
453
+ role: str,
454
+ content: str,
455
+ ):
456
+ await db.execute(
457
+ """
458
+ INSERT INTO ai_conversations(user_id,profile_id,role,content)
459
+ VALUES(?,?,?,?)
460
+ """,
461
+ (user_id, profile_id, role, content),
462
+ )
463
+ # keep only last 20 messages
464
+ await db.execute(
465
+ """
466
+ DELETE FROM ai_conversations
467
+ WHERE id IN (
468
+ SELECT id FROM ai_conversations
469
+ WHERE user_id=? AND profile_id=?
470
+ ORDER BY id ASC
471
+ LIMIT (
472
+ SELECT MAX(COUNT(*)-20,0)
473
+ FROM ai_conversations
474
+ WHERE user_id=? AND profile_id=?
475
+ )
476
+ )
477
+ """,
478
+ (user_id, profile_id, user_id, profile_id),
479
+ )
480
+ await db.commit()
481
+
482
+
483
+ async def call_ai(profile: Dict[str, Any], messages: List[Dict[str, str]]) -> str:
484
+ # OpenAI-compatible /chat/completions style
485
+ url = profile["api_base"].rstrip("/") + "/chat/completions"
486
+ headers = {
487
+ "Authorization": f"Bearer {profile['api_key']}",
488
+ "Content-Type": "application/json",
489
+ }
490
+ body = {
491
+ "model": profile["model"],
492
+ "messages": [{"role": "system", "content": profile["system_prompt"]}]
493
+ + messages,
494
+ }
495
+ async with aiohttp.ClientSession() as session:
496
+ async with session.post(url, json=body, headers=headers, timeout=120) as resp:
497
+ if resp.status != 200:
498
+ txt = await resp.text()
499
+ logger.error("AI error %s: %s", resp.status, txt)
500
+ raise RuntimeError("AI API error")
501
+ data = await resp.json()
502
+ return data["choices"][0]["message"]["content"]
503
+
504
+
505
+ # ============ BOT SETUP ============
506
+
507
+ bot = Bot(token=BOT_TOKEN, parse_mode="HTML")
508
+ dp = Dispatcher()
509
+
510
+
511
+ # ============ HANDLERS ============
512
+
513
+ @dp.message(CommandStart())
514
+ async def cmd_start(message: types.Message):
515
+ async with await get_db() as db:
516
+ await ensure_user(db, message.from_user)
517
+ if not await check_promo_membership(bot, db, message.from_user):
518
+ return
519
+
520
+ text = (
521
+ "๐Ÿ‘‹ Welcome to the Quiz & AI Bot!\n\n"
522
+ "Use the menu below to start:\n"
523
+ "๐ŸŽฎ Single Quiz\n"
524
+ "๐Ÿ‘ฅ Multiplayer\n"
525
+ "๐Ÿ† Tournament\n"
526
+ "๐Ÿ“š Reference Books\n"
527
+ "๐Ÿค– Ask AI"
528
+ )
529
+ await message.answer(text, reply_markup=main_menu_kb())
530
+
531
+
532
+ @dp.message(Command("menu"))
533
+ async def cmd_menu(message: types.Message):
534
+ await message.answer("๐Ÿ“‹ Main menu:", reply_markup=main_menu_kb())
535
+
536
+
537
+ # ----- PROMO CHANNEL SETUP (ADMIN) -----
538
+
539
+ @dp.message(Command("set_promo_channel"))
540
+ async def cmd_set_promo_channel(message: types.Message):
541
+ if not is_admin(message.from_user):
542
+ return
543
+ parts = message.text.split()
544
+ if len(parts) < 3:
545
+ await message.answer(
546
+ "Usage:\n"
547
+ "<code>/set_promo_channel &lt;channel_id&gt; &lt;channel_link&gt;</code>\n"
548
+ "Example:\n"
549
+ "<code>/set_promo_channel -1001234567890 https://t.me/yourchannel</code>"
550
+ )
551
+ return
552
+ channel_id = parts[1]
553
+ channel_link = parts[2]
554
+ async with await get_db() as db:
555
+ await set_setting(db, "promo_channel_id", channel_id)
556
+ await set_setting(db, "promo_channel_link", channel_link)
557
+ await message.answer(
558
+ f"โœ… Promotion channel set.\nID: <code>{channel_id}</code>\nLink: {channel_link}"
559
+ )
560
+
561
+
562
+ # ----- ADMIN: ADD QUIZ FROM TEXT FILE -----
563
+
564
+ @dp.message(Command("add_quiz"))
565
+ async def cmd_add_quiz(message: types.Message):
566
+ if not is_admin(message.from_user):
567
+ return
568
+
569
+ await message.answer(
570
+ "๐Ÿ“„ Send me a TEXT FILE with your quiz in this format:\n\n"
571
+ "Question: ...\n"
572
+ "A) ...\nB) ...\nC) ...\nD) ...\n"
573
+ "Answer: A\n"
574
+ "Explanation: ...\n"
575
+ "Hint: ...\n\n"
576
+ "Send the file now."
577
+ )
578
+
579
+
580
+ @dp.message(lambda m: m.document and m.caption and m.caption.startswith("quiz:"))
581
+ async def handle_quiz_file(message: types.Message):
582
+ # Admin sends text file with caption: quiz: Subject | Title
583
+ if not is_admin(message.from_user):
584
+ return
585
+
586
+ doc = message.document
587
+ caption = message.caption[len("quiz:") :].strip()
588
+ try:
589
+ subject, title = [x.strip() for x in caption.split("|", 1)]
590
+ except ValueError:
591
+ await message.answer(
592
+ "โŒ Caption format invalid.\nUse: <code>quiz: Subject | Title</code>"
593
+ )
594
+ return
595
+
596
+ file = await bot.get_file(doc.file_id)
597
+ file_bytes = await bot.download_file(file.file_path)
598
+ text = file_bytes.read().decode("utf-8", errors="ignore")
599
+
600
+ async with await get_db() as db:
601
+ try:
602
+ quiz_file_id = await parse_and_store_quiz(
603
+ db=db,
604
+ file_id=doc.file_id,
605
+ text=text,
606
+ subject=subject,
607
+ title=title,
608
+ admin_id=message.from_user.id,
609
+ )
610
+ except Exception as e:
611
+ logger.exception("Quiz parse error")
612
+ await message.answer(f"โŒ Failed to parse quiz: {e}")
613
+ return
614
+
615
+ await message.answer(f"โœ… Quiz saved (ID: {quiz_file_id}) with subject <b>{subject}</b> and title <b>{title}</b>.")
616
+
617
+
618
+ # ----- ADMIN: TIMER VIDEOS -----
619
+
620
+ @dp.message(Command("add_timer"))
621
+ async def cmd_add_timer(message: types.Message):
622
+ if not is_admin(message.from_user):
623
+ return
624
+ await message.answer(
625
+ "โฑ Send a GIF/Video with caption:\n\n"
626
+ "<code>timer: scope duration label</code>\n\n"
627
+ "Where:\n"
628
+ "โ€ข scope = single | multi | tournament\n"
629
+ "โ€ข duration = seconds (e.g. 30)\n"
630
+ "โ€ข label = short name (no spaces or use _)\n\n"
631
+ "Example:\n"
632
+ "<code>timer: single 30 basic_single</code>"
633
+ )
634
+
635
+
636
+ @dp.message(lambda m: m.video or m.animation)
637
+ async def handle_timer_video(message: types.Message):
638
+ if not is_admin(message.from_user):
639
+ return
640
+ if not message.caption or not message.caption.startswith("timer:"):
641
+ return
642
+
643
+ try:
644
+ _, rest = message.caption.split("timer:", 1)
645
+ parts = rest.strip().split()
646
+ scope = parts[0]
647
+ duration = int(parts[1])
648
+ label = parts[2] if len(parts) > 2 else f"{scope}_{duration}s"
649
+ if scope not in ("single", "multi", "tournament"):
650
+ raise ValueError("invalid scope")
651
+ except Exception:
652
+ await message.answer(
653
+ "โŒ Invalid caption.\nUse: <code>timer: scope duration label</code>\n"
654
+ "Example: <code>timer: single 30 basic_single</code>"
655
+ )
656
+ return
657
+
658
+ file_id = (
659
+ message.video.file_id if message.video else message.animation.file_id
660
+ )
661
+
662
+ async with await get_db() as db:
663
+ await db.execute(
664
+ """
665
+ INSERT INTO timer_videos(file_id,duration_seconds,scope,label)
666
+ VALUES(?,?,?,?)
667
+ """,
668
+ (file_id, duration, scope, label),
669
+ )
670
+ await db.commit()
671
+ await message.answer(
672
+ f"โœ… Timer video saved.\nScope: {scope}\nDuration: {duration}s\nLabel: {label}"
673
+ )
674
+
675
+
676
+ # ----- ADMIN: REFERENCE BOOKS -----
677
+
678
+ @dp.message(Command("add_book"))
679
+ async def cmd_add_book(message: types.Message):
680
+ if not is_admin(message.from_user):
681
+ return
682
+ await message.answer(
683
+ "๐Ÿ“š Send a PDF (or any doc) with caption:\n"
684
+ "<code>book: Title of Book</code>"
685
+ )
686
+
687
+
688
+ @dp.message(lambda m: m.document and m.caption and m.caption.startswith("book:"))
689
+ async def handle_book(message: types.Message):
690
+ if not is_admin(message.from_user):
691
+ return
692
+ title = message.caption[len("book:") :].strip()
693
+ file_id = message.document.file_id
694
+
695
+ async with await get_db() as db:
696
+ await db.execute(
697
+ """
698
+ INSERT INTO reference_books(file_id,title,added_by)
699
+ VALUES(?,?,?)
700
+ """,
701
+ (file_id, title, message.from_user.id),
702
+ )
703
+ await db.commit()
704
+
705
+ await message.answer(f"โœ… Reference book added: <b>{title}</b>.")
706
+
707
+
708
+ @dp.message(lambda m: m.text == "๐Ÿ“š Reference Books")
709
+ async def list_books(message: types.Message):
710
+ async with await get_db() as db:
711
+ if not await check_promo_membership(bot, db, message.from_user):
712
+ return
713
+ cur = await db.execute(
714
+ "SELECT id,title FROM reference_books ORDER BY id DESC"
715
+ )
716
+ rows = await cur.fetchall()
717
+ await cur.close()
718
+ if not rows:
719
+ await message.answer("๐Ÿ“š No reference books yet.")
720
+ return
721
+
722
+ kb = InlineKeyboardBuilder()
723
+ for row in rows:
724
+ kb.button(text=row[1][:30], callback_data=f"book_{row[0]}")
725
+ kb.adjust(1)
726
+ await message.answer("Select a reference book:", reply_markup=kb.as_markup())
727
+
728
+
729
+ @dp.callback_query(lambda c: c.data and c.data.startswith("book_"))
730
+ async def send_book(call: types.CallbackQuery):
731
+ book_id = int(call.data.split("_", 1)[1])
732
+ async with await get_db() as db:
733
+ cur = await db.execute(
734
+ "SELECT file_id,title FROM reference_books WHERE id=?", (book_id,)
735
+ )
736
+ row = await cur.fetchone()
737
+ await cur.close()
738
+ if not row:
739
+ await call.answer("Not found.", show_alert=True)
740
+ return
741
+ file_id, title = row
742
+ await bot.send_document(call.from_user.id, file_id, caption=f"๐Ÿ“š {title}")
743
+ await call.answer()
744
+
745
+
746
+ # ----- SINGLE PLAYER QUIZ (BASIC FLOW) -----
747
+ # For brevity, we implement a very simple single-player flow:
748
+ # - user chooses latest quiz
749
+ # - each question shown one by one
750
+ # - explanation shown after answer
751
+ # - summary at end
752
+ # You can extend later to choose subject/quiz, timer video options, etc.
753
+
754
+
755
+ async def get_latest_quiz(db: aiosqlite.Connection) -> Optional[Dict[str, Any]]:
756
+ cur = await db.execute(
757
+ "SELECT id,subject,title FROM quiz_files ORDER BY id DESC LIMIT 1"
758
+ )
759
+ row = await cur.fetchone()
760
+ await cur.close()
761
+ if not row:
762
+ return None
763
+ return {"id": row[0], "subject": row[1], "title": row[2]}
764
+
765
+
766
+ async def get_questions_for_quiz(
767
+ db: aiosqlite.Connection, quiz_file_id: int
768
+ ) -> List[Dict[str, Any]]:
769
+ cur = await db.execute(
770
+ """
771
+ SELECT id,question,option_a,option_b,option_c,option_d,correct_option,explanation,hint
772
+ FROM questions WHERE quiz_file_id=?
773
+ ORDER BY id
774
+ """,
775
+ (quiz_file_id,),
776
+ )
777
+ rows = await cur.fetchall()
778
+ await cur.close()
779
+ return [
780
+ {
781
+ "id": r[0],
782
+ "question": r[1],
783
+ "a": r[2],
784
+ "b": r[3],
785
+ "c": r[4],
786
+ "d": r[5],
787
+ "correct": r[6],
788
+ "exp": r[7],
789
+ "hint": r[8],
790
+ }
791
+ for r in rows
792
+ ]
793
+
794
+
795
+ @dp.message(lambda m: m.text == "๐ŸŽฎ Single Quiz")
796
+ async def single_quiz_entry(message: types.Message):
797
+ async with await get_db() as db:
798
+ if not await check_promo_membership(bot, db, message.from_user):
799
+ return
800
+ await ensure_user(db, message.from_user)
801
+ quiz = await get_latest_quiz(db)
802
+ if not quiz:
803
+ await message.answer("โŒ No quizzes yet. Ask admin to add one.")
804
+ return
805
+
806
+ # choose first matching timer for scope=single (optional)
807
+ cur = await db.execute(
808
+ """
809
+ SELECT id,file_id,duration_seconds FROM timer_videos
810
+ WHERE scope='single' ORDER BY id DESC LIMIT 1
811
+ """
812
+ )
813
+ trow = await cur.fetchone()
814
+ await cur.close()
815
+ timer_id = trow[0] if trow else None
816
+
817
+ cur = await db.execute(
818
+ """
819
+ INSERT INTO single_sessions(user_id,quiz_file_id,timer_video_id)
820
+ VALUES(?,?,?)
821
+ """,
822
+ (message.from_user.id, quiz["id"], timer_id),
823
+ )
824
+ session_id = cur.lastrowid
825
+ await db.commit()
826
+
827
+ await message.answer(
828
+ f"๐ŸŽฎ Starting single-player quiz:\n<b>{quiz['title']}</b>\nSubject: {quiz['subject']}"
829
+ )
830
+ await send_next_single_question(message.chat.id, message.from_user.id, session_id)
831
+
832
+
833
+ async def send_next_single_question(chat_id: int, user_id: int, session_id: int):
834
+ async with await get_db() as db:
835
+ cur = await db.execute(
836
+ """
837
+ SELECT quiz_file_id,current_question_index,timer_video_id
838
+ FROM single_sessions WHERE id=? AND active=1
839
+ """,
840
+ (session_id,),
841
+ )
842
+ srow = await cur.fetchone()
843
+ await cur.close()
844
+ if not srow:
845
+ return
846
+ quiz_file_id, idx, timer_video_id = srow
847
+ questions = await get_questions_for_quiz(db, quiz_file_id)
848
+ if idx >= len(questions):
849
+ # finished
850
+ await finish_single_session(chat_id, user_id, session_id, db, questions)
851
+ return
852
+ q = questions[idx]
853
+
854
+ # send timer video if any
855
+ if timer_video_id:
856
+ cur = await db.execute(
857
+ """
858
+ SELECT file_id,duration_seconds FROM timer_videos WHERE id=?
859
+ """,
860
+ (timer_video_id,),
861
+ )
862
+ trow = await cur.fetchone()
863
+ await cur.close()
864
+ else:
865
+ trow = None
866
+
867
+ if trow:
868
+ file_id, duration = trow
869
+ await bot.send_animation(chat_id, file_id, caption=f"โฑ {duration} seconds")
870
+
871
+ kb = InlineKeyboardBuilder()
872
+ kb.button(text=f"A) {q['a']}", callback_data=f"sans_{session_id}_{q['id']}_A")
873
+ kb.button(text=f"B) {q['b']}", callback_data=f"sans_{session_id}_{q['id']}_B")
874
+ kb.button(text=f"C) {q['c']}", callback_data=f"sans_{session_id}_{q['id']}_C")
875
+ kb.button(text=f"D) {q['d']}", callback_data=f"sans_{session_id}_{q['id']}_D")
876
+ kb.adjust(1)
877
+
878
+ text = f"โ“ <b>Question {idx+1}</b>\n\n{q['question']}"
879
+ await bot.send_message(chat_id, text, reply_markup=kb.as_markup())
880
+
881
+
882
+ @dp.callback_query(lambda c: c.data and c.data.startswith("sans_"))
883
+ async def handle_single_answer(call: types.CallbackQuery):
884
+ # data: sans_sessionId_questionId_option
885
+ try:
886
+ _, sid, qid, opt = call.data.split("_", 3)
887
+ session_id = int(sid)
888
+ question_id = int(qid)
889
+ chosen = opt
890
+ except Exception:
891
+ await call.answer("Error.", show_alert=True)
892
+ return
893
+
894
+ async with await get_db() as db:
895
+ # get session + question
896
+ cur = await db.execute(
897
+ """
898
+ SELECT quiz_file_id,current_question_index,correct_count,total_time_seconds
899
+ FROM single_sessions WHERE id=? AND active=1
900
+ """,
901
+ (session_id,),
902
+ )
903
+ srow = await cur.fetchone()
904
+ await cur.close()
905
+ if not srow:
906
+ await call.answer("Session ended.", show_alert=True)
907
+ return
908
+ quiz_file_id, idx, correct_count, total_time = srow
909
+
910
+ cur = await db.execute(
911
+ """
912
+ SELECT id,question,option_a,option_b,option_c,option_d,correct_option,explanation,hint
913
+ FROM questions WHERE id=? AND quiz_file_id=?
914
+ """,
915
+ (question_id, quiz_file_id),
916
+ )
917
+ qrow = await cur.fetchone()
918
+ await cur.close()
919
+ if not qrow:
920
+ await call.answer("Question not found.", show_alert=True)
921
+ return
922
+
923
+ correct = qrow[6]
924
+ exp = qrow[7]
925
+ hint = qrow[8]
926
+
927
+ is_correct = 1 if chosen == correct else 0
928
+ # time measurement could be refined; here we just add fixed
929
+ time_spent = 5
930
+
931
+ await db.execute(
932
+ """
933
+ INSERT INTO single_answers(session_id,question_id,chosen_option,is_correct,time_spent_seconds)
934
+ VALUES(?,?,?,?,?)
935
+ """,
936
+ (session_id, question_id, chosen, is_correct, time_spent),
937
+ )
938
+
939
+ if is_correct:
940
+ correct_count += 1
941
+ total_time += time_spent
942
+
943
+ # move to next
944
+ idx += 1
945
+ await db.execute(
946
+ """
947
+ UPDATE single_sessions
948
+ SET current_question_index=?,correct_count=?,total_time_seconds=?
949
+ WHERE id=?
950
+ """,
951
+ (idx, correct_count, total_time, session_id),
952
+ )
953
+ await db.commit()
954
+
955
+ answer_text = "โœ… Correct!" if is_correct else f"โŒ Wrong. Correct answer: {correct}"
956
+ answer_text += f"\n\n๐Ÿ“˜ Explanation:\n{exp}"
957
+ if hint:
958
+ answer_text += f"\n\n๐Ÿ’ก Hint was:\n{hint}"
959
+ await call.message.answer(answer_text)
960
+
961
+ await call.answer()
962
+ await send_next_single_question(call.message.chat.id, call.from_user.id, session_id)
963
+
964
+
965
+ async def finish_single_session(
966
+ chat_id: int,
967
+ user_id: int,
968
+ session_id: int,
969
+ db: aiosqlite.Connection,
970
+ questions: List[Dict[str, Any]],
971
+ ):
972
+ cur = await db.execute(
973
+ """
974
+ SELECT correct_count,total_time_seconds
975
+ FROM single_sessions WHERE id=?
976
+ """,
977
+ (session_id,),
978
+ )
979
+ srow = await cur.fetchone()
980
+ await cur.close()
981
+ if not srow:
982
+ return
983
+ correct_count, total_time = srow
984
+ await db.execute(
985
+ """
986
+ UPDATE single_sessions
987
+ SET active=0,ended_at=CURRENT_TIMESTAMP
988
+ WHERE id=?
989
+ """,
990
+ (session_id,),
991
+ )
992
+ await db.commit()
993
+
994
+ total_q = len(questions)
995
+ avg_time = total_time / max(total_q, 1)
996
+ text = (
997
+ f"๐ŸŽ‰ Single-player quiz finished!\n\n"
998
+ f"Score: <b>{correct_count}</b> / {total_q}\n"
999
+ f"โฑ Avg time per question: {avg_time:.1f} sec\n\n"
1000
+ "Thanks for playing!"
1001
+ )
1002
+ await bot.send_message(chat_id, text)
1003
+
1004
+
1005
+ # ----- AI PROFILE MANAGEMENT (ADMIN) -----
1006
+
1007
+ @dp.message(Command("add_ai"))
1008
+ async def cmd_add_ai(message: types.Message):
1009
+ if not is_admin(message.from_user):
1010
+ return
1011
+ await message.answer(
1012
+ "๐Ÿค– Let's add a new AI profile.\n"
1013
+ "Send me data in this format (all in one message):\n\n"
1014
+ "<code>Name\n"
1015
+ "API_BASE_URL\n"
1016
+ "API_KEY\n"
1017
+ "MODEL\n"
1018
+ "SYSTEM_PROMPT\n"
1019
+ "DESCRIPTION(optional)</code>\n\n"
1020
+ "Example:\n"
1021
+ "<code>Friendly Tutor\n"
1022
+ "https://api.openai.com/v1\n"
1023
+ "sk-XXX\n"
1024
+ "gpt-4o-mini\n"
1025
+ "You are a friendly tutor for high school.\n"
1026
+ "Helps with math and science.</code>"
1027
+ )
1028
+
1029
+
1030
+ @dp.message(Command("list_ai"))
1031
+ async def cmd_list_ai(message: types.Message):
1032
+ if not is_admin(message.from_user):
1033
+ return
1034
+ async with await get_db() as db:
1035
+ profiles = await list_ai_profiles(db)
1036
+ if not profiles:
1037
+ await message.answer("No AI profiles yet.")
1038
+ return
1039
+ lines = []
1040
+ for p in profiles:
1041
+ status = "โœ…" if p["active"] else "โ›”"
1042
+ lines.append(f"{status} <b>{p['id']}</b> โ€“ {p['name']} โ€“ {p['description']}")
1043
+ await message.answer("\n".join(lines))
1044
+
1045
+
1046
+ @dp.message(Command("edit_ai"))
1047
+ async def cmd_edit_ai(message: types.Message):
1048
+ if not is_admin(message.from_user):
1049
+ return
1050
+ parts = message.text.split(maxsplit=2)
1051
+ if len(parts) < 3:
1052
+ await message.answer(
1053
+ "Usage:\n<code>/edit_ai &lt;id&gt; &lt;on|off|rename|model&gt; new_value</code>"
1054
+ )
1055
+ return
1056
+ ai_id = int(parts[1])
1057
+ field_action = parts[2].split()[0]
1058
+ new_value = " ".join(parts[2].split()[1:])
1059
+
1060
+ async with await get_db() as db:
1061
+ if field_action == "on":
1062
+ await db.execute(
1063
+ "UPDATE ai_profiles SET active=1 WHERE id=?", (ai_id,)
1064
+ )
1065
+ elif field_action == "off":
1066
+ await db.execute(
1067
+ "UPDATE ai_profiles SET active=0 WHERE id=?", (ai_id,)
1068
+ )
1069
+ elif field_action == "rename":
1070
+ await db.execute(
1071
+ "UPDATE ai_profiles SET name=? WHERE id=?", (new_value, ai_id)
1072
+ )
1073
+ elif field_action == "model":
1074
+ await db.execute(
1075
+ "UPDATE ai_profiles SET model=? WHERE id=?", (new_value, ai_id)
1076
+ )
1077
+ else:
1078
+ await message.answer("Unknown action. Use on/off/rename/model.")
1079
+ return
1080
+ await db.commit()
1081
+ await message.answer("โœ… AI profile updated.")
1082
+
1083
+
1084
+ @dp.message(Command("del_ai"))
1085
+ async def cmd_del_ai(message: types.Message):
1086
+ if not is_admin(message.from_user):
1087
+ return
1088
+ parts = message.text.split()
1089
+ if len(parts) < 2:
1090
+ await message.answer("Usage: <code>/del_ai &lt;id&gt;</code>")
1091
+ return
1092
+ ai_id = int(parts[1])
1093
+ async with await get_db() as db:
1094
+ await db.execute("DELETE FROM ai_conversations WHERE profile_id=?", (ai_id,))
1095
+ await db.execute("DELETE FROM ai_profiles WHERE id=?", (ai_id,))
1096
+ await db.commit()
1097
+ await message.answer("๐Ÿ—‘ AI profile deleted.")
1098
+
1099
+
1100
+ @dp.message(lambda m: m.text == "๐Ÿค– Ask AI")
1101
+ async def ai_chat_entry(message: types.Message):
1102
+ async with await get_db() as db:
1103
+ if not await check_promo_membership(bot, db, message.from_user):
1104
+ return
1105
+ profiles = await list_ai_profiles(db)
1106
+ profiles = [p for p in profiles if p["active"]]
1107
+ if not profiles:
1108
+ await message.answer("No AI profiles configured yet. Ask admin.")
1109
+ return
1110
+ kb = InlineKeyboardBuilder()
1111
+ for p in profiles:
1112
+ label = f"{p['name']}"
1113
+ kb.button(text=label[:30], callback_data=f"aiprof_{p['id']}")
1114
+ kb.adjust(1)
1115
+ await message.answer("Choose an AI:", reply_markup=kb.as_markup())
1116
+
1117
+
1118
+ @dp.callback_query(lambda c: c.data and c.data.startswith("aiprof_"))
1119
+ async def select_ai_profile(call: types.CallbackQuery):
1120
+ profile_id = int(call.data.split("_", 1)[1])
1121
+ # store chosen profile in simple in-memory dict or ask user to repeat; for simplicity,
1122
+ # we just ask them to use /ai <profile_id> message
1123
+ await call.message.answer(
1124
+ f"Selected AI profile ID <b>{profile_id}</b>.\n"
1125
+ f"Now send:\n<code>/ai {profile_id} your question...</code>"
1126
+ )
1127
+ await call.answer()
1128
+
1129
+
1130
+ @dp.message(Command("ai"))
1131
+ async def cmd_ai(message: types.Message):
1132
+ parts = message.text.split(maxsplit=2)
1133
+ if len(parts) < 3:
1134
+ await message.answer("Usage: <code>/ai &lt;profile_id&gt; your question...</code>")
1135
+ return
1136
+ profile_id = int(parts[1])
1137
+ user_msg = parts[2]
1138
+
1139
+ async with await get_db() as db:
1140
+ if not await check_promo_membership(bot, db, message.from_user):
1141
+ return
1142
+ await ensure_user(db, message.from_user)
1143
+ profile = await get_ai_profile(db, profile_id)
1144
+ if not profile or not profile["active"]:
1145
+ await message.answer("AI profile not found or inactive.")
1146
+ return
1147
+ history = await get_conversation_history(db, message.from_user.id, profile_id)
1148
+
1149
+ await message.answer("โณ Asking AI...")
1150
+
1151
+ try:
1152
+ ai_reply = await call_ai(
1153
+ profile,
1154
+ history + [{"role": "user", "content": user_msg}],
1155
+ )
1156
+ except Exception as e:
1157
+ logger.exception("AI call failed")
1158
+ await message.answer("โŒ AI error.")
1159
+ return
1160
+
1161
+ async with await get_db() as db:
1162
+ await append_message(
1163
+ db, message.from_user.id, profile_id, "user", user_msg
1164
+ )
1165
+ await append_message(
1166
+ db, message.from_user.id, profile_id, "assistant", ai_reply
1167
+ )
1168
+
1169
+ await message.answer(ai_reply)
1170
+
1171
+
1172
+ # ----- PROMO RECHECK CALLBACK -----
1173
+
1174
+ @dp.callback_query(lambda c: c.data == "promo_recheck")
1175
+ async def promo_recheck(call: types.CallbackQuery):
1176
+ async with await get_db() as db:
1177
+ ok = await check_promo_membership(bot, db, call.from_user)
1178
+ if ok:
1179
+ await call.message.answer("โœ… Thanks for joining! You can now use the bot.", reply_markup=main_menu_kb())
1180
+ await call.answer()
1181
+
1182
+
1183
+ # ----- BASIC TEXT MENU ROUTER -----
1184
+
1185
+ @dp.message()
1186
+ async def text_router(message: types.Message):
1187
+ # we handled menu labels above; we can add simple hints
1188
+ if message.text in {
1189
+ "๐ŸŽฎ Single Quiz",
1190
+ "๐Ÿ‘ฅ Multiplayer",
1191
+ "๐Ÿ† Tournament",
1192
+ "๐Ÿ“š Reference Books",
1193
+ "๐Ÿค– Ask AI",
1194
+ }:
1195
+ # individual handlers already exist
1196
+ return
1197
+ # Default: just show menu
1198
+ await message.answer("Use the menu below:", reply_markup=main_menu_kb())
1199
+
1200
+
1201
+ # ============ TOURNAMENT SCHEDULING (SKELETON) ============
1202
+ # Due to length, we implement the core requirement:
1203
+ # - admin can schedule a tournament (code, group_chat_id, quiz, timer, start time)
1204
+ # - at scheduled time, bot announces in group with deep link + start time
1205
+ # - detailed tournament gameplay can be added next iteration.
1206
+
1207
+
1208
+ @dp.message(Command("schedule_tour"))
1209
+ async def cmd_schedule_tour(message: types.Message):
1210
+ if not is_admin(message.from_user):
1211
+ return
1212
+ parts = message.text.split(maxsplit=5)
1213
+ if len(parts) < 6:
1214
+ await message.answer(
1215
+ "Usage:\n"
1216
+ "<code>/schedule_tour &lt;code&gt; &lt;group_chat_id&gt; &lt;quiz_id&gt; &lt;timer_id|0&gt; &lt;YYYY-MM-DD_HH:MM&gt;</code>\n"
1217
+ "Time is in UTC.\n"
1218
+ "Example:\n"
1219
+ "<code>/schedule_tour battle1 -1001234567890 1 2 2025-01-01_18:00</code>"
1220
+ )
1221
+ return
1222
+ code = parts[1]
1223
+ group_chat_id = int(parts[2])
1224
+ quiz_id = int(parts[3])
1225
+ timer_id = int(parts[4]) if parts[4] != "0" else None
1226
+ dt_str = parts[5]
1227
+ try:
1228
+ scheduled = datetime.strptime(dt_str, "%Y-%m-%d_%H:%M").replace(tzinfo=timezone.utc)
1229
+ except ValueError:
1230
+ await message.answer("โŒ Invalid datetime. Use YYYY-MM-DD_HH:MM (UTC).")
1231
+ return
1232
+
1233
+ async with await get_db() as db:
1234
+ cur = await db.execute(
1235
+ """
1236
+ INSERT INTO tournaments(code,admin_user_id,group_chat_id,quiz_file_id,timer_video_id,scheduled_start)
1237
+ VALUES(?,?,?,?,?,?)
1238
+ """,
1239
+ (code, message.from_user.id, group_chat_id, quiz_id, timer_id, scheduled.isoformat()),
1240
+ )
1241
+ tour_id = cur.lastrowid
1242
+ await db.commit()
1243
+
1244
+ # schedule job
1245
+ scheduler.add_job(
1246
+ func=announce_tournament_start,
1247
+ trigger=DateTrigger(run_date=scheduled),
1248
+ args=(tour_id,),
1249
+ id=f"tour_{tour_id}",
1250
+ replace_existing=True,
1251
+ )
1252
+
1253
+ await message.answer(
1254
+ f"โœ… Tournament scheduled.\n"
1255
+ f"Code: <code>{code}</code>\n"
1256
+ f"Group: <code>{group_chat_id}</code>\n"
1257
+ f"Quiz ID: {quiz_id}\n"
1258
+ f"Timer ID: {timer_id or 'none'}\n"
1259
+ f"Start (UTC): {scheduled:%Y-%m-%d %H:%M}"
1260
+ )
1261
+
1262
+
1263
+ async def announce_tournament_start(tour_id: int):
1264
+ async with await get_db() as db:
1265
+ cur = await db.execute(
1266
+ """
1267
+ SELECT code,group_chat_id,quiz_file_id,timer_video_id,scheduled_start
1268
+ FROM tournaments WHERE id=?
1269
+ """,
1270
+ (tour_id,),
1271
+ )
1272
+ row = await cur.fetchone()
1273
+ await cur.close()
1274
+ if not row:
1275
+ return
1276
+ code, group_chat_id, quiz_id, timer_id, scheduled_str = row
1277
+ # mark as running
1278
+ await db.execute(
1279
+ "UPDATE tournaments SET status='running',started_at=CURRENT_TIMESTAMP WHERE id=?",
1280
+ (tour_id,),
1281
+ )
1282
+ await db.commit()
1283
+
1284
+ scheduled = datetime.fromisoformat(scheduled_str)
1285
+ start_text = scheduled.strftime("%Y-%m-%d %H:%M UTC")
1286
+ deep_link = f"https://t.me/{(await bot.me()).username}?start=tour_{code}"
1287
+
1288
+ text = (
1289
+ f"๐Ÿ† <b>Tournament Starting Now!</b>\n\n"
1290
+ f"Code: <code>{code}</code>\n"
1291
+ f"Start time: <b>{start_text}</b>\n\n"
1292
+ f"โžก๏ธ Join via this link: {deep_link}\n\n"
1293
+ "Get ready!"
1294
+ )
1295
+
1296
+ try:
1297
+ await bot.send_message(group_chat_id, text)
1298
+ except Exception as e:
1299
+ logger.error("Failed to announce tournament: %s", e)
1300
+
1301
+
1302
+ # ============ STARTUP ============
1303
+
1304
+ async def main():
1305
+ await init_db()
1306
+ scheduler.start()
1307
+ logger.info("Bot started.")
1308
+ await dp.start_polling(bot)
1309
+
1310
+
1311
+ if __name__ == "__main__":
1312
+ asyncio.run(main())