mirror of
https://github.com/koloideal/Quizzi.git
synced 2026-06-10 18:35:28 +03:00
commit
This commit is contained in:
@@ -52,16 +52,13 @@ async def main() -> None:
|
|||||||
take_test_dialog,
|
take_test_dialog,
|
||||||
registration_dialog,
|
registration_dialog,
|
||||||
deeplink_dialog,
|
deeplink_dialog,
|
||||||
# Shared dialogs
|
|
||||||
shared_tests_dialog,
|
shared_tests_dialog,
|
||||||
shared_groups_dialog,
|
shared_groups_dialog,
|
||||||
shared_broadcast_dialog,
|
shared_broadcast_dialog,
|
||||||
shared_templates_dialog,
|
shared_templates_dialog,
|
||||||
shared_create_test_dialog,
|
shared_create_test_dialog,
|
||||||
# Admin dialogs
|
|
||||||
admin_menu_dialog,
|
admin_menu_dialog,
|
||||||
admin_users_dialog,
|
admin_users_dialog,
|
||||||
# Creator dialogs
|
|
||||||
creator_menu_dialog,
|
creator_menu_dialog,
|
||||||
creator_users_dialog,
|
creator_users_dialog,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -179,7 +179,6 @@ async def get_user_result_detail(
|
|||||||
"\n<b>📋 Ответы:</b>\n",
|
"\n<b>📋 Ответы:</b>\n",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Загружаем все вопросы за один запрос
|
|
||||||
question_ids = [answer.question_id for answer in answers]
|
question_ids = [answer.question_id for answer in answers]
|
||||||
questions_map = await test_repo.get_questions_with_options_by_ids(question_ids)
|
questions_map = await test_repo.get_questions_with_options_by_ids(question_ids)
|
||||||
|
|
||||||
|
|||||||
@@ -303,7 +303,6 @@ async def get_user_result_detail(
|
|||||||
"\n<b>📋 Ответы:</b>\n",
|
"\n<b>📋 Ответы:</b>\n",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Загружаем все вопросы за один запрос
|
|
||||||
question_ids = [answer.question_id for answer in answers]
|
question_ids = [answer.question_id for answer in answers]
|
||||||
questions_map = await test_repo.get_questions_with_options_by_ids(question_ids)
|
questions_map = await test_repo.get_questions_with_options_by_ids(question_ids)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.types import BufferedInputFile, CallbackQuery, ContentType, Message
|
from aiogram.types import BufferedInputFile, CallbackQuery, ContentType, Message
|
||||||
from aiogram_dialog import Dialog, DialogManager, StartMode, Window
|
from aiogram_dialog import Dialog, DialogManager, StartMode, Window
|
||||||
@@ -57,6 +58,7 @@ SPEC_INFO = """<b>📋 Спецификация формата JSON</b>
|
|||||||
<code>{
|
<code>{
|
||||||
"question_type": "single",
|
"question_type": "single",
|
||||||
"question": "Текст вопроса",
|
"question": "Текст вопроса",
|
||||||
|
"image_url": "https://...",
|
||||||
"answers": [
|
"answers": [
|
||||||
{"option": "Вариант 1", "is_correct": true},
|
{"option": "Вариант 1", "is_correct": true},
|
||||||
{"option": "Вариант 2", "is_correct": false}
|
{"option": "Вариант 2", "is_correct": false}
|
||||||
@@ -67,13 +69,15 @@ SPEC_INFO = """<b>📋 Спецификация формата JSON</b>
|
|||||||
<code>{
|
<code>{
|
||||||
"question_type": "input",
|
"question_type": "input",
|
||||||
"question": "Текст вопроса",
|
"question": "Текст вопроса",
|
||||||
|
"image_url": "https://...",
|
||||||
"correct_answer": "правильный ответ"
|
"correct_answer": "правильный ответ"
|
||||||
}</code>
|
}</code>
|
||||||
|
|
||||||
<b>⚠️ Важно:</b>
|
<b>⚠️ Важно:</b>
|
||||||
• Для <code>single</code> — ровно один <code>is_correct: true</code>
|
• Для <code>single</code> — ровно один <code>is_correct: true</code>
|
||||||
• Для <code>multiple</code> — один или более <code>is_correct: true</code>
|
• Для <code>multiple</code> — один или более <code>is_correct: true</code>
|
||||||
• Минимум 2 варианта ответа для single/multiple"""
|
• Минимум 2 варианта ответа для single/multiple
|
||||||
|
• <code>image_url</code> — опционально, URL изображения к вопросу"""
|
||||||
|
|
||||||
|
|
||||||
TEMPLATE_ULTIMATE = """// ═══════════════════════════════════════════════════════════════
|
TEMPLATE_ULTIMATE = """// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -91,7 +95,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════
|
|||||||
//
|
//
|
||||||
// ❓ ВОПРОСЫ (всего 6):
|
// ❓ ВОПРОСЫ (всего 6):
|
||||||
// 1. [single] - Один правильный ответ (3 варианта)
|
// 1. [single] - Один правильный ответ (3 варианта)
|
||||||
// 2. [single] - Один правильный ответ (4 варианта)
|
// 2. [single] - Один правильный ответ (4 варианта) + изображение
|
||||||
// 3. [multiple] - Несколько правильных (4 варианта, 2 верных)
|
// 3. [multiple] - Несколько правильных (4 варианта, 2 верных)
|
||||||
// 4. [multiple] - Несколько правильных (5 вариантов, 3 верных)
|
// 4. [multiple] - Несколько правильных (5 вариантов, 3 верных)
|
||||||
// 5. [input] - Ввод текста (точный ответ)
|
// 5. [input] - Ввод текста (точный ответ)
|
||||||
@@ -101,6 +105,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════
|
|||||||
// • null означает "не задано" / "без ограничений"
|
// • null означает "не задано" / "без ограничений"
|
||||||
// • expires_at в формате ISO 8601: YYYY-MM-DDTHH:MM:SS
|
// • expires_at в формате ISO 8601: YYYY-MM-DDTHH:MM:SS
|
||||||
// • for_group - номер группы или null для всех пользователей
|
// • for_group - номер группы или null для всех пользователей
|
||||||
|
// • image_url - URL изображения к вопросу (опционально)
|
||||||
//
|
//
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@@ -124,6 +129,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════
|
|||||||
{
|
{
|
||||||
"question_type": "single",
|
"question_type": "single",
|
||||||
"question": "Сколько байт в одном килобайте?",
|
"question": "Сколько байт в одном килобайте?",
|
||||||
|
"image_url": "https://example.com/kilobyte.png",
|
||||||
"answers": [
|
"answers": [
|
||||||
{"option": "100", "is_correct": false},
|
{"option": "100", "is_correct": false},
|
||||||
{"option": "1000", "is_correct": false},
|
{"option": "1000", "is_correct": false},
|
||||||
@@ -232,6 +238,9 @@ async def on_test_selected_for_export(
|
|||||||
"question": question.text,
|
"question": question.text,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if question.tg_file_id:
|
||||||
|
question_data["tg_file_id"] = question.tg_file_id
|
||||||
|
|
||||||
if question.question_type == QuestionType.INPUT:
|
if question.question_type == QuestionType.INPUT:
|
||||||
correct_options = [o for o in options if o.is_correct]
|
correct_options = [o for o in options if o.is_correct]
|
||||||
if correct_options:
|
if correct_options:
|
||||||
@@ -246,7 +255,6 @@ async def on_test_selected_for_export(
|
|||||||
|
|
||||||
json_str = json.dumps(export_data, ensure_ascii=False, indent=2)
|
json_str = json.dumps(export_data, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
# Build comment header
|
|
||||||
created_str = test.created_at.strftime("%d.%m.%Y %H:%M") if test.created_at else "—"
|
created_str = test.created_at.strftime("%d.%m.%Y %H:%M") if test.created_at else "—"
|
||||||
updated_str = test.updated_at.strftime("%d.%m.%Y %H:%M") if test.updated_at else "—"
|
updated_str = test.updated_at.strftime("%d.%m.%Y %H:%M") if test.updated_at else "—"
|
||||||
questions_count = len(questions_with_options)
|
questions_count = len(questions_with_options)
|
||||||
@@ -288,11 +296,44 @@ async def on_template_ultimate(_callback: CallbackQuery, _button: Button, _manag
|
|||||||
await send_template(_callback, TEMPLATE_ULTIMATE, "ultimate", "Ультимативный пример теста")
|
await send_template(_callback, TEMPLATE_ULTIMATE, "ultimate", "Ультимативный пример теста")
|
||||||
|
|
||||||
|
|
||||||
|
async def download_image(url: str) -> bytes | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
|
if not content_type.startswith("image/"):
|
||||||
|
return None
|
||||||
|
if len(response.content) > 10 * 1024 * 1024:
|
||||||
|
return None
|
||||||
|
return response.content
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_image_to_telegram(bot: Bot, image_data: bytes, chat_id: int) -> str | None:
|
||||||
|
try:
|
||||||
|
msg = await bot.send_photo(
|
||||||
|
chat_id=chat_id,
|
||||||
|
photo=BufferedInputFile(image_data, filename="image.jpg"),
|
||||||
|
disable_notification=True,
|
||||||
|
)
|
||||||
|
await msg.delete()
|
||||||
|
if msg.photo:
|
||||||
|
return msg.photo[-1].file_id
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def create_test_from_parsed(
|
async def create_test_from_parsed(
|
||||||
parsed: ParsedTest,
|
parsed: ParsedTest,
|
||||||
test_dao: TestDAO,
|
test_dao: TestDAO,
|
||||||
question_dao: QuestionDAO,
|
question_dao: QuestionDAO,
|
||||||
option_dao: OptionDAO,
|
option_dao: OptionDAO,
|
||||||
|
bot: Bot | None = None,
|
||||||
|
chat_id: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
test = await test_dao.create(
|
test = await test_dao.create(
|
||||||
title=parsed.title,
|
title=parsed.title,
|
||||||
@@ -305,11 +346,19 @@ async def create_test_from_parsed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for position, q in enumerate(parsed.questions):
|
for position, q in enumerate(parsed.questions):
|
||||||
|
tg_file_id: str | None = None
|
||||||
|
|
||||||
|
if q.image_url and bot and chat_id:
|
||||||
|
image_data = await download_image(q.image_url)
|
||||||
|
if image_data:
|
||||||
|
tg_file_id = await upload_image_to_telegram(bot, image_data, chat_id)
|
||||||
|
|
||||||
question = await question_dao.create(
|
question = await question_dao.create(
|
||||||
test_id=test.id,
|
test_id=test.id,
|
||||||
text=q.text,
|
text=q.text,
|
||||||
position=position,
|
position=position,
|
||||||
question_type=q.question_type,
|
question_type=q.question_type,
|
||||||
|
tg_file_id=tg_file_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
for opt in q.options:
|
for opt in q.options:
|
||||||
@@ -375,14 +424,24 @@ async def on_import_file(
|
|||||||
await progress_msg.edit_text("\n".join(error_lines))
|
await progress_msg.edit_text("\n".join(error_lines))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Проверяем существование группы
|
|
||||||
if result.for_group is not None:
|
if result.for_group is not None:
|
||||||
group = await group_dao.get_by_number(result.for_group)
|
group = await group_dao.get_by_number(result.for_group)
|
||||||
if not group:
|
if not group:
|
||||||
await progress_msg.edit_text(f"❌ Группа {result.for_group} не существует")
|
await progress_msg.edit_text(f"❌ Группа {result.for_group} не существует")
|
||||||
return
|
return
|
||||||
|
|
||||||
await create_test_from_parsed(result, test_dao, question_dao, option_dao)
|
has_images = any(q.image_url for q in result.questions)
|
||||||
|
if has_images:
|
||||||
|
await progress_msg.edit_text("⏳ Загружаю изображения...")
|
||||||
|
|
||||||
|
await create_test_from_parsed(
|
||||||
|
result,
|
||||||
|
test_dao,
|
||||||
|
question_dao,
|
||||||
|
option_dao,
|
||||||
|
bot=bot_inst if has_images else None,
|
||||||
|
chat_id=message.chat.id if has_images else None,
|
||||||
|
)
|
||||||
|
|
||||||
await progress_msg.edit_text(
|
await progress_msg.edit_text(
|
||||||
f"✅ <b>Тест импортирован!</b>\n\n"
|
f"✅ <b>Тест импортирован!</b>\n\n"
|
||||||
|
|||||||
@@ -201,7 +201,6 @@ async def get_attempt_detail(
|
|||||||
"<b>📋 Ответы:</b>\n",
|
"<b>📋 Ответы:</b>\n",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Загружаем все вопросы с опциями за один запрос
|
|
||||||
question_ids = [answer.question_id for answer in answers]
|
question_ids = [answer.question_id for answer in answers]
|
||||||
questions_map = await test_repo.get_questions_with_options_by_ids(question_ids)
|
questions_map = await test_repo.get_questions_with_options_by_ids(question_ids)
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ async def on_start_deeplink_test(
|
|||||||
await attempt_repo.attempt_dao.delete(active_attempt.id)
|
await attempt_repo.attempt_dao.delete(active_attempt.id)
|
||||||
|
|
||||||
if test.password:
|
if test.password:
|
||||||
# Проверяем rate limit перед показом экрана ввода пароля
|
|
||||||
allowed, wait_time = await rate_limiter.check(user_id)
|
allowed, wait_time = await rate_limiter.check(user_id)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
minutes = int(wait_time // 60) + 1
|
minutes = int(wait_time // 60) + 1
|
||||||
@@ -173,7 +172,6 @@ async def on_deeplink_password_input(
|
|||||||
manager, test_repo, attempt_repo, test_id, message.from_user.id
|
manager, test_repo, attempt_repo, test_id, message.from_user.id
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Проверяем rate limit при неверном пароле
|
|
||||||
allowed, wait_time = await rate_limiter.check(message.from_user.id)
|
allowed, wait_time = await rate_limiter.check(message.from_user.id)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
minutes = int(wait_time // 60) + 1
|
minutes = int(wait_time // 60) + 1
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
from aiogram.enums import ContentType as AiogramContentType
|
||||||
from aiogram.types import CallbackQuery, Message
|
from aiogram.types import CallbackQuery, Message
|
||||||
from aiogram_dialog import Dialog, DialogManager, StartMode, Window
|
from aiogram_dialog import Dialog, DialogManager, StartMode, Window
|
||||||
|
from aiogram_dialog.api.entities import MediaAttachment, MediaId
|
||||||
from aiogram_dialog.widgets.input import MessageInput
|
from aiogram_dialog.widgets.input import MessageInput
|
||||||
from aiogram_dialog.widgets.kbd import Button, Column, Multiselect, Radio
|
from aiogram_dialog.widgets.kbd import Button, Column, Multiselect, Radio
|
||||||
|
from aiogram_dialog.widgets.media import DynamicMedia
|
||||||
from aiogram_dialog.widgets.text import Const, Format
|
from aiogram_dialog.widgets.text import Const, Format
|
||||||
from dishka import FromDishka
|
from dishka import FromDishka
|
||||||
from dishka.integrations.aiogram_dialog import inject
|
from dishka.integrations.aiogram_dialog import inject
|
||||||
@@ -68,7 +71,6 @@ async def on_start_test(
|
|||||||
await attempt_repo.attempt_dao.delete(active_attempt.id)
|
await attempt_repo.attempt_dao.delete(active_attempt.id)
|
||||||
|
|
||||||
if test.password:
|
if test.password:
|
||||||
# Проверяем rate limit перед показом экрана ввода пароля
|
|
||||||
allowed, wait_time = await rate_limiter.check(user_id)
|
allowed, wait_time = await rate_limiter.check(user_id)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
minutes = int(wait_time // 60) + 1
|
minutes = int(wait_time // 60) + 1
|
||||||
@@ -146,7 +148,6 @@ async def on_password_input(
|
|||||||
|
|
||||||
await manager.switch_to(first_state)
|
await manager.switch_to(first_state)
|
||||||
else:
|
else:
|
||||||
# Проверяем rate limit при неверном пароле
|
|
||||||
allowed, wait_time = await rate_limiter.check(message.from_user.id)
|
allowed, wait_time = await rate_limiter.check(message.from_user.id)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
minutes = int(wait_time // 60) + 1
|
minutes = int(wait_time // 60) + 1
|
||||||
@@ -190,19 +191,27 @@ async def get_question_data(
|
|||||||
questions = dialog_manager.dialog_data.get("questions") or start_data.get("questions", [])
|
questions = dialog_manager.dialog_data.get("questions") or start_data.get("questions", [])
|
||||||
|
|
||||||
if not questions or current_index >= len(questions):
|
if not questions or current_index >= len(questions):
|
||||||
return {"question_text": "Ошибка", "options": []}
|
return {"question_text": "Ошибка", "options": [], "media": None}
|
||||||
|
|
||||||
question_id = questions[current_index]
|
question_id = questions[current_index]
|
||||||
question, options = await test_repo.get_question_with_options(question_id)
|
question, options = await test_repo.get_question_with_options(question_id)
|
||||||
|
|
||||||
if not question:
|
if not question:
|
||||||
return {"question_text": "Ошибка", "options": []}
|
return {"question_text": "Ошибка", "options": [], "media": None}
|
||||||
|
|
||||||
progress = f"{current_index + 1}/{len(questions)}"
|
progress = f"{current_index + 1}/{len(questions)}"
|
||||||
|
|
||||||
|
media = None
|
||||||
|
if question.tg_file_id:
|
||||||
|
media = MediaAttachment(
|
||||||
|
type=AiogramContentType.PHOTO,
|
||||||
|
file_id=MediaId(question.tg_file_id),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"question_text": f"<b>📝 Вопрос {progress}</b>\n\n<blockquote>{question.text}</blockquote>",
|
"question_text": f"<b>📝 Вопрос {progress}</b>\n\n<blockquote>{question.text}</blockquote>",
|
||||||
"options": [(opt.text, str(opt.id)) for opt in options],
|
"options": [(opt.text, str(opt.id)) for opt in options],
|
||||||
|
"media": media,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -492,6 +501,7 @@ take_test_dialog = Dialog(
|
|||||||
state=UserTestSG.password_input,
|
state=UserTestSG.password_input,
|
||||||
),
|
),
|
||||||
Window(
|
Window(
|
||||||
|
DynamicMedia("media", when="media"),
|
||||||
Format("{question_text}\n\n<i>Выберите один вариант ответа:</i>"),
|
Format("{question_text}\n\n<i>Выберите один вариант ответа:</i>"),
|
||||||
Column(
|
Column(
|
||||||
Radio(
|
Radio(
|
||||||
@@ -508,6 +518,7 @@ take_test_dialog = Dialog(
|
|||||||
getter=get_question_data,
|
getter=get_question_data,
|
||||||
),
|
),
|
||||||
Window(
|
Window(
|
||||||
|
DynamicMedia("media", when="media"),
|
||||||
Format("{question_text}\n\n<i>Выберите несколько вариантов ответа:</i>"),
|
Format("{question_text}\n\n<i>Выберите несколько вариантов ответа:</i>"),
|
||||||
Column(
|
Column(
|
||||||
Multiselect(
|
Multiselect(
|
||||||
@@ -524,6 +535,7 @@ take_test_dialog = Dialog(
|
|||||||
getter=get_question_data,
|
getter=get_question_data,
|
||||||
),
|
),
|
||||||
Window(
|
Window(
|
||||||
|
DynamicMedia("media", when="media"),
|
||||||
Format("{question_text}\n\n<i>Введите ответ:</i>"),
|
Format("{question_text}\n\n<i>Введите ответ:</i>"),
|
||||||
MessageInput(on_text_answer_input),
|
MessageInput(on_text_answer_input),
|
||||||
state=UserTestSG.question_input,
|
state=UserTestSG.question_input,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class ParsedQuestion:
|
|||||||
question_type: str
|
question_type: str
|
||||||
options: list[ParsedOption]
|
options: list[ParsedOption]
|
||||||
correct_answer: str | None = None
|
correct_answer: str | None = None
|
||||||
|
image_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -219,16 +220,48 @@ class TestParser:
|
|||||||
))
|
))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
image_url = self._parse_image_url(data, path, errors)
|
||||||
|
|
||||||
if question_type == "input":
|
if question_type == "input":
|
||||||
return self._parse_input_question(data, path, text, errors)
|
return self._parse_input_question(data, path, text, image_url, errors)
|
||||||
else:
|
else:
|
||||||
return self._parse_choice_question(data, path, text, question_type, errors)
|
return self._parse_choice_question(data, path, text, question_type, image_url, errors)
|
||||||
|
|
||||||
|
def _parse_image_url(
|
||||||
|
self,
|
||||||
|
data: dict,
|
||||||
|
path: str,
|
||||||
|
errors: list[ParseError],
|
||||||
|
) -> str | None:
|
||||||
|
image_url = data.get("image_url")
|
||||||
|
|
||||||
|
if image_url is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(image_url, str):
|
||||||
|
errors.append(ParseError("Поле 'image_url' должно быть строкой", path=f"{path}.image_url"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
image_url = image_url.strip()
|
||||||
|
if not image_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not image_url.startswith(("http://", "https://")):
|
||||||
|
errors.append(ParseError("Поле 'image_url' должно быть URL (http:// или https://)", path=f"{path}.image_url"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(image_url) > 2000:
|
||||||
|
errors.append(ParseError("URL изображения слишком длинный (максимум 2000)", path=f"{path}.image_url"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
return image_url
|
||||||
|
|
||||||
def _parse_input_question(
|
def _parse_input_question(
|
||||||
self,
|
self,
|
||||||
data: dict,
|
data: dict,
|
||||||
path: str,
|
path: str,
|
||||||
text: str,
|
text: str,
|
||||||
|
image_url: str | None,
|
||||||
errors: list[ParseError],
|
errors: list[ParseError],
|
||||||
) -> ParsedQuestion | None:
|
) -> ParsedQuestion | None:
|
||||||
correct_answer = data.get("correct_answer")
|
correct_answer = data.get("correct_answer")
|
||||||
@@ -254,6 +287,7 @@ class TestParser:
|
|||||||
question_type="input",
|
question_type="input",
|
||||||
options=[ParsedOption(text=correct_answer, is_correct=True)],
|
options=[ParsedOption(text=correct_answer, is_correct=True)],
|
||||||
correct_answer=correct_answer,
|
correct_answer=correct_answer,
|
||||||
|
image_url=image_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _parse_choice_question(
|
def _parse_choice_question(
|
||||||
@@ -262,6 +296,7 @@ class TestParser:
|
|||||||
path: str,
|
path: str,
|
||||||
text: str,
|
text: str,
|
||||||
question_type: str,
|
question_type: str,
|
||||||
|
image_url: str | None,
|
||||||
errors: list[ParseError],
|
errors: list[ParseError],
|
||||||
) -> ParsedQuestion | None:
|
) -> ParsedQuestion | None:
|
||||||
options_data = data.get("answers")
|
options_data = data.get("answers")
|
||||||
@@ -333,4 +368,5 @@ class TestParser:
|
|||||||
text=text,
|
text=text,
|
||||||
question_type=question_type,
|
question_type=question_type,
|
||||||
options=options,
|
options=options,
|
||||||
|
image_url=image_url,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ async def broadcast_message(bot: Bot, message_id: int, chat_id: int, user_dao: U
|
|||||||
except TelegramRetryAfter as e:
|
except TelegramRetryAfter as e:
|
||||||
logger.warning("Rate limited, waiting %d seconds", e.retry_after)
|
logger.warning("Rate limited, waiting %d seconds", e.retry_after)
|
||||||
await asyncio.sleep(e.retry_after)
|
await asyncio.sleep(e.retry_after)
|
||||||
# Retry after waiting
|
|
||||||
try:
|
try:
|
||||||
await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id)
|
await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id)
|
||||||
success += 1
|
success += 1
|
||||||
|
|||||||
Reference in New Issue
Block a user