SilverElixir commited on
Commit
220dbde
·
1 Parent(s): 5b278f2

P0 batch 1: JPEG/GIF fixes, webhook 503, allowed_updates, LICENSE/SECURITY (AUD-E-001, AUD-E-002, AUD-E-003, AUD-J-001, AUD-O-001, AUD-D-007)

Browse files
Files changed (8) hide show
  1. .gitignore +2 -0
  2. LICENSE +21 -0
  3. SECURITY.md +10 -0
  4. bot.py +8 -3
  5. lumen_images.py +1 -1
  6. lumen_media.py +3 -3
  7. test_bot.py +16 -3
  8. test_lumen_images.py +28 -0
.gitignore CHANGED
@@ -19,6 +19,8 @@ chat_state.json
19
  chat_state.tmp
20
  global_quota.json
21
  global_quota.tmp
 
 
22
 
23
  # ОС/редакторы
24
  .DS_Store
 
19
  chat_state.tmp
20
  global_quota.json
21
  global_quota.tmp
22
+ # Выгрузка state-backup.yml: полные истории чатов — никогда в коммит (AUD-D-007)
23
+ export_state.json
24
 
25
  # ОС/редакторы
26
  .DS_Store
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 S1lverElixir
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
SECURITY.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Policy
2
+
3
+ Нашли уязвимость — напишите владельцу через GitHub Issues (приватно, без деталей
4
+ эксплуатации в публичном тексте) или в личных сообщениях Telegram-бота.
5
+
6
+ Что полезно прислать: где (файл и строка), что может сделать атакующий,
7
+ как воспроизвести на тестовом окружении. Не присылайте боевые ключи и токены.
8
+
9
+ Секреты в `.env` и переменные окружения никогда не коммитятся (см. `.gitignore`).
10
+ Если ключ утек — скажите об этом прямо, ключ будет перевыпущен.
bot.py CHANGED
@@ -55,6 +55,7 @@ from aiogram.types import (
55
  Update,
56
  )
57
  from fastapi import FastAPI, Request
 
58
  from google import genai
59
  from google.genai import types
60
 
@@ -1256,7 +1257,7 @@ async def get_webhook_url(request: Request) -> dict[str, str]:
1256
  }
1257
 
1258
  @app.post("/webhook")
1259
- async def webhook_handler(request: Request) -> dict[str, bool]:
1260
  token = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "")
1261
  if not hmac.compare_digest(token, WEBHOOK_SECRET):
1262
  log.warning("[webhook] Rejected request with invalid secret token")
@@ -1266,7 +1267,9 @@ async def webhook_handler(request: Request) -> dict[str, bool]:
1266
  if bot is not None:
1267
  _track_inflight_task(asyncio.create_task(_process_raw_update(body)))
1268
  else:
1269
- log.warning("[webhook] Bot not initialized yet, dropping update")
 
 
1270
  except Exception as exc:
1271
  log.warning("[webhook] Failed to process incoming update: %s", exc)
1272
  return {"ok": True}
@@ -1349,7 +1352,9 @@ async def export_state(request: Request) -> dict[str, Any]:
1349
  "global_quota": GLOBAL_QUOTA,
1350
  }
1351
 
1352
- ALLOWED_UPDATES = ["message", "edited_message", "callback_query", "guest_message"]
 
 
1353
 
1354
  # хранение состояния и квот
1355
 
 
55
  Update,
56
  )
57
  from fastapi import FastAPI, Request
58
+ from fastapi.responses import JSONResponse
59
  from google import genai
60
  from google.genai import types
61
 
 
1257
  }
1258
 
1259
  @app.post("/webhook")
1260
+ async def webhook_handler(request: Request) -> Any:
1261
  token = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "")
1262
  if not hmac.compare_digest(token, WEBHOOK_SECRET):
1263
  log.warning("[webhook] Rejected request with invalid secret token")
 
1267
  if bot is not None:
1268
  _track_inflight_task(asyncio.create_task(_process_raw_update(body)))
1269
  else:
1270
+ # 503 вместо 200: пусть Telegram повторит апдейт, а не считает дроп успехом (AUD-E-003).
1271
+ log.warning("[webhook] Bot not initialized yet, asking Telegram to retry")
1272
+ return JSONResponse(status_code=503, content={"ok": False, "retry": True})
1273
  except Exception as exc:
1274
  log.warning("[webhook] Failed to process incoming update: %s", exc)
1275
  return {"ok": True}
 
1352
  "global_quota": GLOBAL_QUOTA,
1353
  }
1354
 
1355
+ # Только настоящие типы Update из Bot API: "guest_message" здесь был бы ошибкой
1356
+ # (Telegram ответил бы 400 и бот замолчал бы) — гости идут через answerGuestQuery, а не подписку (AUD-J-001).
1357
+ ALLOWED_UPDATES = ["message", "edited_message", "callback_query"]
1358
 
1359
  # хранение состояния и квот
1360
 
lumen_images.py CHANGED
@@ -120,7 +120,7 @@ async def _pollinations_generate(session: aiohttp.ClientSession, model_name: str
120
  if resp.status == 200:
121
  ctype = (resp.headers.get("Content-Type") or "").lower()
122
  body = await resp.read()
123
- if body and (ctype.startswith("image/") or body[:4] in (b"\x89PNG", b"\xff\xd8\xff", b"RIFF", b"GIF8")):
124
  return body
125
  raise RuntimeError(f"Pollinations вернул не-изображение: {ctype}")
126
  raise RuntimeError(f"Pollinations.ai HTTP {resp.status}")
 
120
  if resp.status == 200:
121
  ctype = (resp.headers.get("Content-Type") or "").lower()
122
  body = await resp.read()
123
+ if body and (ctype.startswith("image/") or body.startswith((b"\x89PNG", b"\xff\xd8\xff", b"RIFF", b"GIF8"))):
124
  return body
125
  raise RuntimeError(f"Pollinations вернул не-изображение: {ctype}")
126
  raise RuntimeError(f"Pollinations.ai HTTP {resp.status}")
lumen_media.py CHANGED
@@ -160,12 +160,12 @@ def _ensure_prompt_text(text: str | None, mime: str) -> str:
160
  if s:
161
  return s
162
  m = mime.lower()
 
 
163
  if m.startswith("image/"):
164
- return "Подробно опиши, что изображено на картинке."
165
  if m.startswith("video/") or m == "video/quicktime":
166
  return "Подробно опиши происходящее на этом видео."
167
  if m.startswith("audio/") or m == "audio/ogg" or m == "audio/mpeg" or m == "audio/mp3" or "voice" in m:
168
  return "Прослушай и подробно опиши, что на этой аудиозаписи, или кратко перескажи ее содержание."
169
- if m == "image/gif" or "animation" in m:
170
- return "Подробно опиши происходящее на этой анимации."
171
  return "Проанализируй и подробно опиши содержимое этого вложения."
 
160
  if s:
161
  return s
162
  m = mime.lower()
163
+ if m == "image/gif" or "animation" in m:
164
+ return "Подробно опиши происходящее на этой анимации."
165
  if m.startswith("image/"):
166
+ return "Подробно опиши, что изображено на картинке."
167
  if m.startswith("video/") or m == "video/quicktime":
168
  return "Подробно опиши происходящее на этом видео."
169
  if m.startswith("audio/") or m == "audio/ogg" or m == "audio/mpeg" or m == "audio/mp3" or "voice" in m:
170
  return "Прослушай и подробно опиши, что на этой аудиозаписи, или кратко перескажи ее содержание."
 
 
171
  return "Проанализируй и подробно опиши содержимое этого вложения."
test_bot.py CHANGED
@@ -136,6 +136,12 @@ def test_sanitize_mime_type_audio_ogg_passthrough():
136
  assert bot._sanitize_mime_type(None, "audio/ogg") == "audio/ogg"
137
 
138
 
 
 
 
 
 
 
139
  # ─────────────────────────── is_tiktok / is_youtube ───────────────────────────
140
 
141
  def test_is_tiktok_true_for_tiktok_url():
@@ -3845,8 +3851,8 @@ def test_webhook_handler_rejects_invalid_or_missing_secret_and_does_not_dispatch
3845
 
3846
  def test_webhook_handler_drops_update_when_bot_not_yet_initialized():
3847
  # Апдейт может прийти раньше, чем main() успеет создать глобальный bot (Bot/
3848
- # genai.Client создаются уже после старта uvicorn) — должен тихо отбрасываться,
3849
- # а не падать, и не пытаться диспатчить апдейт в ещё не готового бота.
3850
  original_secret = bot.WEBHOOK_SECRET
3851
  original_bot_obj = bot.bot
3852
  original_process = bot._process_raw_update
@@ -3864,7 +3870,7 @@ def test_webhook_handler_drops_update_when_bot_not_yet_initialized():
3864
  body={"update_id": 1},
3865
  )
3866
  result = asyncio.run(_run_webhook_handler(req))
3867
- assert result == {"ok": True}
3868
  assert calls == []
3869
  finally:
3870
  bot.WEBHOOK_SECRET = original_secret
@@ -3872,6 +3878,13 @@ def test_webhook_handler_drops_update_when_bot_not_yet_initialized():
3872
  bot._process_raw_update = original_process
3873
 
3874
 
 
 
 
 
 
 
 
3875
  # ─────────────────── ADMIN_SECRET_SEED — независимая ротация секретов ───────────────────
3876
 
3877
  def test_admin_secrets_are_independent_of_bot_token_when_seed_set():
 
136
  assert bot._sanitize_mime_type(None, "audio/ogg") == "audio/ogg"
137
 
138
 
139
+ def test_ensure_prompt_text_gif_gets_animation_prompt_not_generic_image():
140
+ assert "анимации" in bot._ensure_prompt_text(None, "image/gif")
141
+ assert "анимации" in bot._ensure_prompt_text(" ", "image/GIF")
142
+ assert "картинке" in bot._ensure_prompt_text(None, "image/jpeg")
143
+
144
+
145
  # ─────────────────────────── is_tiktok / is_youtube ───────────────────────────
146
 
147
  def test_is_tiktok_true_for_tiktok_url():
 
3851
 
3852
  def test_webhook_handler_drops_update_when_bot_not_yet_initialized():
3853
  # Апдейт может прийти раньше, чем main() успеет создать глобальный bot (Bot/
3854
+ # genai.Client создаются уже после старта uvicorn) — отвечаем 503, чтобы
3855
+ # Telegram повторил апдейт, а не считаем дроп успехом (AUD-E-003).
3856
  original_secret = bot.WEBHOOK_SECRET
3857
  original_bot_obj = bot.bot
3858
  original_process = bot._process_raw_update
 
3870
  body={"update_id": 1},
3871
  )
3872
  result = asyncio.run(_run_webhook_handler(req))
3873
+ assert result.status_code == 503
3874
  assert calls == []
3875
  finally:
3876
  bot.WEBHOOK_SECRET = original_secret
 
3878
  bot._process_raw_update = original_process
3879
 
3880
 
3881
+ def test_allowed_updates_contains_only_real_telegram_types():
3882
+ # Регрессия AUD-J-001: "guest_message" — не тип Update из Bot API, из-за него
3883
+ # setWebhook мог ответить 400 и бот замолчал бы. Гости идут через answerGuestQuery.
3884
+ assert "guest_message" not in bot.ALLOWED_UPDATES
3885
+ assert "message" in bot.ALLOWED_UPDATES
3886
+
3887
+
3888
  # ─────────────────── ADMIN_SECRET_SEED — независимая ротация секретов ───────────────────
3889
 
3890
  def test_admin_secrets_are_independent_of_bot_token_when_seed_set():
test_lumen_images.py CHANGED
@@ -10,6 +10,8 @@ lumen_images.py не зависит от Telegram/рантайм-состоян
10
  Запуск:
11
  pytest test_lumen_images.py -v
12
  """
 
 
13
  import lumen_images
14
 
15
 
@@ -66,3 +68,29 @@ def test_pick_image_model_result_always_a_known_model():
66
  ]
67
  for p in prompts:
68
  assert lumen_images._pick_image_model(p) in lumen_images.POLLINATIONS_IMAGE_MODELS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  Запуск:
11
  pytest test_lumen_images.py -v
12
  """
13
+ import asyncio
14
+
15
  import lumen_images
16
 
17
 
 
68
  ]
69
  for p in prompts:
70
  assert lumen_images._pick_image_model(p) in lumen_images.POLLINATIONS_IMAGE_MODELS
71
+
72
+
73
+ # ─────────────────────────── _pollinations_generate ───────────────────────────
74
+
75
+ def test_pollinations_generate_accepts_jpeg_by_magic_bytes():
76
+ # Регрессия AUD-E-001: body[:4] никогда не равен 3-байтному b"\xff\xd8\xff",
77
+ # поэтому JPEG без image/* Content-Type отвергался как "не-изображение".
78
+ class FakeResp:
79
+ status = 200
80
+ headers = {"Content-Type": "application/octet-stream"}
81
+
82
+ async def read(self):
83
+ return b"\xff\xd8\xff\xe0" + b"\x00" * 100
84
+
85
+ async def __aenter__(self):
86
+ return self
87
+
88
+ async def __aexit__(self, *exc):
89
+ return False
90
+
91
+ class FakeSession:
92
+ def get(self, url, timeout=None):
93
+ return FakeResp()
94
+
95
+ body = asyncio.run(lumen_images._pollinations_generate(FakeSession(), "flux", "кот"))
96
+ assert body[:3] == b"\xff\xd8\xff"