diff --git a/infra/components/bot.py b/infra/components/bot.py
index d1b0578..138ed08 100644
--- a/infra/components/bot.py
+++ b/infra/components/bot.py
@@ -113,7 +113,7 @@ def __init__(
runtime=LAMBDA_RUNTIME,
architecture=_lambda.Architecture.ARM_64,
layers=[shared_layer],
- timeout=Duration.seconds(90),
+ timeout=Duration.seconds(300),
memory_size=1024,
log_group=logs.LogGroup(
self,
diff --git a/infra/components/messaging.py b/infra/components/messaging.py
index 7b62634..3402bda 100644
--- a/infra/components/messaging.py
+++ b/infra/components/messaging.py
@@ -34,7 +34,7 @@ def __init__(
f"{CONSTRUCT_PREFIX}TimeoutTasksQueue",
queue_name=f"{RESOURCE_PREFIX}-timeout-tasks-queue-{env_name}",
retention_period=Duration.hours(1),
- visibility_timeout=Duration.seconds(60 * 3),
+ visibility_timeout=Duration.seconds(1800), # >= 6 × Lambda timeout (300 s)
receive_message_wait_time=Duration.seconds(20),
removal_policy=removal_policy,
dead_letter_queue=sqs.DeadLetterQueue(
diff --git a/src/bot/core/config.py b/src/bot/core/config.py
index 882a93e..ef270a0 100644
--- a/src/bot/core/config.py
+++ b/src/bot/core/config.py
@@ -88,11 +88,7 @@ def get_deepseek_api_key() -> str | None:
MAX_EXPLAIN_MEDIA_BYTES: int = int(os.environ.get("MAX_EXPLAIN_MEDIA_BYTES", str(15 * 1024 * 1024)))
# MIME types allowed for automatic document summarization (non-command uploads).
DOCUMENT_AUTO_SUMMARY_MIMES: frozenset[str] = frozenset(
- {
- "application/pdf",
- "text/plain",
- "text/markdown",
- }
+ {"application/pdf", "text/plain", "text/markdown", "text/x-web-markdown"}
)
# ── Chat → language mapping ──────────────────────────────────────────────────
diff --git a/src/bot/core/translations.py b/src/bot/core/translations.py
index bfb32db..56cc9a4 100644
--- a/src/bot/core/translations.py
+++ b/src/bot/core/translations.py
@@ -97,12 +97,13 @@
"quizstats_response": (
"🧠 Your Quiz Stats\n"
"📍 {chat_title}\n\n"
- "🏆 Score: {score} points\n"
- "🔥 Streak: {streak} days\n"
- "⭐ Best streak: {best_streak} days\n"
- "📊 Rank: #{rank} / {total_players} players"
+ "🗓 This week: {week_score} pts · Rank #{rank} / {total_players} players\n"
+ "🎖 Season wins: {season_wins}\n"
+ "──────────────\n"
+ "⭐ All-time: {total_score} pts\n"
+ "🔥 Streak: {streak} days current · {best_streak} days best"
),
- "quizstats_no_data": "🧠 You haven't answered any quizzes yet. Wait for the next daily quiz!",
+ "quizstats_no_data": "🧠 No quiz score yet — answer tomorrow's daily quiz to get on the board!",
"quizstats_open_private_chat": (
"📬 I couldn't send you a private message.\n"
"Please open a chat with me and send /start first, then try /quizstats again."
@@ -144,8 +145,9 @@
"genquiz_lambda_not_configured": "❌ Quiz Lambda is not configured.",
"genquiz_usage": (
"❌ Usage: /genquiz <topic> [<difficulty> [<lang>]]\n"
- "Order: topic → difficulty → language. "
- "Defaults: difficulty medium, language from this chat (CHAT_LANG_MAP)."
+ "Order: topic → difficulty → language.\n"
+ "Difficulties: easy, medium, hard, expert.\n"
+ "Defaults: difficulty medium, language from this group's default."
),
"genquiz_invalid_lang": "❌ Invalid lang. Choose from: {langs}",
"genquiz_invalid_difficulty": "❌ Invalid difficulty. Choose from: {difficulties}",
@@ -262,19 +264,21 @@
"voteban_forgiven": (
"💚 Бұғаттаудан бас тартылды\n\n"
"🎯 {TARGET} {VOTES_AGAINST} дауыспен ақталды.\n\n"
- "👼 Ақтап шыққандар (қарсы дауыс бергендер): {VOTERS_AGAINST}"
+ "👼 Ақтап шыққандар: {VOTERS_AGAINST}"
),
"quizstats_response": (
- "🧠 Сіздің Quiz бойынша нәтижеңіз:\n"
+ "🧠 Сіздің Quiz статистикаңыз\n"
"📍 {chat_title}\n\n"
- "🏆 Жалпы ұпай: {score}\n"
- "🔥 Үздіксіз streak: {streak} күн\n"
- "⭐ Ең ұзақ streak: {best_streak} күн\n"
- "📊 Рейтингтегі орныңыз: #{rank} / {total_players} қатысушы"
+ "🗓 Осы аптада: {week_score} ұпай · Рейтинг #{rank} / {total_players} қатысушы\n"
+ "🎖 Маусымдағы жеңістер: {season_wins}\n"
+ "──────────────\n"
+ "⭐ Барлық уақыт бойынша: {total_score} ұпай\n"
+ "🔥 Серия (Streak): қазір {streak} күн · рекорд {best_streak} күн"
),
- "quizstats_no_data": "🧠 Сіз әлі ешқандай Quiz сұрағына жауап бермепсіз. Келесі сұрақты жіберіп алмаңыз!",
+ "quizstats_no_data": "🧠 Сіздің ұпайыңыз әлі жоқ — ертеңгі күнделікті сұраққа жауап беріп, рейтингке кіріңіз!",
"quizstats_open_private_chat": (
- "📬 Сізге жеке хабарлама жібере алмадым.\n" "Алдымен менімен жеке чат ашып, /start пәрменін жіберіңіз"
+ "📬 Сізге жеке хабарлама жібере алмадым.\n"
+ "Алдымен менімен жеке чат ашып, /start пәрменін жіберіңіз, содан соң /quizstats қайта көріңіз."
),
"quiz_not_configured": "⚙️ Quiz бұл бот үшін бапталмаған.",
"wtf_usage": (
@@ -312,8 +316,9 @@
"genquiz_lambda_not_configured": "❌ Quiz Lambda бапталмаған.",
"genquiz_usage": (
"❌ Қолданылуы: /genquiz <тақырып> [<деңгей> [<тіл>]]\n"
- "Реті: тақырып → деңгей → тіл. "
- "Әдепкі: деңгей medium, тіл чаттан (CHAT_LANG_MAP)."
+ "Реті: тақырып → деңгей → тіл.\n"
+ "Деңгейлер: easy, medium, hard, expert.\n"
+ "Әдепкі: деңгей medium, тіл осы топтың негізгі тілі бойынша."
),
"genquiz_invalid_lang": "❌ Тіл қате. Келесілерді таңдаңыз: {langs}",
"genquiz_invalid_difficulty": "❌ Деңгей қате. Келесілерді таңдаңыз: {difficulties}",
@@ -428,12 +433,13 @@
"quizstats_response": (
"🧠 你的 Quiz 统计\n"
"📍 {chat_title}\n\n"
- "🏆 积分:{score}\n"
- "🔥 连胜:{streak} 天\n"
- "⭐ 最佳连胜:{best_streak} 天\n"
- "📊 排名:#{rank} / {total_players} 人"
+ "🗓 本周:{week_score} 分 · 排名 #{rank} / {total_players} 人\n"
+ "🎖 赛季冠军次数:{season_wins}\n"
+ "──────────────\n"
+ "⭐ 历史总分:{total_score} 分\n"
+ "🔥 连胜:当前 {streak} 天 · 最佳 {best_streak} 天"
),
- "quizstats_no_data": "🧠 你还没有答过 Quiz 题目。请等待下一次每日测验!",
+ "quizstats_no_data": "🧠 暂无积分记录 —— 明天参加每日测验即可上榜!",
"quizstats_open_private_chat": (
"📬 我无法给你发送私信。\n" "请先打开与我的私聊并发送 /start,然后再试一次 /quizstats。"
),
@@ -467,7 +473,8 @@
"genquiz_usage": (
"❌ 用法:/genquiz <主题> [<难度> [<语言>]]\n"
"顺序:主题 → 难度 → 语言。\n"
- "默认:难度 medium,语言为本群设置(CHAT_LANG_MAP)。"
+ "可选难度:easy, medium, hard, expert。\n"
+ "默认:难度 medium,语言为当前群组的默认语言。"
),
"genquiz_invalid_lang": "❌ 语言无效。可选:{langs}",
"genquiz_invalid_difficulty": "❌ 难度无效。可选:{difficulties}",
@@ -582,12 +589,13 @@
"quizstats_response": (
"🧠 Ваша статистика Quiz\n"
"📍 {chat_title}\n\n"
- "🏆 Очки: {score}\n"
- "🔥 Серия: {streak} дней\n"
- "⭐ Лучшая серия: {best_streak} дней\n"
- "📊 Ранг: #{rank} / {total_players} игроков"
+ "🗓 На этой неделе: {week_score} очк. · Ранг #{rank} / {total_players} игроков\n"
+ "🎖 Побед в сезоне: {season_wins}\n"
+ "──────────────\n"
+ "⭐ За всё время: {total_score} очк.\n"
+ "🔥 Серия: {streak} дн. сейчас · {best_streak} дн. рекорд"
),
- "quizstats_no_data": "🧠 Вы еще не отвечали на Quiz. Дождитесь следующего ежедневного вопроса!",
+ "quizstats_no_data": "🧠 Очков пока нет — ответьте на завтрашний ежедневный вопрос и попадите в рейтинг!",
"quizstats_open_private_chat": (
"📬 Я не смог отправить вам личное сообщение.\n"
"Сначала откройте со мной личный чат и отправьте /start, затем попробуйте /quizstats снова."
@@ -628,8 +636,9 @@
"genquiz_lambda_not_configured": "❌ Quiz Lambda не настроена.",
"genquiz_usage": (
"❌ Использование: /genquiz <тема> [<сложность> [<язык>]]\n"
- "Порядок: тема → сложность → язык. "
- "По умолчанию: сложность medium, язык чата (CHAT_LANG_MAP)."
+ "Порядок: тема → сложность → язык.\n"
+ "Сложности: easy, medium, hard, expert.\n"
+ "По умолчанию: сложность medium, язык по умолчанию для этой группы."
),
"genquiz_invalid_lang": "❌ Неверный язык. Выберите из: {langs}",
"genquiz_invalid_difficulty": "❌ Неверная сложность. Выберите из: {difficulties}",
diff --git a/src/bot/services/ai/gemini_client.py b/src/bot/services/ai/gemini_client.py
index 85647f4..74abf7e 100644
--- a/src/bot/services/ai/gemini_client.py
+++ b/src/bot/services/ai/gemini_client.py
@@ -4,8 +4,11 @@
across all Lambda invocations and chat groups. The "day" matches
Gemini/Google: calendar date in America/Los_Angeles (midnight PT reset).
-Timeout budget: explain tasks run via SQS Lambda (90 s budget), not API Gateway.
-Plain-text explain: read timeout 25 s. Multimodal (large inline payloads): read 90 s.
+Timeout budget: explain tasks run via SQS Lambda (300 s budget), not API Gateway.
+Plain-text explain: read timeout 25 s. Multimodal (large inline payloads): read 150 s.
+
+Retry policy: up to 3 retries with exponential back-off (2 s, 4 s, 8 s) for
+transient failures (5xx, network/timeout). 429 raises immediately — no retry.
"""
import base64
@@ -29,7 +32,7 @@
logger = LoggerAdapter(get_logger(__name__), {})
_http = urllib3.PoolManager(maxsize=2, timeout=urllib3.Timeout(connect=3, read=25))
-_http_multimodal = urllib3.PoolManager(maxsize=2, timeout=urllib3.Timeout(connect=3, read=90))
+_http_multimodal = urllib3.PoolManager(maxsize=2, timeout=urllib3.Timeout(connect=3, read=150))
def _thinking_config_for_model(model: str) -> dict[str, Any] | None:
@@ -115,9 +118,8 @@ def explain_term(self, term: str, lang: str = "kk", style: WTFPromptStyle = "ang
body = json.dumps(payload)
headers = {"Content-Type": "application/json"}
- # Single retry for transient network/timeout failures. 503 (model globally
- # overloaded) breaks immediately — retrying in 1s won't help and just delays fallback.
- _retry_delays = (1,)
+ # 3 retries with exponential back-off; 429 raises immediately without retry.
+ _RETRY_DELAYS = (2, 4, 8)
last_exc: Exception | None = None
logger.info(
@@ -134,7 +136,7 @@ def explain_term(self, term: str, lang: str = "kk", style: WTFPromptStyle = "ang
"term_chars": len(term),
},
)
- for attempt, delay in enumerate(_retry_delays):
+ for attempt in range(len(_RETRY_DELAYS) + 1):
try:
logger.info(
"Gemini explain request started",
@@ -147,8 +149,8 @@ def explain_term(self, term: str, lang: str = "kk", style: WTFPromptStyle = "ang
extra={"model": self._model, "attempt": attempt + 1, "error": str(exc)},
)
last_exc = GeminiUnavailableError(f"Gemini unreachable: {exc}")
- if attempt < len(_retry_delays) - 1:
- time.sleep(delay + random.uniform(0, 2))
+ if attempt < len(_RETRY_DELAYS):
+ time.sleep(_RETRY_DELAYS[attempt] + random.uniform(0, 1))
continue
if resp.status == 429:
@@ -162,10 +164,8 @@ def explain_term(self, term: str, lang: str = "kk", style: WTFPromptStyle = "ang
extra={"status": resp.status, "attempt": attempt + 1, "body": body_text[:200]},
)
last_exc = GeminiUnavailableError(f"Gemini API {resp.status}: {body_text[:200]}")
- if resp.status == 503:
- break # globally overloaded — fall back immediately, don't retry
- if attempt < len(_retry_delays) - 1:
- time.sleep(delay + random.uniform(0, 2))
+ if attempt < len(_RETRY_DELAYS):
+ time.sleep(_RETRY_DELAYS[attempt] + random.uniform(0, 1))
continue
if resp.status >= 400:
@@ -261,7 +261,8 @@ def explain_media(
url = f"{GEMINI_API_BASE}/{self._model}:generateContent?key={self._api_key}"
body = json.dumps(payload)
headers = {"Content-Type": "application/json"}
- _retry_delays = (1,)
+ # 3 retries with exponential back-off; 429 raises immediately without retry.
+ _RETRY_DELAYS = (2, 4, 8)
last_exc: Exception | None = None
logger.info(
@@ -277,7 +278,7 @@ def explain_media(
},
)
- for attempt, delay in enumerate(_retry_delays):
+ for attempt in range(len(_RETRY_DELAYS) + 1):
try:
resp = _http_multimodal.request(
"POST",
@@ -292,8 +293,8 @@ def explain_media(
extra={"model": self._model, "attempt": attempt + 1, "error": str(exc)},
)
last_exc = GeminiUnavailableError(f"Gemini unreachable: {exc}")
- if attempt < len(_retry_delays) - 1:
- time.sleep(delay + random.uniform(0, 2))
+ if attempt < len(_RETRY_DELAYS):
+ time.sleep(_RETRY_DELAYS[attempt] + random.uniform(0, 1))
continue
if resp.status == 429:
@@ -307,10 +308,8 @@ def explain_media(
extra={"status": resp.status, "attempt": attempt + 1, "body": body_text[:200]},
)
last_exc = GeminiUnavailableError(f"Gemini API {resp.status}: {body_text[:200]}")
- if resp.status == 503:
- break
- if attempt < len(_retry_delays) - 1:
- time.sleep(delay + random.uniform(0, 2))
+ if attempt < len(_RETRY_DELAYS):
+ time.sleep(_RETRY_DELAYS[attempt] + random.uniform(0, 1))
continue
if resp.status >= 400:
diff --git a/src/bot/services/handlers/commands.py b/src/bot/services/handlers/commands.py
index fa492f2..8476a93 100644
--- a/src/bot/services/handlers/commands.py
+++ b/src/bot/services/handlers/commands.py
@@ -1,7 +1,6 @@
"""Simple bot commands: /start, /help, /support, /ping, /stats, /genquiz."""
from core.config import (
- ADMIN_USER_ID,
QUIZ_LAMBDA_NAME,
VALID_DIFFICULTIES,
VALID_LANGS,
@@ -115,15 +114,11 @@ def handle_stats(ctx: Context) -> None:
def handle_quiz_generate(ctx: Context) -> None:
- """Admin-only: generate and send an on-demand quiz poll to the current chat.
+ """Generate and send an on-demand quiz poll to the current chat (open to all users).
Usage: ``/genquiz `` [, ```` [, ````]] — fixed order;
omitted difficulty defaults to ``medium``, omitted lang to this chat's default.
"""
- if ctx.user_id != ADMIN_USER_ID:
- react_genquiz_processing(ctx, "🤡")
- return
-
if not QUIZ_LAMBDA_NAME or not ctx.lambda_invoker:
react_genquiz_processing(ctx, "🤡")
ctx.reply(get_translated_text("genquiz_lambda_not_configured", ctx.lang_code), ctx.message_id)
@@ -165,7 +160,6 @@ def handle_quiz_generate(ctx: Context) -> None:
"topic": topic,
"lang": lang,
"difficulty": difficulty,
- "include_rpd_footer": True,
"reply_to_message_id": ctx.message_id,
},
)
diff --git a/src/bot/services/handlers/explain_document.py b/src/bot/services/handlers/explain_document.py
index 14f4e8f..a730506 100644
--- a/src/bot/services/handlers/explain_document.py
+++ b/src/bot/services/handlers/explain_document.py
@@ -63,11 +63,9 @@ def handle_document_auto_summary(ctx: Context) -> None:
if not isinstance(doc, dict):
return
- allowed, reason_key = document_auto_allowed(doc)
+ allowed, _ = document_auto_allowed(doc)
if not allowed:
- if reason_key:
- _react(ctx, _ERROR_REACTION)
- ctx.reply(get_translated_text(reason_key, lang), ctx.message_id)
+ _react(ctx, _ERROR_REACTION)
return
file_id = doc.get("file_id")
diff --git a/src/bot/services/handlers/quiz.py b/src/bot/services/handlers/quiz.py
index 594fa8c..72d8b97 100644
--- a/src/bot/services/handlers/quiz.py
+++ b/src/bot/services/handlers/quiz.py
@@ -69,7 +69,7 @@ def handle_poll_answer(ctx: Context) -> None:
# Look up the quiz record by poll_id
quiz_record = ctx.quiz_repo.lookup_poll(poll_id)
if not quiz_record:
- logger.debug("poll_answer for unknown poll_id, ignoring", extra={"poll_id": poll_id})
+ logger.warning("poll_answer for unknown poll_id, ignoring", extra={"poll_id": poll_id})
return
chat_id = quiz_record["PK"].replace("QUIZ#", "")
@@ -96,14 +96,23 @@ def handle_quizstats(ctx: Context) -> None:
user_id = str(ctx.user_id)
lang = ctx.lang_code
- user_score = ctx.quiz_repo.get_user_score(chat_id, user_id)
- if not user_score:
- ctx.reply(get_translated_text("quizstats_no_data", lang))
+ user_score_raw = ctx.quiz_repo.get_user_score(chat_id, user_id)
+ if user_score_raw is None:
+ try:
+ ctx.send_private_message(get_translated_text("quizstats_no_data", lang))
+ ctx.react("👌")
+ except TelegramAPIError as error:
+ if error.status == 403:
+ ctx.reply(get_translated_text("quizstats_open_private_chat", lang), ctx.message_id)
+ return
+ raise
return
- # Calculate rank
+ user_score = user_score_raw
+
leaderboard = ctx.quiz_repo.get_leaderboard(chat_id)
- rank = 1
+ # Unranked users (on the board but not in this week's list) go after the last row
+ rank = len(leaderboard) + 1
for i, entry in enumerate(leaderboard):
if entry.get("SK") == f"USER#{user_id}":
rank = i + 1
@@ -117,9 +126,11 @@ def handle_quizstats(ctx: Context) -> None:
"quizstats_response",
lang,
chat_title=chat_title,
- score=user_score.get("total_score", 0),
- streak=user_score.get("current_streak", 0),
- best_streak=user_score.get("best_streak", 0),
+ week_score=int(user_score.get("week_score", 0)),
+ season_wins=int(user_score.get("season_wins", 0)),
+ total_score=int(user_score.get("total_score", 0)),
+ streak=int(user_score.get("current_streak", 0)),
+ best_streak=int(user_score.get("best_streak", 0)),
rank=rank,
total_players=len(leaderboard),
)
diff --git a/src/bot/services/handlers/wtf.py b/src/bot/services/handlers/wtf.py
index 6e3faed..ded5b24 100644
--- a/src/bot/services/handlers/wtf.py
+++ b/src/bot/services/handlers/wtf.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import time
-from typing import Callable, cast
+from typing import Any, Callable, cast
from core.config import get_chat_lang, get_deepseek_api_key, get_gemini_api_key
from core.dispatcher import Context
@@ -55,12 +55,21 @@ def _get_task_repo() -> ExplainTaskRepository:
return _task_repo
+def _reply_message_visible_text(reply: dict[str, Any]) -> str:
+ """Text body or media caption (channel posts and albums often use caption, not text)."""
+ for key in ("text", "caption"):
+ raw = reply.get(key)
+ if isinstance(raw, str) and raw.strip():
+ return raw.strip()
+ return ""
+
+
def _extract_term(ctx: Context) -> str:
parts = ctx.text.split(maxsplit=1)
if len(parts) > 1:
return parts[1].strip()
if ctx.reply_to_message:
- return (ctx.reply_to_message.get("text") or "").strip()
+ return _reply_message_visible_text(ctx.reply_to_message)
# External replies (cross-chat forwards) have no reply_to_message;
# Telegram instead populates `quote.text` with the selected fragment.
quote_text = (ctx.message.get("quote") or {}).get("text") or ""
diff --git a/src/bot/webhook.py b/src/bot/webhook.py
index adcebf8..1d84abc 100644
--- a/src/bot/webhook.py
+++ b/src/bot/webhook.py
@@ -17,6 +17,7 @@
from services.repositories.sqs import SQSClient
from services.spam.screening_service import SpamScreeningService
from services.telegram import TelegramClient
+from zerde_common.logging_utils import telegram_update_log_extra
logger = LoggerAdapter(get_logger(__name__), {})
@@ -71,12 +72,15 @@ def _handle_api_gateway(
try:
body = parse_api_gateway_event(event)
- logger.info("API Gateway event parsed successfully")
except ValueError as e:
logger.error("Failed to parse API Gateway event", extra={"error": e})
return create_response(200, {"message": "Invalid request"})
chat_id, chat_type = _extract_chat_context(body)
+ update_log = telegram_update_log_extra(body)
+ update_log["chat_id"] = chat_id
+ update_log["chat_type"] = chat_type
+ logger.info("Telegram webhook update received", extra=update_log)
if chat_type == "private":
dispatcher.bot.send_message(
diff --git a/src/quiz/main.py b/src/quiz/main.py
index e6b1ad7..350f18d 100644
--- a/src/quiz/main.py
+++ b/src/quiz/main.py
@@ -39,7 +39,6 @@ def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
chat_id = event.get("chat_id", "")
topic = event.get("topic", "programming")
difficulty = event.get("difficulty", "medium")
- include_rpd_footer = bool(event.get("include_rpd_footer", False))
reply_to_message_id = event.get("reply_to_message_id")
if not chat_id:
return {"status": "error", "reason": "missing chat_id"}
@@ -48,7 +47,6 @@ def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
lang,
topic,
difficulty,
- include_rpd_footer=include_rpd_footer,
reply_to_message_id=reply_to_message_id if isinstance(reply_to_message_id, int) else None,
)
diff --git a/src/quiz/services/quiz_sender.py b/src/quiz/services/quiz_sender.py
index d5e4ee9..2f4aa46 100644
--- a/src/quiz/services/quiz_sender.py
+++ b/src/quiz/services/quiz_sender.py
@@ -62,6 +62,7 @@ def send_quiz_poll(
options: list[str],
correct_option_id: int,
explanation: str | None = None,
+ question_parse_mode: str | None = None,
) -> dict[str, Any] | None:
"""Send a quiz poll to a chat. Returns the Telegram response result or None on failure."""
url = f"{self._base_url}/sendPoll"
@@ -77,6 +78,8 @@ def send_quiz_poll(
}
if explanation:
payload["explanation"] = explanation[:200] # Telegram limit
+ if question_parse_mode:
+ payload["question_parse_mode"] = question_parse_mode
try:
resp = http.request(
diff --git a/src/quiz/services/quiz_service.py b/src/quiz/services/quiz_service.py
index f722dd7..c5ec4b1 100644
--- a/src/quiz/services/quiz_service.py
+++ b/src/quiz/services/quiz_service.py
@@ -36,6 +36,21 @@
"aws-dva-c02": "AWS Developer Associate Practice Exam",
}
+# Topic aliases that /genquiz should serve from the bank instead of AI.
+# Keys are lowercased topic strings; values are the banked category name.
+_GENQUIZ_TOPIC_TO_BANKED: dict[str, str] = {
+ "cloud": "cloud",
+ "aws": "cloud",
+ "aws-clf": "cloud",
+ "clf": "cloud",
+ "clf-c02": "cloud",
+ "aws-clf-c02": "cloud",
+ "dva": "cloud",
+ "aws-dva": "cloud",
+ "dva-c02": "cloud",
+ "aws-dva-c02": "cloud",
+}
+
class QuizService:
"""Orchestrates quiz operations (daily quiz and leaderboards)."""
@@ -46,13 +61,6 @@ def __init__(self) -> None:
self._sender = QuizSender()
self._repo = QuizRepository()
- def _rpd_payload(self) -> dict[str, int]:
- """Build a response fragment with quiz Gemini RPD counters."""
- remaining, total = self._generator.get_rpd_status()
- if remaining is None or total is None:
- return {}
- return {"rpd_remaining": remaining, "rpd_total": total}
-
def get_difficulty(self) -> str:
"""Return the difficulty level for today (Almaty time)."""
weekday = datetime.now(_ALMATY_TZ).weekday()
@@ -210,6 +218,47 @@ def _pick_banked_question_for_chat(self, category: str, chat_id: str, difficulty
self._repo.save_question_queue(category, chat_id, remaining)
return None
+ def _pick_banked_question_for_genquiz(self, category: str, chat_id: str, difficulty: str) -> dict | None:
+ """Pick the next on-demand question from the bank using a genquiz-specific per-chat queue.
+
+ Uses a different DynamoDB key and shuffle seed from the daily rotation so that
+ genquiz picks are unlikely to collide with upcoming daily questions.
+ Daily category queue is never read or written by this method.
+ """
+ sources = _BANKED_CATEGORIES[category]
+ remaining = self._repo.get_genquiz_question_queue(category, chat_id)
+ if not remaining:
+ all_keys = self._repo.get_bank_question_ids(category, sources)
+ if not all_keys:
+ return None
+ # Different seed from daily ("genquiz:" prefix) → different shuffle order
+ rng = random.Random(f"genquiz:{chat_id}::{len(all_keys)}")
+ remaining = rng.sample(all_keys, len(all_keys))
+ logger.info(
+ "New genquiz bank round",
+ extra={"chat_id": chat_id, "category": category, "total": len(remaining)},
+ )
+
+ while remaining:
+ key = remaining.pop(0)
+ source, q_uuid = key.split("::", 1)
+ item = self._repo.get_bank_question(category, source, q_uuid)
+ if item:
+ self._repo.save_genquiz_question_queue(category, chat_id, remaining)
+ return {
+ "question": item["question"],
+ "options": list(item["options"]),
+ "correct_option_index": int(item["correct_option_id"]),
+ "explanation": item.get("explanation", ""),
+ "difficulty": difficulty,
+ "points": DIFFICULTY_POINTS.get(difficulty, 1),
+ "source_label": _BANK_SOURCE_LABELS.get(source, source),
+ }
+ logger.warning("Genquiz bank question missing, skipping", extra={"uuid": q_uuid})
+
+ self._repo.save_genquiz_question_queue(category, chat_id, remaining)
+ return None
+
def process_daily_quiz(self, chat_ids: list[str], lang: str) -> dict:
"""Generate and send the daily quiz to each chat with independent category rotation."""
if not chat_ids:
@@ -224,6 +273,16 @@ def process_daily_quiz(self, chat_ids: list[str], lang: str) -> dict:
failed: list[dict] = []
for chat_id in chat_ids:
+ existing = self._repo.get_today_quiz_record(str(chat_id))
+ if existing:
+ logger.info(
+ "Daily quiz already sent for this chat today, skipping",
+ extra={"chat_id": chat_id, "poll_id": existing.get("poll_id")},
+ )
+ sent_count += 1
+ sent_chat_ids.append(str(chat_id))
+ continue
+
category, remaining = self._pick_category_for_chat(str(chat_id))
generated = None
used_category = category
@@ -333,16 +392,68 @@ def process_daily_quiz(self, chat_ids: list[str], lang: str) -> dict:
}
def process_on_demand_quiz(self, chat_id: str, lang: str, topic: str, difficulty: str) -> dict:
- """Generate and send a single on-demand quiz to one chat."""
+ """Generate and send a single on-demand quiz to one chat.
+
+ For topics that map to a banked category (e.g. "cloud", "aws"), questions are drawn
+ from the question bank using a per-chat genquiz queue that is independent from the
+ daily rotation. The RPD footer is omitted for bank-sourced questions (no AI used).
+ """
logger.info(
"On-demand quiz requested",
extra={"chat_id": chat_id, "topic": topic, "lang": lang, "difficulty": difficulty},
)
+ # ── Bank path ────────────────────────────────────────────────────────
+ banked_category = _GENQUIZ_TOPIC_TO_BANKED.get(topic.lower().strip())
+ if banked_category:
+ banked = self._pick_banked_question_for_genquiz(banked_category, str(chat_id), difficulty)
+ if banked:
+ question = banked
+ if lang != "en":
+ translated = self._generator.translate_question(banked, lang)
+ if translated:
+ question = translated
+ else:
+ logger.warning(
+ "Genquiz translation failed, using English original",
+ extra={"chat_id": chat_id, "lang": lang},
+ )
+ # Prepend source label inline in the question text (no separate announcement)
+ source_label = question.get("source_label", "")
+ if source_label:
+ prefix = f"📚 {source_label}\n\n"
+ q_text = question["question"][: 300 - len(prefix)]
+ poll_question = prefix + q_text
+ else:
+ poll_question = question["question"]
+ poll_result = self._sender.send_quiz_poll(
+ chat_id=chat_id,
+ question=poll_question,
+ options=question["options"],
+ correct_option_id=question["correct_option_index"],
+ explanation=question.get("explanation"),
+ question_parse_mode="HTML",
+ )
+ if poll_result:
+ logger.info(
+ "Genquiz sent from bank",
+ extra={"chat_id": chat_id, "category": banked_category, "lang": lang},
+ )
+ # No rpd_payload — bank questions don't consume AI quota
+ return {"status": "ok", "sent": 1, "total": 1}
+ logger.error("Failed to send genquiz poll from bank", extra={"chat_id": chat_id})
+ return {"status": "error", "reason": "failed to send poll"}
+ # Bank exhausted (shouldn't happen in practice) — fall through to AI
+ logger.warning(
+ "Genquiz bank empty for topic, falling back to AI",
+ extra={"chat_id": chat_id, "topic": topic},
+ )
+
+ # ── AI path ──────────────────────────────────────────────────────────
question = self._generator.generate_question(topic, lang, difficulty)
if not question:
logger.error("Failed to generate on-demand question", extra={"topic": topic})
- return {"status": "error", "reason": "no valid question", **self._rpd_payload()}
+ return {"status": "error", "reason": "no valid question"}
poll_result = self._sender.send_quiz_poll(
chat_id=chat_id,
@@ -353,11 +464,11 @@ def process_on_demand_quiz(self, chat_id: str, lang: str, topic: str, difficulty
)
if poll_result:
- logger.info("On-demand quiz sent", extra={"chat_id": chat_id, "topic": topic})
- return {"status": "ok", "sent": 1, "total": 1, **self._rpd_payload()}
+ logger.info("On-demand quiz sent via AI", extra={"chat_id": chat_id, "topic": topic})
+ return {"status": "ok", "sent": 1, "total": 1}
logger.error("Failed to send on-demand quiz poll", extra={"chat_id": chat_id})
- return {"status": "error", "reason": "failed to send poll", **self._rpd_payload()}
+ return {"status": "error", "reason": "failed to send poll"}
def process_on_demand_quiz_with_feedback(
self,
@@ -366,15 +477,7 @@ def process_on_demand_quiz_with_feedback(
topic: str,
difficulty: str,
*,
- include_rpd_footer: bool,
reply_to_message_id: int | None = None,
) -> dict:
- """Run on-demand quiz and optionally send RPD footer to chat."""
- result = self.process_on_demand_quiz(chat_id, lang, topic, difficulty)
- if include_rpd_footer:
- remaining = result.get("rpd_remaining")
- total = result.get("rpd_total")
- if isinstance(remaining, int) and isinstance(total, int):
- footer = get_translated_text("genquiz_rpd_footer", lang, remaining=remaining, total=total)
- self._sender.send_message(chat_id, footer, reply_to_message_id=reply_to_message_id)
- return result
+ """Run on-demand quiz (bank or AI path). No RPD footer is sent."""
+ return self.process_on_demand_quiz(chat_id, lang, topic, difficulty)
diff --git a/src/quiz/services/repository.py b/src/quiz/services/repository.py
index 7d90d02..beebe94 100644
--- a/src/quiz/services/repository.py
+++ b/src/quiz/services/repository.py
@@ -5,7 +5,8 @@
from typing import Any
import boto3
-from boto3.dynamodb.conditions import Key
+from boto3.dynamodb.conditions import Attr, Key
+from botocore.exceptions import ClientError
from core.config import TABLE_NAME
from core.logger import LoggerAdapter, get_logger
@@ -235,6 +236,49 @@ def save_question_queue(self, category: str, chat_id: str, remaining: list[str])
except Exception as e:
logger.error("Failed to save question queue", extra={"error": str(e)})
+ # ── Genquiz (on-demand) question queue — separate from daily rotation ─────
+
+ def get_genquiz_question_queue(self, category: str, chat_id: str) -> list[str]:
+ """Read the per-chat on-demand genquiz question queue (independent of daily rotation)."""
+ try:
+ resp = self._table.get_item(
+ Key={"PK": f"META#genquiz_q_queue#{category}#{chat_id}", "SK": "LATEST"},
+ ConsistentRead=False,
+ )
+ item = resp.get("Item")
+ if item and "remaining" in item:
+ return list(item["remaining"])
+ return []
+ except Exception as e:
+ logger.error("Failed to get genquiz question queue", extra={"error": str(e)})
+ return []
+
+ def save_genquiz_question_queue(self, category: str, chat_id: str, remaining: list[str]) -> None:
+ """Write the per-chat on-demand genquiz question queue."""
+ try:
+ self._table.put_item(
+ Item={
+ "PK": f"META#genquiz_q_queue#{category}#{chat_id}",
+ "SK": "LATEST",
+ "remaining": remaining,
+ }
+ )
+ except Exception as e:
+ logger.error("Failed to save genquiz question queue", extra={"error": str(e)})
+
+ def get_today_quiz_record(self, chat_id: str) -> dict[str, Any] | None:
+ """Return today's quiz record for a chat, or None if not yet sent."""
+ today = datetime.now(_ALMATY_TZ).strftime("%Y-%m-%d")
+ try:
+ resp = self._table.get_item(
+ Key={"PK": f"QUIZ#{chat_id}", "SK": f"DATE#{today}"},
+ ConsistentRead=True,
+ )
+ return resp.get("Item")
+ except ClientError as e:
+ logger.error("Failed to read today quiz record", extra={"chat_id": chat_id, "error": str(e)})
+ return None
+
def save_quiz_record(
self,
chat_id: str,
@@ -248,8 +292,12 @@ def save_quiz_record(
message_id: int,
difficulty: str = "easy",
points: int = 1,
- ) -> None:
- """Write a daily quiz record for a chat."""
+ ) -> bool:
+ """Write a daily quiz record for a chat.
+
+ Returns True on success, False if a record already exists for today
+ (idempotent: safe to call on Lambda retry without overwriting a live poll).
+ """
now = datetime.now(_ALMATY_TZ)
today = now.strftime("%Y-%m-%d")
ttl = int(time.time()) + (_TTL_DAYS * 86400)
@@ -271,9 +319,17 @@ def save_quiz_record(
"points": points,
"sent_at": now.isoformat(),
"ttl": ttl,
- }
+ },
+ ConditionExpression=Attr("PK").not_exists(),
)
logger.info("Quiz record saved", extra={"chat_id": chat_id, "date": today})
- except Exception as e:
+ return True
+ except ClientError as e:
+ if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
+ logger.warning(
+ "Quiz record already exists for today, skipping save",
+ extra={"chat_id": chat_id, "date": today},
+ )
+ return False
logger.error("Failed to save quiz record", extra={"chat_id": chat_id, "error": str(e)})
raise
diff --git a/src/shared/python/zerde_common/logging_utils.py b/src/shared/python/zerde_common/logging_utils.py
index e64effe..54806ed 100644
--- a/src/shared/python/zerde_common/logging_utils.py
+++ b/src/shared/python/zerde_common/logging_utils.py
@@ -7,6 +7,8 @@
_DEFAULT_MAX_EXTRA = 512
_DEFAULT_LLM_PREVIEW = 200
+# CloudWatch log events are capped (~256 KiB); keep serialized Update under this budget.
+_DEFAULT_MAX_TELEGRAM_UPDATE_JSON = 200_000
def truncate_log_text(text: str | None, max_chars: int = _DEFAULT_MAX_EXTRA) -> str:
@@ -90,3 +92,37 @@ def safe_json_dumps_for_log(obj: Any, max_chars: int = _DEFAULT_MAX_EXTRA) -> st
except (TypeError, ValueError) as e:
return f""
return truncate_log_text(raw, max_chars)
+
+
+def _bounded_dict_log_extra(
+ data: dict[str, Any],
+ field_prefix: str,
+ *,
+ max_json_chars: int,
+) -> dict[str, Any]:
+ """Build ``extra`` keys: full dict under ``field_prefix`` if small, else truncated JSON string."""
+ extra: dict[str, Any] = {}
+ try:
+ raw = json.dumps(data, default=str, ensure_ascii=False)
+ except (TypeError, ValueError) as e:
+ extra[field_prefix] = f""
+ return extra
+ extra[f"{field_prefix}_chars"] = len(raw)
+ if len(raw) <= max_json_chars:
+ extra[field_prefix] = data
+ extra[f"{field_prefix}_truncated"] = False
+ else:
+ extra[f"{field_prefix}_json"] = raw[: max_json_chars - 60] + "…(truncated)"
+ extra[f"{field_prefix}_truncated"] = True
+ return extra
+
+
+def telegram_update_log_extra(
+ update: dict[str, Any],
+ *,
+ max_json_chars: int = _DEFAULT_MAX_TELEGRAM_UPDATE_JSON,
+) -> dict[str, Any]:
+ """Structured ``extra`` for logging a full Telegram Bot API ``Update`` at INFO (size-bounded)."""
+ out = _bounded_dict_log_extra(update, "telegram_update", max_json_chars=max_json_chars)
+ out["update_id"] = update.get("update_id")
+ return out
diff --git a/tests/test_explain_enqueue.py b/tests/test_explain_enqueue.py
index f985ee0..35d1589 100644
--- a/tests/test_explain_enqueue.py
+++ b/tests/test_explain_enqueue.py
@@ -42,3 +42,48 @@ def test_sqs_send_failure_releases_reservation(
task_repo.release_reservation.assert_called_once_with(424242)
assert not task_repo.mark_enqueued.called
ctx.reply.assert_called_once()
+
+
+@patch("services.handlers.wtf.get_deepseek_api_key", return_value="sk")
+@patch("services.handlers.wtf.get_gemini_api_key", return_value="gk")
+@patch("services.handlers.wtf._get_task_repo")
+@patch("services.handlers.wtf._get_gemini")
+@patch("services.handlers.wtf._get_fallback")
+def test_wtf_reply_to_photo_uses_caption_as_term(
+ _mock_fallback: MagicMock,
+ _mock_gemini: MagicMock,
+ mock_get_repo: MagicMock,
+ _mock_gemini_key: MagicMock,
+ _mock_deepseek_key: MagicMock,
+) -> None:
+ """Channel auto-forwards often have caption instead of body text on photo posts."""
+ task_repo = MagicMock()
+ task_repo.try_reserve_update.return_value = True
+ mock_get_repo.return_value = task_repo
+
+ sqs = MagicMock()
+ ctx = MagicMock()
+ ctx.text = "/wtf@zerde_kz_bot"
+ ctx.reply_to_message = {
+ "message_id": 36737,
+ "caption": "Ну блять, тест",
+ "photo": [{"file_id": "AgAC...", "width": 1, "height": 1}],
+ }
+ ctx.chat_id = 1
+ ctx.message_id = 2
+ ctx.update_id = 900001
+ ctx.sqs_repo = sqs
+ ctx.message = {}
+ ctx.reply = MagicMock()
+
+ with (
+ patch("services.handlers.wtf.get_chat_lang", return_value="kk"),
+ patch("services.handlers.wtf._react_processing"),
+ patch("services.handlers.wtf._send_typing_once"),
+ ):
+ wtf_mod.handle_wtf(ctx)
+
+ sqs.send_explain_task.assert_called_once()
+ kwargs = sqs.send_explain_task.call_args.kwargs
+ assert kwargs["term"] == "Ну блять, тест"
+ task_repo.mark_enqueued.assert_called_once_with(900001)
diff --git a/tests/test_quiz_handler.py b/tests/test_quiz_handler.py
index 2e3fec3..82c20f2 100644
--- a/tests/test_quiz_handler.py
+++ b/tests/test_quiz_handler.py
@@ -102,13 +102,15 @@ def test_shows_stats_for_existing_user(self):
quiz_repo = MagicMock()
quiz_repo.get_user_score.return_value = {
"total_score": 10,
+ "week_score": 4,
+ "season_wins": 1,
"current_streak": 3,
"best_streak": 5,
}
quiz_repo.get_leaderboard.return_value = [
- {"SK": "USER#111", "total_score": 20},
- {"SK": "USER#456", "total_score": 10},
- {"SK": "USER#789", "total_score": 5},
+ {"SK": "USER#111", "week_score": 8},
+ {"SK": "USER#456", "week_score": 4},
+ {"SK": "USER#789", "week_score": 1},
]
ctx = self._make_ctx(-100123, 456, quiz_repo)
ctx.bot.get_chat.return_value = {
@@ -125,9 +127,11 @@ def test_shows_stats_for_existing_user(self):
call_text = ctx.bot.send_message.call_args[0][1]
assert "Test Group" in call_text
assert "@testgroup" in call_text
- assert "10 points" in call_text
- assert "3 days" in call_text
- assert "#2" in call_text
+ assert "4 pts" in call_text # week_score
+ assert "10 pts" in call_text # total_score (all-time)
+ assert "Season wins: 1" in call_text
+ assert "3 days" in call_text # streak
+ assert "#2" in call_text # rank
def test_shows_no_data_for_new_user(self):
quiz_repo = MagicMock()
@@ -138,4 +142,4 @@ def test_shows_no_data_for_new_user(self):
ctx.bot.send_message.assert_called_once()
call_text = ctx.bot.send_message.call_args[0][1]
- assert "haven't answered" in call_text
+ assert "No quiz score yet" in call_text
diff --git a/tests/test_zerde_common_logging_utils.py b/tests/test_zerde_common_logging_utils.py
new file mode 100644
index 0000000..e3cd892
--- /dev/null
+++ b/tests/test_zerde_common_logging_utils.py
@@ -0,0 +1,36 @@
+"""Tests for shared logging helpers."""
+
+import json
+import os
+import sys
+
+_ROOT = os.path.join(os.path.dirname(__file__), "..")
+sys.path.insert(0, os.path.join(_ROOT, "src", "shared", "python"))
+
+from zerde_common.logging_utils import telegram_update_log_extra # noqa: E402
+
+
+def test_telegram_update_log_extra_inlines_small_update() -> None:
+ update = {
+ "update_id": 42,
+ "message": {
+ "message_id": 1,
+ "text": "/start",
+ "chat": {"id": -1001, "type": "supergroup"},
+ },
+ }
+ extra = telegram_update_log_extra(update)
+ assert extra["update_id"] == 42
+ assert extra["telegram_update"] == update
+ assert extra["telegram_update_truncated"] is False
+ assert extra["telegram_update_chars"] == len(json.dumps(update, ensure_ascii=False))
+
+
+def test_telegram_update_log_extra_truncates_huge_payload() -> None:
+ long_text = "x" * 500_000
+ update = {"update_id": 99, "message": {"text": long_text}}
+ extra = telegram_update_log_extra(update, max_json_chars=10_000)
+ assert extra["update_id"] == 99
+ assert extra["telegram_update_truncated"] is True
+ assert "telegram_update_json" in extra
+ assert len(extra["telegram_update_json"]) <= 10_000