import html import json import logging import os import random import re from dataclasses import dataclass, field from typing import Any import psycopg from psycopg.rows import dict_row from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.constants import ChatAction, ParseMode from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes, MessageHandler, filters logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) logger = logging.getLogger("quiz-telegram-bot") logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING) TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"] DATABASE_URL = os.environ["DATABASE_URL"] DEFAULT_QUIZ_SIZE = int(os.getenv("DEFAULT_QUIZ_SIZE", "20")) MAX_QUIZ_SIZE = int(os.getenv("MAX_QUIZ_SIZE", "50")) PUBLIC_APP_URL = os.getenv("PUBLIC_APP_URL", "https://pedshub.com").rstrip("/") TELEGRAM_MESSAGE_LIMIT = 4096 QUESTION_TEXT_LIMIT = 3200 OPTION_TEXT_LIMIT = 3200 TELEGRAM_MESSAGE_MARGIN = 200 # Telegram is where you answer a question on the bus, not where you read the # teaching. The explanation is abridged to roughly a screenful and the site # link carries the rest; per-option explanations are not sent at all. EXPLANATION_LIMIT = int(os.getenv("EXPLANATION_LIMIT", "700")) LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" NUMBER_RE = re.compile(r"^\s*(\d{1,3})\s*$") @dataclass class QuizState: questions: list[dict[str, Any]] mode: str = "study" index: int = 0 score: int = 0 answers: list[tuple[int, str, str, bool]] = field(default_factory=list) active_quizzes: dict[int, QuizState] = {} # What each chat is revising for, by exam id. In memory and per chat, like the # quiz itself: the bot has no accounts and writes nothing down. chat_exam: dict[int, int] = {} def db_query(sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]: with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn: with conn.cursor() as cur: cur.execute(sql, params) return list(cur.fetchall()) def normalize_options(value: Any) -> list[str]: if value is None: return [] if isinstance(value, list): return [str(item) for item in value] if isinstance(value, str): try: parsed = json.loads(value) except json.JSONDecodeError: return [] if isinstance(parsed, list): return [str(item) for item in parsed] return [] def answer_index(question: dict[str, Any], options: list[str]) -> int | None: raw = str(question.get("correct_answer") or "").strip() if not raw: return None upper = raw.upper() if len(upper) == 1 and upper in LETTERS: idx = LETTERS.index(upper) return idx if idx < len(options) else None if raw.isdigit(): idx = int(raw) - 1 return idx if 0 <= idx < len(options) else None for idx, option in enumerate(options): if option.strip().lower() == raw.lower(): return idx return None #: Questions the bot may serve at all. `is_shared` is the only gate between an #: anonymous stranger on Telegram and the bank, so it is applied in one place. SERVEABLE = """ q.question_type = 'mcq' and q.options is not null and q.is_shared = 1 """ #: Every category at or below one node. The taxonomy is a tree now — discipline #: above subdiscipline above condition — so "Cardiology" has to mean everything #: under Cardiology, not only what was filed directly on it. SUBTREE = """ with recursive subtree(id) as ( select id from question_categories where id = %s union all select c.id from question_categories c join subtree s on c.parent_id = s.id ) """ #: A question belongs to a category by its primary column or by a link row. IN_SUBTREE = """ (q.question_category_id in (select id from subtree) or exists (select 1 from question_category_links l where l.question_id = q.id and l.category_id in (select id from subtree))) """ def exam_clause(exam_id: int | None) -> tuple[str, tuple[Any, ...]]: """Narrow to one study objective, or to nothing if none is chosen.""" if not exam_id: return "", () return ("and exists (select 1 from question_exam_links qel " "where qel.question_id = q.id and qel.exam_id = %s)", (exam_id,)) def question_base_sql(where: str = "", prefix: str = "") -> str: return f""" {prefix} select q.id, q.question_text, q.options, q.correct_answer, q.explanation, qc.name as category from questions q left join question_categories qc on qc.id = q.question_category_id where {SERVEABLE} {where} order by random() limit %s """ def valid_questions(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: clean = [] for row in rows: options = normalize_options(row.get("options")) if len(options) < 2: continue idx = answer_index(row, options) if idx is None: continue row["options"] = options row["correct_index"] = idx clean.append(row) return clean def random_questions(limit: int, exam_id: int | None = None) -> list[dict[str, Any]]: clause, params = exam_clause(exam_id) rows = db_query(question_base_sql(clause), (*params, limit * 2)) return valid_questions(rows)[:limit] def search_questions(term: str, limit: int, exam_id: int | None = None) -> list[dict[str, Any]]: pattern = f"%{term}%" clause, params = exam_clause(exam_id) rows = db_query( question_base_sql(f""" {clause} and ( q.question_text ilike %s or q.explanation ilike %s or exists ( select 1 from question_categories c where c.id = q.question_category_id and c.name ilike %s ) ) """), (*params, pattern, pattern, pattern, limit * 2), ) return valid_questions(rows)[:limit] def category_questions(category_id: int, limit: int, exam_id: int | None = None) -> list[dict[str, Any]]: clause, params = exam_clause(exam_id) rows = db_query( question_base_sql(f"and {IN_SUBTREE} {clause}", prefix=SUBTREE), (category_id, *params, limit * 2), ) return valid_questions(rows)[:limit] def list_exams() -> list[dict[str, Any]]: """The objectives, with how many serveable questions each covers.""" return db_query( f""" select e.id, e.name, e.family, count(distinct q.id)::int as count from exams e join question_exam_links qel on qel.exam_id = e.id join questions q on q.id = qel.question_id and {SERVEABLE} where e.is_active = 1 group by e.id, e.name, e.family, e.sort_order having count(distinct q.id) > 0 order by e.sort_order, e.name """ ) def category_children(parent_id: int | None, exam_id: int | None = None) -> list[dict[str, Any]]: """The children of one node, each counted over its own whole subtree. Counting only what is filed directly on a node would show "Cardiology (0)" for a discipline holding six hundred questions three levels down. """ clause, params = exam_clause(exam_id) parent_test = "c.parent_id is null" if parent_id is None else "c.parent_id = %s" parent_params: tuple[Any, ...] = () if parent_id is None else (parent_id,) return db_query( f""" with recursive tree(root, id) as ( select c.id, c.id from question_categories c where {parent_test} union all select t.root, c.id from question_categories c join tree t on c.parent_id = t.id ), hits(root, question_id) as ( select t.root, q.id from tree t join questions q on q.question_category_id = t.id where {SERVEABLE} {clause} union select t.root, q.id from tree t join question_category_links l on l.category_id = t.id join questions q on q.id = l.question_id where {SERVEABLE} {clause} ) select r.id, r.name, count(distinct h.question_id)::int as count, exists (select 1 from question_categories k where k.parent_id = r.id) as has_children from question_categories r left join hits h on h.root = r.id where r.id in (select distinct root from tree) group by r.id, r.name having count(distinct h.question_id) > 0 order by r.name """, (*parent_params, *params, *params), ) def category_name(category_id: int) -> str: rows = db_query("select name from question_categories where id = %s", (category_id,)) return rows[0]["name"] if rows else "Category" def clamp_count(value: int | None) -> int: if value is None: return DEFAULT_QUIZ_SIZE return max(1, min(MAX_QUIZ_SIZE, value)) def parse_count(args: list[str]) -> int: for arg in args: if arg.isdigit(): return clamp_count(int(arg)) return DEFAULT_QUIZ_SIZE def parse_mode(args: list[str]) -> str: lowered = {arg.lower() for arg in args} return "exam" if "exam" in lowered else "study" async def send_text(update: Update, text: str, reply_markup: InlineKeyboardMarkup | None = None) -> None: if update.message: await update.message.reply_text( text, parse_mode=ParseMode.HTML, disable_web_page_preview=True, reply_markup=reply_markup, ) async def respond(update: Update, context: ContextTypes.DEFAULT_TYPE | None, text: str, reply_markup: InlineKeyboardMarkup | None = None) -> None: if update.message: await send_text(update, text, reply_markup) elif update.callback_query and update.callback_query.message: await update.callback_query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=reply_markup, disable_web_page_preview=True) elif context and update.effective_chat: await context.bot.send_message(update.effective_chat.id, text, parse_mode=ParseMode.HTML, reply_markup=reply_markup, disable_web_page_preview=True) def main_menu(chat_id: int | None = None) -> InlineKeyboardMarkup: exam_id = chat_exam.get(chat_id) if chat_id else None objective = "Change objective" if exam_id else "Choose an objective" return InlineKeyboardMarkup([ [InlineKeyboardButton("Random 20 study", callback_data="quiz:random:20:study")], [InlineKeyboardButton("Random 20 exam", callback_data="quiz:random:20:exam")], [InlineKeyboardButton("Browse topics", callback_data="browse:0")], [InlineKeyboardButton(objective, callback_data="exams")], ]) def count_menu(kind: str, item_id: int, back_to: int | None = None) -> InlineKeyboardMarkup: buttons = [] for count in (5, 10, 20, 30, 50): buttons.append([ InlineKeyboardButton(f"{count} study", callback_data=f"start:{kind}:{item_id}:{count}:study"), InlineKeyboardButton(f"{count} exam", callback_data=f"start:{kind}:{item_id}:{count}:exam"), ]) if back_to is not None: buttons.append([InlineKeyboardButton("⬅ Back", callback_data=f"browse:{back_to}")]) return InlineKeyboardMarkup(buttons) def truncate_text(value: str, limit: int) -> str: value = (value or "").strip() if len(value) <= limit: return value return value[:limit].rsplit(" ", 1)[0].rstrip() + "..." def strip_markup(value: str) -> str: """Explanations are markdown on the site; Telegram gets the words.""" value = re.sub(r"!?\[\[?\d*\|?([^\]]*)\]\]?(\([^)]*\))?", r"\1", value) # links, cross-refs value = re.sub(r"[*_`>#]+", "", value) value = re.sub(r"\n{3,}", "\n\n", value) return value.strip() def abridge(explanation: str | None) -> str: """About a screenful, cut at a sentence rather than mid-word.""" text = strip_markup(explanation or "") if not text: return "No explanation available." if len(text) <= EXPLANATION_LIMIT: return text window = text[:EXPLANATION_LIMIT] stop = max(window.rfind(". "), window.rfind("! "), window.rfind("? ")) if stop > EXPLANATION_LIMIT // 2: return window[:stop + 1] return window.rsplit(" ", 1)[0].rstrip() + "…" def was_abridged(explanation: str | None) -> bool: return len(strip_markup(explanation or "")) > EXPLANATION_LIMIT def question_message_text( state: QuizState, question: dict[str, Any], chosen_idx: int | None = None, correct_idx: int | None = None, ) -> str: def build(question_limit: int, option_limit: int) -> str: lines = [ f"Question {state.index + 1}/{len(state.questions)}", html.escape(truncate_text(question["question_text"], question_limit)), "", ] for idx, option in enumerate(question["options"][:8]): marker = "" if correct_idx is not None and idx == correct_idx: marker = " ✓ correct" elif chosen_idx is not None and idx == chosen_idx: marker = " ✗ your answer" lines.append(f"{LETTERS[idx]}. {html.escape(truncate_text(option, option_limit))}{marker}") lines.extend([ "", f"Category: {html.escape(str(question.get('category') or 'Uncategorized'))}", ]) return "\n".join(lines) for question_limit, option_limit in ( (QUESTION_TEXT_LIMIT, OPTION_TEXT_LIMIT), (QUESTION_TEXT_LIMIT, 700), (1600, 420), ): text = build(question_limit, option_limit) if len(text) <= TELEGRAM_MESSAGE_LIMIT - TELEGRAM_MESSAGE_MARGIN: return text return build(1200, 280) def answer_feedback_header(chosen_idx: int, correct_idx: int, ok: bool) -> str: chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?" correct = LETTERS[correct_idx] if correct_idx < len(LETTERS) else "?" return "Correct." if ok else f"Incorrect. You chose {chosen}; correct answer: {correct}." def split_plain_text(text: str, limit: int) -> list[str]: if len(text) <= limit: return [text] chunks = [] remaining = text while remaining: if len(remaining) <= limit: chunks.append(remaining) break split_at = remaining.rfind("\n\n", 0, limit) if split_at < limit // 2: split_at = remaining.rfind("\n", 0, limit) if split_at < limit // 2: split_at = remaining.rfind(" ", 0, limit) if split_at < limit // 2: split_at = limit chunks.append(remaining[:split_at].rstrip()) remaining = remaining[split_at:].lstrip() return chunks async def send_answer_feedback( context: ContextTypes.DEFAULT_TYPE, chat_id: int, question: dict[str, Any], chosen_idx: int, correct_idx: int, ok: bool, ) -> None: header = answer_feedback_header(chosen_idx, correct_idx, ok) body = abridge(question.get("explanation")) lines = [header, "", f"Why: {html.escape(body)}"] if was_abridged(question.get("explanation")): # Per-option reasoning and the figures stay on the site. Chat is where # you answer; it is not where you read the teaching. lines.append(f'\nFull explanation on PedsHub') await context.bot.send_message(chat_id, "\n".join(lines), parse_mode=ParseMode.HTML, disable_web_page_preview=True) def answered_question_text(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int) -> str: return question_message_text(state, question, chosen_idx, correct_idx) async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat_id = update.effective_chat.id if update.effective_chat else 0 exam_id = chat_exam.get(chat_id) current = next((e["name"] for e in list_exams() if e["id"] == exam_id), None) if exam_id else None await send_text(update, "\n".join([ "PedsHub bot", "Send 20 for a random 20-question study quiz.", "/random 20 exam — random questions in exam mode.", "/topics — browse the subject tree and quiz any part of it.", "/objective — pick the exam you are revising for.", "/search sepsis 20 — quiz by text search.", "/stop — end the current quiz.", "", f"Objective: {html.escape(current)}" if current else "Objective: everything.", "", "Study mode shows each answer immediately; exam mode shows them at the end.", "Explanations here are abridged — the full teaching, the figures and the", f"per-option reasoning are on the site: {PUBLIC_APP_URL}", "", "Nothing you do here is recorded. The bot keeps no account and writes", "nothing to your history — a quiz lives only until you finish or /stop.", ]), main_menu(chat_id)) async def random_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat_id = update.effective_chat.id if update.effective_chat else 0 await start_quiz(update, random_questions(parse_count(context.args), chat_exam.get(chat_id)), parse_mode(context.args)) async def search_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not context.args: await send_text(update, "Usage: /search [number] [study|exam]") return chat_id = update.effective_chat.id if update.effective_chat else 0 count = parse_count(context.args) mode = parse_mode(context.args) term = " ".join(arg for arg in context.args if not arg.isdigit() and arg.lower() not in {"study", "exam"}).strip() if not term: await send_text(update, "Usage: /search [number] [study|exam]") return if update.message: await update.message.chat.send_action(ChatAction.TYPING) await start_quiz(update, search_questions(term, count, chat_exam.get(chat_id)), mode, label=f"Search: {term}") async def topics_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await show_browse(update, 0) async def objective_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await show_exams(update) async def stop_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.effective_chat: active_quizzes.pop(update.effective_chat.id, None) await send_text(update, "Quiz stopped. Nothing was recorded.", main_menu(update.effective_chat.id if update.effective_chat else 0)) PER_PAGE = 10 async def show_browse(update: Update, parent_id: int, page: int = 0) -> None: """One level of the taxonomy. 0 means the top.""" chat_id = update.effective_chat.id if update.effective_chat else 0 exam_id = chat_exam.get(chat_id) node = None if parent_id == 0 else parent_id rows = category_children(node, exam_id) if not rows: await respond(update, None, "Nothing here for the objective you have chosen.", main_menu(chat_id)) return chunk = rows[page * PER_PAGE:(page + 1) * PER_PAGE] buttons = [] # A node that goes deeper opens; a leaf goes straight to the size menu. for row in chunk: action = "browse" if row["has_children"] else "pick:category" target = f"{action}:{row['id']}" if row["has_children"] else f"pick:category:{row['id']}" arrow = " ›" if row["has_children"] else "" buttons.append([InlineKeyboardButton(f"{row['name']} ({row['count']}){arrow}", callback_data=target)]) nav = [] if page > 0: nav.append(InlineKeyboardButton("Prev", callback_data=f"browse:{parent_id}:{page - 1}")) if (page + 1) * PER_PAGE < len(rows): nav.append(InlineKeyboardButton("Next", callback_data=f"browse:{parent_id}:{page + 1}")) if nav: buttons.append(nav) if node is None: title = "Choose a topic." else: # Everything under this node, not only what is filed directly on it. title = f"{html.escape(category_name(node))} — choose a sub-topic, or quiz the whole of it." buttons.append([InlineKeyboardButton("▶ All of this topic", callback_data=f"pick:category:{node}")]) buttons.append([InlineKeyboardButton("⬅ Top", callback_data="browse:0")]) markup = InlineKeyboardMarkup(buttons) if update.callback_query: await update.callback_query.edit_message_text(title, parse_mode=ParseMode.HTML, reply_markup=markup) else: await send_text(update, title, markup) async def show_exams(update: Update) -> None: """Which exam the questions should come from. Optional — none means all.""" chat_id = update.effective_chat.id if update.effective_chat else 0 rows = list_exams() if not rows: await respond(update, None, "No study objectives are set up yet.", main_menu(chat_id)) return current = chat_exam.get(chat_id) buttons = [] for row in rows: mark = "✓ " if row["id"] == current else "" family = f"{row['family']} — " if row.get("family") else "" buttons.append([InlineKeyboardButton(f"{mark}{family}{row['name']} ({row['count']})", callback_data=f"exam:{row['id']}")]) buttons.append([InlineKeyboardButton(("✓ " if not current else "") + "Everything", callback_data="exam:0")]) text = "Which exam are you revising for? It scopes every quiz the bot gives you." markup = InlineKeyboardMarkup(buttons) if update.callback_query: await update.callback_query.edit_message_text(text, reply_markup=markup) else: await send_text(update, text, markup) async def start_quiz( update: Update, questions: list[dict[str, Any]], mode: str, label: str = "Random", context: ContextTypes.DEFAULT_TYPE | None = None, ) -> None: if not update.effective_chat: return if not questions: await respond(update, context, "No questions there for the objective you have chosen. Try /topics, " "a broader /search, or /objective to widen it.", main_menu(update.effective_chat.id if update.effective_chat else 0)) return random.shuffle(questions) active_quizzes[update.effective_chat.id] = QuizState(questions=questions, mode=mode) start_text = f"{html.escape(label)} quiz started: {len(questions)} questions, {mode} mode." if update.message: await send_text(update, start_text) await send_current_question(update.effective_chat.id, update, None) elif context: await context.bot.send_message(update.effective_chat.id, start_text, parse_mode=ParseMode.HTML) await send_current_question(update.effective_chat.id, None, context) async def send_current_question(chat_id: int, update: Update | None, context: ContextTypes.DEFAULT_TYPE | None) -> None: state = active_quizzes.get(chat_id) if not state: return question = state.questions[state.index] options = question["options"] buttons = [ [InlineKeyboardButton(LETTERS[idx], callback_data=f"answer:{idx}")] for idx, option in enumerate(options[:8]) ] text = question_message_text(state, question) markup = InlineKeyboardMarkup(buttons) if update and update.message: await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=markup) elif context: await context.bot.send_message(chat_id, text, parse_mode=ParseMode.HTML, reply_markup=markup) async def finish_quiz(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None: state = active_quizzes.pop(chat_id, None) if not state: return total = len(state.questions) lines = [f"Quiz complete. Score: {state.score}/{total}"] if state.mode == "exam": lines.append("") lines.append("Answers:") for question_id, chosen, correct, ok in state.answers: marker = "OK" if ok else "MISS" lines.append(f"Q{question_id}: {marker}. You: {chosen}. Correct: {correct}") lines.append("") lines.append(f"Full question bank: {PUBLIC_APP_URL}") await context.bot.send_message(chat_id, "\n".join(lines), reply_markup=main_menu(chat_id), disable_web_page_preview=True) async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query if not query: return await query.answer() data = query.data or "" chat_id = query.message.chat_id if query.message else update.effective_chat.id if data.startswith("browse:"): parts = data.split(":") parent_id = int(parts[1]) page = int(parts[2]) if len(parts) > 2 else 0 await show_browse(update, parent_id, page) return if data == "exams": await show_exams(update) return if data.startswith("exam:"): exam_id = int(data.rsplit(":", 1)[1]) if exam_id: chat_exam[chat_id] = exam_id name = next((e["name"] for e in list_exams() if e["id"] == exam_id), "that objective") await query.edit_message_text(f"Revising for {html.escape(name)}.", parse_mode=ParseMode.HTML, reply_markup=main_menu(chat_id)) else: chat_exam.pop(chat_id, None) await query.edit_message_text("Questions will come from everything.", reply_markup=main_menu(chat_id)) return if data.startswith("quiz:random:"): _, _, count, mode = data.split(":", 3) await start_quiz(update, random_questions(clamp_count(int(count)), chat_exam.get(chat_id)), mode, context=context) return if data.startswith("pick:category:"): category_id = int(data.rsplit(":", 1)[1]) await query.edit_message_text( f"{html.escape(category_name(category_id))} — how many questions?", parse_mode=ParseMode.HTML, reply_markup=count_menu("category", category_id, back_to=category_id)) return if data.startswith("start:category:"): _, _, category_id, count, mode = data.split(":", 4) await start_quiz(update, category_questions(int(category_id), clamp_count(int(count)), chat_exam.get(chat_id)), mode, category_name(int(category_id)), context) return if data.startswith("answer:"): state = active_quizzes.get(chat_id) if not state: await query.edit_message_text("This quiz expired. Start a new one with /random or /topics.") return chosen_idx = int(data.split(":", 1)[1]) question = state.questions[state.index] correct_idx = question["correct_index"] ok = chosen_idx == correct_idx if ok: state.score += 1 chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?" correct = LETTERS[correct_idx] if correct_idx < len(LETTERS) else "?" state.answers.append((state.index + 1, chosen, correct, ok)) if state.mode == "study": await query.edit_message_text( answered_question_text(state, question, chosen_idx, correct_idx), parse_mode=ParseMode.HTML, ) await send_answer_feedback(context, chat_id, question, chosen_idx, correct_idx, ok) else: await query.edit_message_reply_markup(reply_markup=None) state.index += 1 if state.index >= len(state.questions): await finish_quiz(chat_id, context) else: await send_current_question(chat_id, None, context) async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: text = update.message.text if update.message else "" match = NUMBER_RE.match(text or "") if match: await start_quiz(update, random_questions(clamp_count(int(match.group(1)))), "study") return await send_text(update, "Send a number like 20, or use /topics, /objective, /search, /random.", main_menu(update.effective_chat.id if update.effective_chat else 0)) async def post_init(app: Application) -> None: await app.bot.set_my_commands([ BotCommand("start", "Show help and quick quiz buttons"), BotCommand("random", "Start a random quiz: /random 20 exam"), BotCommand("topics", "Browse the subject tree"), BotCommand("objective", "Pick the exam you are revising for"), BotCommand("search", "Search text/topic: /search sepsis 20"), BotCommand("stop", "Stop the current quiz"), ]) def main() -> None: app = Application.builder().token(TELEGRAM_BOT_TOKEN).post_init(post_init).build() app.add_handler(CommandHandler(["start", "help"], help_cmd)) app.add_handler(CommandHandler("random", random_cmd)) app.add_handler(CommandHandler("search", search_cmd)) app.add_handler(CommandHandler(["topics", "categories"], topics_cmd)) app.add_handler(CommandHandler(["objective", "exam"], objective_cmd)) app.add_handler(CommandHandler("stop", stop_cmd)) app.add_handler(CallbackQueryHandler(callback_handler)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler)) app.run_polling(allowed_updates=Update.ALL_TYPES) if __name__ == "__main__": main()