Skip to content
2 changes: 1 addition & 1 deletion infra/components/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion infra/components/messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 1 addition & 5 deletions src/bot/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────
Expand Down
69 changes: 39 additions & 30 deletions src/bot/core/translations.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,13 @@
"quizstats_response": (
"🧠 <b>Your Quiz Stats</b>\n"
"📍 <b>{chat_title}</b>\n\n"
"🏆 Score: <b>{score}</b> points\n"
"🔥 Streak: <b>{streak}</b> days\n"
"⭐ Best streak: <b>{best_streak}</b> days\n"
"📊 Rank: <b>#{rank}</b> / {total_players} players"
"🗓 This week: <b>{week_score} pts</b> · Rank <b>#{rank}</b> / {total_players} players\n"
"🎖 Season wins: <b>{season_wins}</b>\n"
"──────────────\n"
"⭐ All-time: <b>{total_score} pts</b>\n"
"🔥 Streak: <b>{streak}</b> days current · <b>{best_streak}</b> 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."
Expand Down Expand Up @@ -144,8 +145,9 @@
"genquiz_lambda_not_configured": "❌ Quiz Lambda is not configured.",
"genquiz_usage": (
"❌ Usage: /genquiz &lt;topic&gt; [&lt;difficulty&gt; [&lt;lang&gt;]]\n"
"Order: topic → difficulty → language. "
"Defaults: difficulty <code>medium</code>, language from this chat (<code>CHAT_LANG_MAP</code>)."
"Order: topic → difficulty → language.\n"
"Difficulties: <code>easy</code>, <code>medium</code>, <code>hard</code>, <code>expert</code>.\n"
"Defaults: difficulty <code>medium</code>, language from this group's default."
),
"genquiz_invalid_lang": "❌ Invalid lang. Choose from: {langs}",
"genquiz_invalid_difficulty": "❌ Invalid difficulty. Choose from: {difficulties}",
Expand Down Expand Up @@ -262,19 +264,21 @@
"voteban_forgiven": (
"💚 <b>Бұғаттаудан бас тартылды</b>\n\n"
"🎯 {TARGET} {VOTES_AGAINST} дауыспен ақталды.\n\n"
"👼 Ақтап шыққандар (қарсы дауыс бергендер): {VOTERS_AGAINST}"
"👼 Ақтап шыққандар: {VOTERS_AGAINST}"
),
"quizstats_response": (
"🧠 <b>Сіздің Quiz бойынша нәтижеңіз:</b>\n"
"🧠 <b>Сіздің Quiz статистикаңыз</b>\n"
"📍 <b>{chat_title}</b>\n\n"
"🏆 Жалпы ұпай: <b>{score}</b>\n"
"🔥 Үздіксіз streak: <b>{streak}</b> күн\n"
"⭐ Ең ұзақ streak: <b>{best_streak}</b> күн\n"
"📊 Рейтингтегі орныңыз: <b>#{rank}</b> / {total_players} қатысушы"
"🗓 Осы аптада: <b>{week_score} ұпай</b> · Рейтинг <b>#{rank}</b> / {total_players} қатысушы\n"
"🎖 Маусымдағы жеңістер: <b>{season_wins}</b>\n"
"──────────────\n"
"⭐ Барлық уақыт бойынша: <b>{total_score} ұпай</b>\n"
"🔥 Серия (Streak): қазір <b>{streak}</b> күн · рекорд <b>{best_streak}</b> күн"
),
"quizstats_no_data": "🧠 Сіз әлі ешқандай Quiz сұрағына жауап бермепсіз. Келесі сұрақты жіберіп алмаңыз!",
"quizstats_no_data": "🧠 Сіздің ұпайыңыз әлі жоқ — ертеңгі күнделікті сұраққа жауап беріп, рейтингке кіріңіз!",
"quizstats_open_private_chat": (
"📬 Сізге жеке хабарлама жібере алмадым.\n" "Алдымен менімен жеке чат ашып, /start пәрменін жіберіңіз"
"📬 Сізге жеке хабарлама жібере алмадым.\n"
"Алдымен менімен жеке чат ашып, /start пәрменін жіберіңіз, содан соң /quizstats қайта көріңіз."
),
"quiz_not_configured": "⚙️ Quiz бұл бот үшін бапталмаған.",
"wtf_usage": (
Expand Down Expand Up @@ -312,8 +316,9 @@
"genquiz_lambda_not_configured": "❌ Quiz Lambda бапталмаған.",
"genquiz_usage": (
"❌ Қолданылуы: /genquiz &lt;тақырып&gt; [&lt;деңгей&gt; [&lt;тіл&gt;]]\n"
"Реті: тақырып → деңгей → тіл. "
"Әдепкі: деңгей <code>medium</code>, тіл чаттан (<code>CHAT_LANG_MAP</code>)."
"Реті: тақырып → деңгей → тіл.\n"
"Деңгейлер: <code>easy</code>, <code>medium</code>, <code>hard</code>, <code>expert</code>.\n"
"Әдепкі: деңгей <code>medium</code>, тіл осы топтың негізгі тілі бойынша."
),
"genquiz_invalid_lang": "❌ Тіл қате. Келесілерді таңдаңыз: {langs}",
"genquiz_invalid_difficulty": "❌ Деңгей қате. Келесілерді таңдаңыз: {difficulties}",
Expand Down Expand Up @@ -428,12 +433,13 @@
"quizstats_response": (
"🧠 <b>你的 Quiz 统计</b>\n"
"📍 <b>{chat_title}</b>\n\n"
"🏆 积分:<b>{score}</b>\n"
"🔥 连胜:<b>{streak}</b> 天\n"
"⭐ 最佳连胜:<b>{best_streak}</b> 天\n"
"📊 排名:<b>#{rank}</b> / {total_players} 人"
"🗓 本周:<b>{week_score} 分</b> · 排名 <b>#{rank}</b> / {total_players} 人\n"
"🎖 赛季冠军次数:<b>{season_wins}</b>\n"
"──────────────\n"
"⭐ 历史总分:<b>{total_score} 分</b>\n"
"🔥 连胜:当前 <b>{streak}</b> 天 · 最佳 <b>{best_streak}</b> 天"
),
"quizstats_no_data": "🧠 你还没有答过 Quiz 题目。请等待下一次每日测验!",
"quizstats_no_data": "🧠 暂无积分记录 —— 明天参加每日测验即可上榜!",
"quizstats_open_private_chat": (
"📬 我无法给你发送私信。\n" "请先打开与我的私聊并发送 /start,然后再试一次 /quizstats。"
),
Expand Down Expand Up @@ -467,7 +473,8 @@
"genquiz_usage": (
"❌ 用法:/genquiz &lt;主题&gt; [&lt;难度&gt; [&lt;语言&gt;]]\n"
"顺序:主题 → 难度 → 语言。\n"
"默认:难度 <code>medium</code>,语言为本群设置(<code>CHAT_LANG_MAP</code>)。"
"可选难度:<code>easy</code>, <code>medium</code>, <code>hard</code>, <code>expert</code>。\n"
"默认:难度 <code>medium</code>,语言为当前群组的默认语言。"
),
"genquiz_invalid_lang": "❌ 语言无效。可选:{langs}",
"genquiz_invalid_difficulty": "❌ 难度无效。可选:{difficulties}",
Expand Down Expand Up @@ -582,12 +589,13 @@
"quizstats_response": (
"🧠 <b>Ваша статистика Quiz</b>\n"
"📍 <b>{chat_title}</b>\n\n"
"🏆 Очки: <b>{score}</b>\n"
"🔥 Серия: <b>{streak}</b> дней\n"
"⭐ Лучшая серия: <b>{best_streak}</b> дней\n"
"📊 Ранг: <b>#{rank}</b> / {total_players} игроков"
"🗓 На этой неделе: <b>{week_score} очк.</b> · Ранг <b>#{rank}</b> / {total_players} игроков\n"
"🎖 Побед в сезоне: <b>{season_wins}</b>\n"
"──────────────\n"
"⭐ За всё время: <b>{total_score} очк.</b>\n"
"🔥 Серия: <b>{streak}</b> дн. сейчас · <b>{best_streak}</b> дн. рекорд"
),
"quizstats_no_data": "🧠 Вы еще не отвечали на Quiz. Дождитесь следующего ежедневного вопроса!",
"quizstats_no_data": "🧠 Очков пока нет — ответьте на завтрашний ежедневный вопрос и попадите в рейтинг!",
"quizstats_open_private_chat": (
"📬 Я не смог отправить вам личное сообщение.\n"
"Сначала откройте со мной личный чат и отправьте /start, затем попробуйте /quizstats снова."
Expand Down Expand Up @@ -628,8 +636,9 @@
"genquiz_lambda_not_configured": "❌ Quiz Lambda не настроена.",
"genquiz_usage": (
"❌ Использование: /genquiz &lt;тема&gt; [&lt;сложность&gt; [&lt;язык&gt;]]\n"
"Порядок: тема → сложность → язык. "
"По умолчанию: сложность <code>medium</code>, язык чата (<code>CHAT_LANG_MAP</code>)."
"Порядок: тема → сложность → язык.\n"
"Сложности: <code>easy</code>, <code>medium</code>, <code>hard</code>, <code>expert</code>.\n"
"По умолчанию: сложность <code>medium</code>, язык по умолчанию для этой группы."
),
"genquiz_invalid_lang": "❌ Неверный язык. Выберите из: {langs}",
"genquiz_invalid_difficulty": "❌ Неверная сложность. Выберите из: {difficulties}",
Expand Down
41 changes: 20 additions & 21 deletions src/bot/services/ai/gemini_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand All @@ -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:
Expand All @@ -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:
Expand Down
8 changes: 1 addition & 7 deletions src/bot/services/handlers/commands.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 <topic>`` [, ``<difficulty>`` [, ``<lang>``]] — 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)
Expand Down Expand Up @@ -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,
},
)
Expand Down
6 changes: 2 additions & 4 deletions src/bot/services/handlers/explain_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading