Spaces:
Running
Running
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- .gitignore +2 -0
- LICENSE +21 -0
- SECURITY.md +10 -0
- bot.py +8 -3
- lumen_images.py +1 -1
- lumen_media.py +3 -3
- test_bot.py +16 -3
- 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) ->
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 ==
|
| 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"
|