| | import asyncio |
| | import logging |
| | import os |
| | import re |
| | import textwrap |
| | from datetime import datetime, timedelta, timezone |
| | from typing import Optional, List, Dict, Any |
| |
|
| | import aiosqlite |
| | from aiogram import Bot, Dispatcher, types |
| | from aiogram.filters import Command, CommandStart |
| | from aiogram.types import ( |
| | InlineKeyboardMarkup, |
| | InlineKeyboardButton, |
| | ReplyKeyboardMarkup, |
| | KeyboardButton, |
| | ReplyKeyboardRemove, |
| | ) |
| | from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder |
| |
|
| | from apscheduler.schedulers.asyncio import AsyncIOScheduler |
| | from apscheduler.triggers.date import DateTrigger |
| |
|
| | logging.basicConfig(level=logging.INFO) |
| | logger = logging.getLogger(__name__) |
| |
|
| | BOT_TOKEN = os.getenv("BOT_TOKEN") |
| | if not BOT_TOKEN: |
| | raise RuntimeError("BOT_TOKEN env var is required") |
| |
|
| | ADMIN_USERNAME = "@nameofbless" |
| |
|
| | DB_PATH = os.getenv("DB_PATH", "bot.db") |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | import aiohttp |
| |
|
| | scheduler = AsyncIOScheduler(timezone=timezone.utc) |
| |
|
| |
|
| | |
| |
|
| | CREATE_TABLES_SQL = """ |
| | PRAGMA journal_mode=WAL; |
| | |
| | CREATE TABLE IF NOT EXISTS bot_settings ( |
| | key TEXT PRIMARY KEY, |
| | value TEXT |
| | ); |
| | |
| | CREATE TABLE IF NOT EXISTS users ( |
| | user_id INTEGER PRIMARY KEY, |
| | username TEXT, |
| | first_name TEXT, |
| | last_name TEXT |
| | ); |
| | |
| | -- AI profiles managed by admin |
| | CREATE TABLE IF NOT EXISTS ai_profiles ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | name TEXT NOT NULL UNIQUE, |
| | api_base TEXT NOT NULL, |
| | api_key TEXT NOT NULL, |
| | model TEXT NOT NULL, |
| | system_prompt TEXT NOT NULL, |
| | description TEXT DEFAULT '', |
| | active INTEGER DEFAULT 1 |
| | ); |
| | |
| | -- Conversation history (20 messages max per (user, profile)) |
| | CREATE TABLE IF NOT EXISTS ai_conversations ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | user_id INTEGER NOT NULL, |
| | profile_id INTEGER NOT NULL, |
| | role TEXT NOT NULL, -- 'user' or 'assistant' |
| | content TEXT NOT NULL, |
| | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| | ); |
| | |
| | -- Quiz files (text) stored by Telegram file_id |
| | CREATE TABLE IF NOT EXISTS quiz_files ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | file_id TEXT NOT NULL, |
| | subject TEXT NOT NULL, |
| | title TEXT NOT NULL, |
| | added_by INTEGER NOT NULL, |
| | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| | ); |
| | |
| | -- Questions parsed from quiz_files |
| | CREATE TABLE IF NOT EXISTS questions ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | quiz_file_id INTEGER NOT NULL, |
| | question TEXT NOT NULL, |
| | option_a TEXT NOT NULL, |
| | option_b TEXT NOT NULL, |
| | option_c TEXT NOT NULL, |
| | option_d TEXT NOT NULL, |
| | correct_option TEXT NOT NULL, -- 'A','B','C','D' |
| | explanation TEXT NOT NULL, |
| | hint TEXT DEFAULT '', |
| | FOREIGN KEY (quiz_file_id) REFERENCES quiz_files(id) ON DELETE CASCADE |
| | ); |
| | |
| | -- GIF / video timer resources |
| | CREATE TABLE IF NOT EXISTS timer_videos ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | file_id TEXT NOT NULL, |
| | duration_seconds INTEGER NOT NULL, |
| | scope TEXT NOT NULL CHECK(scope IN ('single','multi','tournament')), |
| | label TEXT NOT NULL |
| | ); |
| | |
| | -- Reference books (PDFs etc) by file_id |
| | CREATE TABLE IF NOT EXISTS reference_books ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | file_id TEXT NOT NULL, |
| | title TEXT NOT NULL, |
| | added_by INTEGER NOT NULL, |
| | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| | ); |
| | |
| | -- Single-player quiz sessions |
| | CREATE TABLE IF NOT EXISTS single_sessions ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | user_id INTEGER NOT NULL, |
| | quiz_file_id INTEGER NOT NULL, |
| | current_question_index INTEGER NOT NULL DEFAULT 0, |
| | correct_count INTEGER NOT NULL DEFAULT 0, |
| | total_time_seconds INTEGER NOT NULL DEFAULT 0, |
| | started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| | ended_at TIMESTAMP, |
| | active INTEGER DEFAULT 1, |
| | timer_video_id INTEGER, |
| | FOREIGN KEY (quiz_file_id) REFERENCES quiz_files(id), |
| | FOREIGN KEY (timer_video_id) REFERENCES timer_videos(id) |
| | ); |
| | |
| | CREATE TABLE IF NOT EXISTS single_answers ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | session_id INTEGER NOT NULL, |
| | question_id INTEGER NOT NULL, |
| | chosen_option TEXT, |
| | is_correct INTEGER NOT NULL DEFAULT 0, |
| | time_spent_seconds INTEGER NOT NULL DEFAULT 0, |
| | FOREIGN KEY (session_id) REFERENCES single_sessions(id), |
| | FOREIGN KEY (question_id) REFERENCES questions(id) |
| | ); |
| | |
| | -- Multiplayer rooms |
| | CREATE TABLE IF NOT EXISTS multi_rooms ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | code TEXT NOT NULL UNIQUE, |
| | host_user_id INTEGER NOT NULL, |
| | chat_id INTEGER NOT NULL, |
| | quiz_file_id INTEGER NOT NULL, |
| | timer_video_id INTEGER, |
| | status TEXT NOT NULL DEFAULT 'lobby', -- 'lobby','running','finished' |
| | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| | started_at TIMESTAMP, |
| | ended_at TIMESTAMP, |
| | FOREIGN KEY (quiz_file_id) REFERENCES quiz_files(id), |
| | FOREIGN KEY (timer_video_id) REFERENCES timer_videos(id) |
| | ); |
| | |
| | CREATE TABLE IF NOT EXISTS multi_players ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | room_id INTEGER NOT NULL, |
| | user_id INTEGER NOT NULL, |
| | score INTEGER NOT NULL DEFAULT 0, |
| | total_time_seconds INTEGER NOT NULL DEFAULT 0, |
| | joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| | UNIQUE(room_id, user_id), |
| | FOREIGN KEY (room_id) REFERENCES multi_rooms(id) |
| | ); |
| | |
| | CREATE TABLE IF NOT EXISTS multi_answers ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | room_id INTEGER NOT NULL, |
| | user_id INTEGER NOT NULL, |
| | question_id INTEGER NOT NULL, |
| | chosen_option TEXT, |
| | is_correct INTEGER NOT NULL DEFAULT 0, |
| | time_spent_seconds INTEGER NOT NULL DEFAULT 0, |
| | FOREIGN KEY (room_id) REFERENCES multi_rooms(id), |
| | FOREIGN KEY (question_id) REFERENCES questions(id) |
| | ); |
| | |
| | -- Tournament |
| | CREATE TABLE IF NOT EXISTS tournaments ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | code TEXT NOT NULL UNIQUE, |
| | admin_user_id INTEGER NOT NULL, |
| | group_chat_id INTEGER NOT NULL, |
| | quiz_file_id INTEGER NOT NULL, |
| | timer_video_id INTEGER, |
| | status TEXT NOT NULL DEFAULT 'scheduled', -- 'scheduled','running','finished' |
| | scheduled_start TIMESTAMP NOT NULL, |
| | started_at TIMESTAMP, |
| | ended_at TIMESTAMP, |
| | FOREIGN KEY (quiz_file_id) REFERENCES quiz_files(id), |
| | FOREIGN KEY (timer_video_id) REFERENCES timer_videos(id) |
| | ); |
| | |
| | CREATE TABLE IF NOT EXISTS tournament_players ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | tournament_id INTEGER NOT NULL, |
| | user_id INTEGER NOT NULL, |
| | score INTEGER NOT NULL DEFAULT 0, |
| | total_time_seconds INTEGER NOT NULL DEFAULT 0, |
| | joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| | UNIQUE(tournament_id, user_id), |
| | FOREIGN KEY (tournament_id) REFERENCES tournaments(id) |
| | ); |
| | |
| | CREATE TABLE IF NOT EXISTS tournament_answers ( |
| | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | tournament_id INTEGER NOT NULL, |
| | user_id INTEGER NOT NULL, |
| | question_id INTEGER NOT NULL, |
| | chosen_option TEXT, |
| | is_correct INTEGER NOT NULL DEFAULT 0, |
| | time_spent_seconds INTEGER NOT NULL DEFAULT 0, |
| | FOREIGN KEY (tournament_id) REFERENCES tournaments(id), |
| | FOREIGN KEY (question_id) REFERENCES questions(id) |
| | ); |
| | """ |
| |
|
| |
|
| | async def init_db(): |
| | async with aiosqlite.connect(DB_PATH) as db: |
| | await db.executescript(CREATE_TABLES_SQL) |
| | await db.commit() |
| |
|
| |
|
| | |
| |
|
| | async def get_db(): |
| | return await aiosqlite.connect(DB_PATH) |
| |
|
| |
|
| | def is_admin(user: types.User) -> bool: |
| | return user.username and f"@{user.username}" == ADMIN_USERNAME |
| |
|
| |
|
| | async def get_setting(db: aiosqlite.Connection, key: str) -> Optional[str]: |
| | cursor = await db.execute("SELECT value FROM bot_settings WHERE key = ?", (key,)) |
| | row = await cursor.fetchone() |
| | await cursor.close() |
| | return row[0] if row else None |
| |
|
| |
|
| | async def set_setting(db: aiosqlite.Connection, key: str, value: str): |
| | await db.execute( |
| | "INSERT INTO bot_settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", |
| | (key, value), |
| | ) |
| | await db.commit() |
| |
|
| |
|
| | async def ensure_user(db: aiosqlite.Connection, user: types.User): |
| | await db.execute( |
| | """ |
| | INSERT INTO users(user_id, username, first_name, last_name) |
| | VALUES(?,?,?,?) |
| | ON CONFLICT(user_id) DO UPDATE SET |
| | username=excluded.username, |
| | first_name=excluded.first_name, |
| | last_name=excluded.last_name |
| | """, |
| | (user.id, user.username, user.first_name, user.last_name), |
| | ) |
| | await db.commit() |
| |
|
| |
|
| | async def check_promo_membership(bot: Bot, db: aiosqlite.Connection, user: types.User) -> bool: |
| | promo_channel_id = await get_setting(db, "promo_channel_id") |
| | promo_channel_link = await get_setting(db, "promo_channel_link") |
| | if not promo_channel_id: |
| | return True |
| |
|
| | try: |
| | member = await bot.get_chat_member(int(promo_channel_id), user.id) |
| | if member.status in ("member", "administrator", "creator"): |
| | return True |
| | except Exception as e: |
| | logger.warning("Failed to check promo membership: %s", e) |
| |
|
| | |
| | kb = InlineKeyboardBuilder() |
| | if promo_channel_link: |
| | kb.button(text="๐ข Join Channel", url=promo_channel_link) |
| | kb.button(text="โ
I Joined", callback_data="promo_recheck") |
| | await bot.send_message( |
| | user.id, |
| | "๐ซ To use this bot you must join our promotion channel first.", |
| | reply_markup=kb.as_markup(), |
| | ) |
| | return False |
| |
|
| |
|
| | def main_menu_kb() -> ReplyKeyboardMarkup: |
| | kb = ReplyKeyboardBuilder() |
| | kb.button(text="๐ฎ Single Quiz") |
| | kb.button(text="๐ฅ Multiplayer") |
| | kb.button(text="๐ Tournament") |
| | kb.button(text="๐ Reference Books") |
| | kb.button(text="๐ค Ask AI") |
| | return kb.as_markup(resize_keyboard=True) |
| |
|
| |
|
| | def medal_for_position(idx: int) -> str: |
| | if idx == 0: |
| | return "๐ฅ" |
| | if idx == 1: |
| | return "๐ฅ" |
| | if idx == 2: |
| | return "๐ฅ" |
| | return f"{idx+1}." |
| |
|
| |
|
| | |
| |
|
| | QUIZ_BLOCK_RE = re.compile( |
| | r"Question:\s*(?P<q>.+?)\n" |
| | r"A\)\s*(?P<a>.+?)\n" |
| | r"B\)\s*(?P<b>.+?)\n" |
| | r"C\)\s*(?P<c>.+?)\n" |
| | r"D\)\s*(?P<d>.+?)\n" |
| | r"Answer:\s*(?P<ans>[ABCD])\s*\n" |
| | r"Explanation:\s*(?P<exp>.+?)\n" |
| | r"Hint:\s*(?P<hint>.+?)\n?", |
| | re.DOTALL | re.IGNORECASE, |
| | ) |
| |
|
| |
|
| | async def parse_and_store_quiz( |
| | db: aiosqlite.Connection, |
| | file_id: str, |
| | text: str, |
| | subject: str, |
| | title: str, |
| | admin_id: int, |
| | ) -> int: |
| | cursor = await db.execute( |
| | """ |
| | INSERT INTO quiz_files(file_id,subject,title,added_by) |
| | VALUES(?,?,?,?) |
| | """, |
| | (file_id, subject.strip(), title.strip(), admin_id), |
| | ) |
| | quiz_file_id = cursor.lastrowid |
| |
|
| | matches = list(QUIZ_BLOCK_RE.finditer(text)) |
| | if not matches: |
| | raise ValueError("No questions found in file. Make sure format is correct.") |
| |
|
| | for m in matches: |
| | q = m.group("q").strip() |
| | a = m.group("a").strip() |
| | b = m.group("b").strip() |
| | c = m.group("c").strip() |
| | d = m.group("d").strip() |
| | ans = m.group("ans").strip().upper() |
| | exp = m.group("exp").strip() |
| | hint = m.group("hint").strip() |
| | await db.execute( |
| | """ |
| | INSERT INTO questions(quiz_file_id,question,option_a,option_b,option_c,option_d,correct_option,explanation,hint) |
| | VALUES(?,?,?,?,?,?,?,?,?) |
| | """, |
| | (quiz_file_id, q, a, b, c, d, ans, exp, hint), |
| | ) |
| |
|
| | await db.commit() |
| | return quiz_file_id |
| |
|
| |
|
| | |
| |
|
| | async def list_ai_profiles(db: aiosqlite.Connection) -> List[Dict[str, Any]]: |
| | cur = await db.execute( |
| | "SELECT id,name,description,active FROM ai_profiles ORDER BY id" |
| | ) |
| | rows = await cur.fetchall() |
| | await cur.close() |
| | return [ |
| | { |
| | "id": r[0], |
| | "name": r[1], |
| | "description": r[2], |
| | "active": bool(r[3]), |
| | } |
| | for r in rows |
| | ] |
| |
|
| |
|
| | async def get_ai_profile(db: aiosqlite.Connection, profile_id: int) -> Optional[Dict[str, Any]]: |
| | cur = await db.execute( |
| | """ |
| | SELECT id,name,api_base,api_key,model,system_prompt,description,active |
| | FROM ai_profiles WHERE id=? |
| | """, |
| | (profile_id,), |
| | ) |
| | row = await cur.fetchone() |
| | await cur.close() |
| | if not row: |
| | return None |
| | return { |
| | "id": row[0], |
| | "name": row[1], |
| | "api_base": row[2], |
| | "api_key": row[3], |
| | "model": row[4], |
| | "system_prompt": row[5], |
| | "description": row[6], |
| | "active": bool(row[7]), |
| | } |
| |
|
| |
|
| | async def get_conversation_history( |
| | db: aiosqlite.Connection, user_id: int, profile_id: int |
| | ) -> List[Dict[str, str]]: |
| | cur = await db.execute( |
| | """ |
| | SELECT role,content FROM ai_conversations |
| | WHERE user_id=? AND profile_id=? |
| | ORDER BY id ASC |
| | """, |
| | (user_id, profile_id), |
| | ) |
| | rows = await cur.fetchall() |
| | await cur.close() |
| | return [{"role": r[0], "content": r[1]} for r in rows] |
| |
|
| |
|
| | async def append_message( |
| | db: aiosqlite.Connection, |
| | user_id: int, |
| | profile_id: int, |
| | role: str, |
| | content: str, |
| | ): |
| | await db.execute( |
| | """ |
| | INSERT INTO ai_conversations(user_id,profile_id,role,content) |
| | VALUES(?,?,?,?) |
| | """, |
| | (user_id, profile_id, role, content), |
| | ) |
| | |
| | await db.execute( |
| | """ |
| | DELETE FROM ai_conversations |
| | WHERE id IN ( |
| | SELECT id FROM ai_conversations |
| | WHERE user_id=? AND profile_id=? |
| | ORDER BY id ASC |
| | LIMIT ( |
| | SELECT MAX(COUNT(*)-20,0) |
| | FROM ai_conversations |
| | WHERE user_id=? AND profile_id=? |
| | ) |
| | ) |
| | """, |
| | (user_id, profile_id, user_id, profile_id), |
| | ) |
| | await db.commit() |
| |
|
| |
|
| | async def call_ai(profile: Dict[str, Any], messages: List[Dict[str, str]]) -> str: |
| | |
| | url = profile["api_base"].rstrip("/") + "/chat/completions" |
| | headers = { |
| | "Authorization": f"Bearer {profile['api_key']}", |
| | "Content-Type": "application/json", |
| | } |
| | body = { |
| | "model": profile["model"], |
| | "messages": [{"role": "system", "content": profile["system_prompt"]}] |
| | + messages, |
| | } |
| | async with aiohttp.ClientSession() as session: |
| | async with session.post(url, json=body, headers=headers, timeout=120) as resp: |
| | if resp.status != 200: |
| | txt = await resp.text() |
| | logger.error("AI error %s: %s", resp.status, txt) |
| | raise RuntimeError("AI API error") |
| | data = await resp.json() |
| | return data["choices"][0]["message"]["content"] |
| |
|
| |
|
| | |
| |
|
| | bot = Bot(token=BOT_TOKEN, parse_mode="HTML") |
| | dp = Dispatcher() |
| |
|
| |
|
| | |
| |
|
| | @dp.message(CommandStart()) |
| | async def cmd_start(message: types.Message): |
| | async with await get_db() as db: |
| | await ensure_user(db, message.from_user) |
| | if not await check_promo_membership(bot, db, message.from_user): |
| | return |
| |
|
| | text = ( |
| | "๐ Welcome to the Quiz & AI Bot!\n\n" |
| | "Use the menu below to start:\n" |
| | "๐ฎ Single Quiz\n" |
| | "๐ฅ Multiplayer\n" |
| | "๐ Tournament\n" |
| | "๐ Reference Books\n" |
| | "๐ค Ask AI" |
| | ) |
| | await message.answer(text, reply_markup=main_menu_kb()) |
| |
|
| |
|
| | @dp.message(Command("menu")) |
| | async def cmd_menu(message: types.Message): |
| | await message.answer("๐ Main menu:", reply_markup=main_menu_kb()) |
| |
|
| |
|
| | |
| |
|
| | @dp.message(Command("set_promo_channel")) |
| | async def cmd_set_promo_channel(message: types.Message): |
| | if not is_admin(message.from_user): |
| | return |
| | parts = message.text.split() |
| | if len(parts) < 3: |
| | await message.answer( |
| | "Usage:\n" |
| | "<code>/set_promo_channel <channel_id> <channel_link></code>\n" |
| | "Example:\n" |
| | "<code>/set_promo_channel -1001234567890 https://t.me/yourchannel</code>" |
| | ) |
| | return |
| | channel_id = parts[1] |
| | channel_link = parts[2] |
| | async with await get_db() as db: |
| | await set_setting(db, "promo_channel_id", channel_id) |
| | await set_setting(db, "promo_channel_link", channel_link) |
| | await message.answer( |
| | f"โ
Promotion channel set.\nID: <code>{channel_id}</code>\nLink: {channel_link}" |
| | ) |
| |
|
| |
|
| | |
| |
|
| | @dp.message(Command("add_quiz")) |
| | async def cmd_add_quiz(message: types.Message): |
| | if not is_admin(message.from_user): |
| | return |
| |
|
| | await message.answer( |
| | "๐ Send me a TEXT FILE with your quiz in this format:\n\n" |
| | "Question: ...\n" |
| | "A) ...\nB) ...\nC) ...\nD) ...\n" |
| | "Answer: A\n" |
| | "Explanation: ...\n" |
| | "Hint: ...\n\n" |
| | "Send the file now." |
| | ) |
| |
|
| |
|
| | @dp.message(lambda m: m.document and m.caption and m.caption.startswith("quiz:")) |
| | async def handle_quiz_file(message: types.Message): |
| | |
| | if not is_admin(message.from_user): |
| | return |
| |
|
| | doc = message.document |
| | caption = message.caption[len("quiz:") :].strip() |
| | try: |
| | subject, title = [x.strip() for x in caption.split("|", 1)] |
| | except ValueError: |
| | await message.answer( |
| | "โ Caption format invalid.\nUse: <code>quiz: Subject | Title</code>" |
| | ) |
| | return |
| |
|
| | file = await bot.get_file(doc.file_id) |
| | file_bytes = await bot.download_file(file.file_path) |
| | text = file_bytes.read().decode("utf-8", errors="ignore") |
| |
|
| | async with await get_db() as db: |
| | try: |
| | quiz_file_id = await parse_and_store_quiz( |
| | db=db, |
| | file_id=doc.file_id, |
| | text=text, |
| | subject=subject, |
| | title=title, |
| | admin_id=message.from_user.id, |
| | ) |
| | except Exception as e: |
| | logger.exception("Quiz parse error") |
| | await message.answer(f"โ Failed to parse quiz: {e}") |
| | return |
| |
|
| | await message.answer(f"โ
Quiz saved (ID: {quiz_file_id}) with subject <b>{subject}</b> and title <b>{title}</b>.") |
| |
|
| |
|
| | |
| |
|
| | @dp.message(Command("add_timer")) |
| | async def cmd_add_timer(message: types.Message): |
| | if not is_admin(message.from_user): |
| | return |
| | await message.answer( |
| | "โฑ Send a GIF/Video with caption:\n\n" |
| | "<code>timer: scope duration label</code>\n\n" |
| | "Where:\n" |
| | "โข scope = single | multi | tournament\n" |
| | "โข duration = seconds (e.g. 30)\n" |
| | "โข label = short name (no spaces or use _)\n\n" |
| | "Example:\n" |
| | "<code>timer: single 30 basic_single</code>" |
| | ) |
| |
|
| |
|
| | @dp.message(lambda m: m.video or m.animation) |
| | async def handle_timer_video(message: types.Message): |
| | if not is_admin(message.from_user): |
| | return |
| | if not message.caption or not message.caption.startswith("timer:"): |
| | return |
| |
|
| | try: |
| | _, rest = message.caption.split("timer:", 1) |
| | parts = rest.strip().split() |
| | scope = parts[0] |
| | duration = int(parts[1]) |
| | label = parts[2] if len(parts) > 2 else f"{scope}_{duration}s" |
| | if scope not in ("single", "multi", "tournament"): |
| | raise ValueError("invalid scope") |
| | except Exception: |
| | await message.answer( |
| | "โ Invalid caption.\nUse: <code>timer: scope duration label</code>\n" |
| | "Example: <code>timer: single 30 basic_single</code>" |
| | ) |
| | return |
| |
|
| | file_id = ( |
| | message.video.file_id if message.video else message.animation.file_id |
| | ) |
| |
|
| | async with await get_db() as db: |
| | await db.execute( |
| | """ |
| | INSERT INTO timer_videos(file_id,duration_seconds,scope,label) |
| | VALUES(?,?,?,?) |
| | """, |
| | (file_id, duration, scope, label), |
| | ) |
| | await db.commit() |
| | await message.answer( |
| | f"โ
Timer video saved.\nScope: {scope}\nDuration: {duration}s\nLabel: {label}" |
| | ) |
| |
|
| |
|
| | |
| |
|
| | @dp.message(Command("add_book")) |
| | async def cmd_add_book(message: types.Message): |
| | if not is_admin(message.from_user): |
| | return |
| | await message.answer( |
| | "๐ Send a PDF (or any doc) with caption:\n" |
| | "<code>book: Title of Book</code>" |
| | ) |
| |
|
| |
|
| | @dp.message(lambda m: m.document and m.caption and m.caption.startswith("book:")) |
| | async def handle_book(message: types.Message): |
| | if not is_admin(message.from_user): |
| | return |
| | title = message.caption[len("book:") :].strip() |
| | file_id = message.document.file_id |
| |
|
| | async with await get_db() as db: |
| | await db.execute( |
| | """ |
| | INSERT INTO reference_books(file_id,title,added_by) |
| | VALUES(?,?,?) |
| | """, |
| | (file_id, title, message.from_user.id), |
| | ) |
| | await db.commit() |
| |
|
| | await message.answer(f"โ
Reference book added: <b>{title}</b>.") |
| |
|
| |
|
| | @dp.message(lambda m: m.text == "๐ Reference Books") |
| | async def list_books(message: types.Message): |
| | async with await get_db() as db: |
| | if not await check_promo_membership(bot, db, message.from_user): |
| | return |
| | cur = await db.execute( |
| | "SELECT id,title FROM reference_books ORDER BY id DESC" |
| | ) |
| | rows = await cur.fetchall() |
| | await cur.close() |
| | if not rows: |
| | await message.answer("๐ No reference books yet.") |
| | return |
| |
|
| | kb = InlineKeyboardBuilder() |
| | for row in rows: |
| | kb.button(text=row[1][:30], callback_data=f"book_{row[0]}") |
| | kb.adjust(1) |
| | await message.answer("Select a reference book:", reply_markup=kb.as_markup()) |
| |
|
| |
|
| | @dp.callback_query(lambda c: c.data and c.data.startswith("book_")) |
| | async def send_book(call: types.CallbackQuery): |
| | book_id = int(call.data.split("_", 1)[1]) |
| | async with await get_db() as db: |
| | cur = await db.execute( |
| | "SELECT file_id,title FROM reference_books WHERE id=?", (book_id,) |
| | ) |
| | row = await cur.fetchone() |
| | await cur.close() |
| | if not row: |
| | await call.answer("Not found.", show_alert=True) |
| | return |
| | file_id, title = row |
| | await bot.send_document(call.from_user.id, file_id, caption=f"๐ {title}") |
| | await call.answer() |
| |
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | async def get_latest_quiz(db: aiosqlite.Connection) -> Optional[Dict[str, Any]]: |
| | cur = await db.execute( |
| | "SELECT id,subject,title FROM quiz_files ORDER BY id DESC LIMIT 1" |
| | ) |
| | row = await cur.fetchone() |
| | await cur.close() |
| | if not row: |
| | return None |
| | return {"id": row[0], "subject": row[1], "title": row[2]} |
| |
|
| |
|
| | async def get_questions_for_quiz( |
| | db: aiosqlite.Connection, quiz_file_id: int |
| | ) -> List[Dict[str, Any]]: |
| | cur = await db.execute( |
| | """ |
| | SELECT id,question,option_a,option_b,option_c,option_d,correct_option,explanation,hint |
| | FROM questions WHERE quiz_file_id=? |
| | ORDER BY id |
| | """, |
| | (quiz_file_id,), |
| | ) |
| | rows = await cur.fetchall() |
| | await cur.close() |
| | return [ |
| | { |
| | "id": r[0], |
| | "question": r[1], |
| | "a": r[2], |
| | "b": r[3], |
| | "c": r[4], |
| | "d": r[5], |
| | "correct": r[6], |
| | "exp": r[7], |
| | "hint": r[8], |
| | } |
| | for r in rows |
| | ] |
| |
|
| |
|
| | @dp.message(lambda m: m.text == "๐ฎ Single Quiz") |
| | async def single_quiz_entry(message: types.Message): |
| | async with await get_db() as db: |
| | if not await check_promo_membership(bot, db, message.from_user): |
| | return |
| | await ensure_user(db, message.from_user) |
| | quiz = await get_latest_quiz(db) |
| | if not quiz: |
| | await message.answer("โ No quizzes yet. Ask admin to add one.") |
| | return |
| |
|
| | |
| | cur = await db.execute( |
| | """ |
| | SELECT id,file_id,duration_seconds FROM timer_videos |
| | WHERE scope='single' ORDER BY id DESC LIMIT 1 |
| | """ |
| | ) |
| | trow = await cur.fetchone() |
| | await cur.close() |
| | timer_id = trow[0] if trow else None |
| |
|
| | cur = await db.execute( |
| | """ |
| | INSERT INTO single_sessions(user_id,quiz_file_id,timer_video_id) |
| | VALUES(?,?,?) |
| | """, |
| | (message.from_user.id, quiz["id"], timer_id), |
| | ) |
| | session_id = cur.lastrowid |
| | await db.commit() |
| |
|
| | await message.answer( |
| | f"๐ฎ Starting single-player quiz:\n<b>{quiz['title']}</b>\nSubject: {quiz['subject']}" |
| | ) |
| | await send_next_single_question(message.chat.id, message.from_user.id, session_id) |
| |
|
| |
|
| | async def send_next_single_question(chat_id: int, user_id: int, session_id: int): |
| | async with await get_db() as db: |
| | cur = await db.execute( |
| | """ |
| | SELECT quiz_file_id,current_question_index,timer_video_id |
| | FROM single_sessions WHERE id=? AND active=1 |
| | """, |
| | (session_id,), |
| | ) |
| | srow = await cur.fetchone() |
| | await cur.close() |
| | if not srow: |
| | return |
| | quiz_file_id, idx, timer_video_id = srow |
| | questions = await get_questions_for_quiz(db, quiz_file_id) |
| | if idx >= len(questions): |
| | |
| | await finish_single_session(chat_id, user_id, session_id, db, questions) |
| | return |
| | q = questions[idx] |
| |
|
| | |
| | if timer_video_id: |
| | cur = await db.execute( |
| | """ |
| | SELECT file_id,duration_seconds FROM timer_videos WHERE id=? |
| | """, |
| | (timer_video_id,), |
| | ) |
| | trow = await cur.fetchone() |
| | await cur.close() |
| | else: |
| | trow = None |
| |
|
| | if trow: |
| | file_id, duration = trow |
| | await bot.send_animation(chat_id, file_id, caption=f"โฑ {duration} seconds") |
| |
|
| | kb = InlineKeyboardBuilder() |
| | kb.button(text=f"A) {q['a']}", callback_data=f"sans_{session_id}_{q['id']}_A") |
| | kb.button(text=f"B) {q['b']}", callback_data=f"sans_{session_id}_{q['id']}_B") |
| | kb.button(text=f"C) {q['c']}", callback_data=f"sans_{session_id}_{q['id']}_C") |
| | kb.button(text=f"D) {q['d']}", callback_data=f"sans_{session_id}_{q['id']}_D") |
| | kb.adjust(1) |
| |
|
| | text = f"โ <b>Question {idx+1}</b>\n\n{q['question']}" |
| | await bot.send_message(chat_id, text, reply_markup=kb.as_markup()) |
| |
|
| |
|
| | @dp.callback_query(lambda c: c.data and c.data.startswith("sans_")) |
| | async def handle_single_answer(call: types.CallbackQuery): |
| | |
| | try: |
| | _, sid, qid, opt = call.data.split("_", 3) |
| | session_id = int(sid) |
| | question_id = int(qid) |
| | chosen = opt |
| | except Exception: |
| | await call.answer("Error.", show_alert=True) |
| | return |
| |
|
| | async with await get_db() as db: |
| | |
| | cur = await db.execute( |
| | """ |
| | SELECT quiz_file_id,current_question_index,correct_count,total_time_seconds |
| | FROM single_sessions WHERE id=? AND active=1 |
| | """, |
| | (session_id,), |
| | ) |
| | srow = await cur.fetchone() |
| | await cur.close() |
| | if not srow: |
| | await call.answer("Session ended.", show_alert=True) |
| | return |
| | quiz_file_id, idx, correct_count, total_time = srow |
| |
|
| | cur = await db.execute( |
| | """ |
| | SELECT id,question,option_a,option_b,option_c,option_d,correct_option,explanation,hint |
| | FROM questions WHERE id=? AND quiz_file_id=? |
| | """, |
| | (question_id, quiz_file_id), |
| | ) |
| | qrow = await cur.fetchone() |
| | await cur.close() |
| | if not qrow: |
| | await call.answer("Question not found.", show_alert=True) |
| | return |
| |
|
| | correct = qrow[6] |
| | exp = qrow[7] |
| | hint = qrow[8] |
| |
|
| | is_correct = 1 if chosen == correct else 0 |
| | |
| | time_spent = 5 |
| |
|
| | await db.execute( |
| | """ |
| | INSERT INTO single_answers(session_id,question_id,chosen_option,is_correct,time_spent_seconds) |
| | VALUES(?,?,?,?,?) |
| | """, |
| | (session_id, question_id, chosen, is_correct, time_spent), |
| | ) |
| |
|
| | if is_correct: |
| | correct_count += 1 |
| | total_time += time_spent |
| |
|
| | |
| | idx += 1 |
| | await db.execute( |
| | """ |
| | UPDATE single_sessions |
| | SET current_question_index=?,correct_count=?,total_time_seconds=? |
| | WHERE id=? |
| | """, |
| | (idx, correct_count, total_time, session_id), |
| | ) |
| | await db.commit() |
| |
|
| | answer_text = "โ
Correct!" if is_correct else f"โ Wrong. Correct answer: {correct}" |
| | answer_text += f"\n\n๐ Explanation:\n{exp}" |
| | if hint: |
| | answer_text += f"\n\n๐ก Hint was:\n{hint}" |
| | await call.message.answer(answer_text) |
| |
|
| | await call.answer() |
| | await send_next_single_question(call.message.chat.id, call.from_user.id, session_id) |
| |
|
| |
|
| | async def finish_single_session( |
| | chat_id: int, |
| | user_id: int, |
| | session_id: int, |
| | db: aiosqlite.Connection, |
| | questions: List[Dict[str, Any]], |
| | ): |
| | cur = await db.execute( |
| | """ |
| | SELECT correct_count,total_time_seconds |
| | FROM single_sessions WHERE id=? |
| | """, |
| | (session_id,), |
| | ) |
| | srow = await cur.fetchone() |
| | await cur.close() |
| | if not srow: |
| | return |
| | correct_count, total_time = srow |
| | await db.execute( |
| | """ |
| | UPDATE single_sessions |
| | SET active=0,ended_at=CURRENT_TIMESTAMP |
| | WHERE id=? |
| | """, |
| | (session_id,), |
| | ) |
| | await db.commit() |
| |
|
| | total_q = len(questions) |
| | avg_time = total_time / max(total_q, 1) |
| | text = ( |
| | f"๐ Single-player quiz finished!\n\n" |
| | f"Score: <b>{correct_count}</b> / {total_q}\n" |
| | f"โฑ Avg time per question: {avg_time:.1f} sec\n\n" |
| | "Thanks for playing!" |
| | ) |
| | await bot.send_message(chat_id, text) |
| |
|
| |
|
| | |
| |
|
| | @dp.message(Command("add_ai")) |
| | async def cmd_add_ai(message: types.Message): |
| | if not is_admin(message.from_user): |
| | return |
| | await message.answer( |
| | "๐ค Let's add a new AI profile.\n" |
| | "Send me data in this format (all in one message):\n\n" |
| | "<code>Name\n" |
| | "API_BASE_URL\n" |
| | "API_KEY\n" |
| | "MODEL\n" |
| | "SYSTEM_PROMPT\n" |
| | "DESCRIPTION(optional)</code>\n\n" |
| | "Example:\n" |
| | "<code>Friendly Tutor\n" |
| | "https://api.openai.com/v1\n" |
| | "sk-XXX\n" |
| | "gpt-4o-mini\n" |
| | "You are a friendly tutor for high school.\n" |
| | "Helps with math and science.</code>" |
| | ) |
| |
|
| |
|
| | @dp.message(Command("list_ai")) |
| | async def cmd_list_ai(message: types.Message): |
| | if not is_admin(message.from_user): |
| | return |
| | async with await get_db() as db: |
| | profiles = await list_ai_profiles(db) |
| | if not profiles: |
| | await message.answer("No AI profiles yet.") |
| | return |
| | lines = [] |
| | for p in profiles: |
| | status = "โ
" if p["active"] else "โ" |
| | lines.append(f"{status} <b>{p['id']}</b> โ {p['name']} โ {p['description']}") |
| | await message.answer("\n".join(lines)) |
| |
|
| |
|
| | @dp.message(Command("edit_ai")) |
| | async def cmd_edit_ai(message: types.Message): |
| | if not is_admin(message.from_user): |
| | return |
| | parts = message.text.split(maxsplit=2) |
| | if len(parts) < 3: |
| | await message.answer( |
| | "Usage:\n<code>/edit_ai <id> <on|off|rename|model> new_value</code>" |
| | ) |
| | return |
| | ai_id = int(parts[1]) |
| | field_action = parts[2].split()[0] |
| | new_value = " ".join(parts[2].split()[1:]) |
| |
|
| | async with await get_db() as db: |
| | if field_action == "on": |
| | await db.execute( |
| | "UPDATE ai_profiles SET active=1 WHERE id=?", (ai_id,) |
| | ) |
| | elif field_action == "off": |
| | await db.execute( |
| | "UPDATE ai_profiles SET active=0 WHERE id=?", (ai_id,) |
| | ) |
| | elif field_action == "rename": |
| | await db.execute( |
| | "UPDATE ai_profiles SET name=? WHERE id=?", (new_value, ai_id) |
| | ) |
| | elif field_action == "model": |
| | await db.execute( |
| | "UPDATE ai_profiles SET model=? WHERE id=?", (new_value, ai_id) |
| | ) |
| | else: |
| | await message.answer("Unknown action. Use on/off/rename/model.") |
| | return |
| | await db.commit() |
| | await message.answer("โ
AI profile updated.") |
| |
|
| |
|
| | @dp.message(Command("del_ai")) |
| | async def cmd_del_ai(message: types.Message): |
| | if not is_admin(message.from_user): |
| | return |
| | parts = message.text.split() |
| | if len(parts) < 2: |
| | await message.answer("Usage: <code>/del_ai <id></code>") |
| | return |
| | ai_id = int(parts[1]) |
| | async with await get_db() as db: |
| | await db.execute("DELETE FROM ai_conversations WHERE profile_id=?", (ai_id,)) |
| | await db.execute("DELETE FROM ai_profiles WHERE id=?", (ai_id,)) |
| | await db.commit() |
| | await message.answer("๐ AI profile deleted.") |
| |
|
| |
|
| | @dp.message(lambda m: m.text == "๐ค Ask AI") |
| | async def ai_chat_entry(message: types.Message): |
| | async with await get_db() as db: |
| | if not await check_promo_membership(bot, db, message.from_user): |
| | return |
| | profiles = await list_ai_profiles(db) |
| | profiles = [p for p in profiles if p["active"]] |
| | if not profiles: |
| | await message.answer("No AI profiles configured yet. Ask admin.") |
| | return |
| | kb = InlineKeyboardBuilder() |
| | for p in profiles: |
| | label = f"{p['name']}" |
| | kb.button(text=label[:30], callback_data=f"aiprof_{p['id']}") |
| | kb.adjust(1) |
| | await message.answer("Choose an AI:", reply_markup=kb.as_markup()) |
| |
|
| |
|
| | @dp.callback_query(lambda c: c.data and c.data.startswith("aiprof_")) |
| | async def select_ai_profile(call: types.CallbackQuery): |
| | profile_id = int(call.data.split("_", 1)[1]) |
| | |
| | |
| | await call.message.answer( |
| | f"Selected AI profile ID <b>{profile_id}</b>.\n" |
| | f"Now send:\n<code>/ai {profile_id} your question...</code>" |
| | ) |
| | await call.answer() |
| |
|
| |
|
| | @dp.message(Command("ai")) |
| | async def cmd_ai(message: types.Message): |
| | parts = message.text.split(maxsplit=2) |
| | if len(parts) < 3: |
| | await message.answer("Usage: <code>/ai <profile_id> your question...</code>") |
| | return |
| | profile_id = int(parts[1]) |
| | user_msg = parts[2] |
| |
|
| | async with await get_db() as db: |
| | if not await check_promo_membership(bot, db, message.from_user): |
| | return |
| | await ensure_user(db, message.from_user) |
| | profile = await get_ai_profile(db, profile_id) |
| | if not profile or not profile["active"]: |
| | await message.answer("AI profile not found or inactive.") |
| | return |
| | history = await get_conversation_history(db, message.from_user.id, profile_id) |
| |
|
| | await message.answer("โณ Asking AI...") |
| |
|
| | try: |
| | ai_reply = await call_ai( |
| | profile, |
| | history + [{"role": "user", "content": user_msg}], |
| | ) |
| | except Exception as e: |
| | logger.exception("AI call failed") |
| | await message.answer("โ AI error.") |
| | return |
| |
|
| | async with await get_db() as db: |
| | await append_message( |
| | db, message.from_user.id, profile_id, "user", user_msg |
| | ) |
| | await append_message( |
| | db, message.from_user.id, profile_id, "assistant", ai_reply |
| | ) |
| |
|
| | await message.answer(ai_reply) |
| |
|
| |
|
| | |
| |
|
| | @dp.callback_query(lambda c: c.data == "promo_recheck") |
| | async def promo_recheck(call: types.CallbackQuery): |
| | async with await get_db() as db: |
| | ok = await check_promo_membership(bot, db, call.from_user) |
| | if ok: |
| | await call.message.answer("โ
Thanks for joining! You can now use the bot.", reply_markup=main_menu_kb()) |
| | await call.answer() |
| |
|
| |
|
| | |
| |
|
| | @dp.message() |
| | async def text_router(message: types.Message): |
| | |
| | if message.text in { |
| | "๐ฎ Single Quiz", |
| | "๐ฅ Multiplayer", |
| | "๐ Tournament", |
| | "๐ Reference Books", |
| | "๐ค Ask AI", |
| | }: |
| | |
| | return |
| | |
| | await message.answer("Use the menu below:", reply_markup=main_menu_kb()) |
| |
|
| |
|
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | @dp.message(Command("schedule_tour")) |
| | async def cmd_schedule_tour(message: types.Message): |
| | if not is_admin(message.from_user): |
| | return |
| | parts = message.text.split(maxsplit=5) |
| | if len(parts) < 6: |
| | await message.answer( |
| | "Usage:\n" |
| | "<code>/schedule_tour <code> <group_chat_id> <quiz_id> <timer_id|0> <YYYY-MM-DD_HH:MM></code>\n" |
| | "Time is in UTC.\n" |
| | "Example:\n" |
| | "<code>/schedule_tour battle1 -1001234567890 1 2 2025-01-01_18:00</code>" |
| | ) |
| | return |
| | code = parts[1] |
| | group_chat_id = int(parts[2]) |
| | quiz_id = int(parts[3]) |
| | timer_id = int(parts[4]) if parts[4] != "0" else None |
| | dt_str = parts[5] |
| | try: |
| | scheduled = datetime.strptime(dt_str, "%Y-%m-%d_%H:%M").replace(tzinfo=timezone.utc) |
| | except ValueError: |
| | await message.answer("โ Invalid datetime. Use YYYY-MM-DD_HH:MM (UTC).") |
| | return |
| |
|
| | async with await get_db() as db: |
| | cur = await db.execute( |
| | """ |
| | INSERT INTO tournaments(code,admin_user_id,group_chat_id,quiz_file_id,timer_video_id,scheduled_start) |
| | VALUES(?,?,?,?,?,?) |
| | """, |
| | (code, message.from_user.id, group_chat_id, quiz_id, timer_id, scheduled.isoformat()), |
| | ) |
| | tour_id = cur.lastrowid |
| | await db.commit() |
| |
|
| | |
| | scheduler.add_job( |
| | func=announce_tournament_start, |
| | trigger=DateTrigger(run_date=scheduled), |
| | args=(tour_id,), |
| | id=f"tour_{tour_id}", |
| | replace_existing=True, |
| | ) |
| |
|
| | await message.answer( |
| | f"โ
Tournament scheduled.\n" |
| | f"Code: <code>{code}</code>\n" |
| | f"Group: <code>{group_chat_id}</code>\n" |
| | f"Quiz ID: {quiz_id}\n" |
| | f"Timer ID: {timer_id or 'none'}\n" |
| | f"Start (UTC): {scheduled:%Y-%m-%d %H:%M}" |
| | ) |
| |
|
| |
|
| | async def announce_tournament_start(tour_id: int): |
| | async with await get_db() as db: |
| | cur = await db.execute( |
| | """ |
| | SELECT code,group_chat_id,quiz_file_id,timer_video_id,scheduled_start |
| | FROM tournaments WHERE id=? |
| | """, |
| | (tour_id,), |
| | ) |
| | row = await cur.fetchone() |
| | await cur.close() |
| | if not row: |
| | return |
| | code, group_chat_id, quiz_id, timer_id, scheduled_str = row |
| | |
| | await db.execute( |
| | "UPDATE tournaments SET status='running',started_at=CURRENT_TIMESTAMP WHERE id=?", |
| | (tour_id,), |
| | ) |
| | await db.commit() |
| |
|
| | scheduled = datetime.fromisoformat(scheduled_str) |
| | start_text = scheduled.strftime("%Y-%m-%d %H:%M UTC") |
| | deep_link = f"https://t.me/{(await bot.me()).username}?start=tour_{code}" |
| |
|
| | text = ( |
| | f"๐ <b>Tournament Starting Now!</b>\n\n" |
| | f"Code: <code>{code}</code>\n" |
| | f"Start time: <b>{start_text}</b>\n\n" |
| | f"โก๏ธ Join via this link: {deep_link}\n\n" |
| | "Get ready!" |
| | ) |
| |
|
| | try: |
| | await bot.send_message(group_chat_id, text) |
| | except Exception as e: |
| | logger.error("Failed to announce tournament: %s", e) |
| |
|
| |
|
| | |
| |
|
| | async def main(): |
| | await init_db() |
| | scheduler.start() |
| | logger.info("Bot started.") |
| | await dp.start_polling(bot) |
| |
|
| |
|
| | if __name__ == "__main__": |
| | asyncio.run(main()) |
| |
|