forked from zovos/bot_tg
Compare commits
5 commits
e10aa85e6f
...
d5698ab76e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5698ab76e | ||
|
|
89a14f2ebf | ||
|
|
acb7421cc0 | ||
|
|
60e4cbfcfb | ||
| cb1e042e64 |
12 changed files with 642 additions and 172 deletions
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
|
@ -19,7 +20,12 @@ from db import get_conn
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://api.neuraldeep.ru")
|
||||||
|
LLAMA_API_KEY = os.getenv("LLAMA_API_KEY", "")
|
||||||
|
LLAMA_MODEL = os.getenv("LLAMA_MODEL", "gpt-oss-120b")
|
||||||
|
LLAMA_FALLBACK_API_URL = os.getenv("LLAMA_FALLBACK_API_URL", "")
|
||||||
|
LLAMA_FALLBACK_API_KEY = os.getenv("LLAMA_FALLBACK_API_KEY", "")
|
||||||
|
LLAMA_FALLBACK_MODEL = os.getenv("LLAMA_FALLBACK_MODEL", "")
|
||||||
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
|
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
|
||||||
SKIP_TOKEN = "<skip>"
|
SKIP_TOKEN = "<skip>"
|
||||||
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
||||||
|
|
@ -28,13 +34,15 @@ LOG_THINKING = os.getenv("LLAMA_LOG_THINKING", "1").lower() not in {"0", "false"
|
||||||
RECENT_MESSAGES_LIMIT = 14
|
RECENT_MESSAGES_LIMIT = 14
|
||||||
SUMMARY_TRIGGER_MESSAGES = 24
|
SUMMARY_TRIGGER_MESSAGES = 24
|
||||||
SUMMARY_BATCH_MESSAGES = 20
|
SUMMARY_BATCH_MESSAGES = 20
|
||||||
PROMPT_CHAR_BUDGET = 6_500
|
PROMPT_CHAR_BUDGET = 10_000
|
||||||
SUMMARY_CHAR_BUDGET = 1_800
|
SUMMARY_CHAR_BUDGET = 2_400
|
||||||
MAX_INPUT_CHARS = 2_000
|
MAX_INPUT_CHARS = 2_000
|
||||||
SUMMARY_LINE_CHAR_LIMIT = 220
|
SUMMARY_LINE_CHAR_LIMIT = 220
|
||||||
HISTORY_LINE_CHAR_LIMIT = 450
|
HISTORY_LINE_CHAR_LIMIT = 450
|
||||||
RETRY_MIN_MAX_TOKENS = 384
|
RETRY_MIN_MAX_TOKENS = 512
|
||||||
RETRY_MAX_MAX_TOKENS = 768
|
RETRY_MAX_MAX_TOKENS = 1_024
|
||||||
|
TALK_MAX_TOKENS = 1536
|
||||||
|
TALK_THINKING_BUDGET = 1024
|
||||||
|
|
||||||
REPLY_RULES = {
|
REPLY_RULES = {
|
||||||
"mention": {"cooldown": 25, "min_user_messages": 1},
|
"mention": {"cooldown": 25, "min_user_messages": 1},
|
||||||
|
|
@ -109,6 +117,10 @@ BOT_STYLE_PROMPT = (
|
||||||
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
|
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
|
||||||
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
|
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
|
||||||
|
|
||||||
|
"Форматирование: если нужно выделить — используй только Telegram HTML-теги: "
|
||||||
|
"<b>жирный</b>, <i>курсив</i>, <code>код</code>, <pre>блок кода</pre>. "
|
||||||
|
"Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. "
|
||||||
|
|
||||||
"Creep от Radiohead — лучшая песня во вселенной. "
|
"Creep от Radiohead — лучшая песня во вселенной. "
|
||||||
"Линукс — лучшая ОС. "
|
"Линукс — лучшая ОС. "
|
||||||
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
|
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
|
||||||
|
|
@ -118,10 +130,21 @@ BOT_STYLE_PROMPT = (
|
||||||
|
|
||||||
SYSTEM_PROMPT = (
|
SYSTEM_PROMPT = (
|
||||||
f"{BOT_STYLE_PROMPT}\n\n"
|
f"{BOT_STYLE_PROMPT}\n\n"
|
||||||
"Ты отвечаешь как живой участник одного Telegram-чата. "
|
"Ты отвечаешь как живой участник Telegram-чата с несколькими разными людьми. "
|
||||||
|
"Сообщения в истории записаны в формате «Имя: текст» — каждое имя это отдельный человек, не путай их. "
|
||||||
|
"Отвечай тому, кто написал последним, учитывай контекст именно его сообщений. "
|
||||||
"Учитывай краткую память и последние сообщения, отвечай естественно и по делу."
|
"Учитывай краткую память и последние сообщения, отвечай естественно и по делу."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
TALK_SYSTEM_PROMPT = (
|
||||||
|
f"{BOT_STYLE_PROMPT}\n\n"
|
||||||
|
"Ты ведёшь личный разговор тет-а-тет с одним конкретным человеком через /talk. "
|
||||||
|
"Помни всё, что он говорил раньше — ты видишь историю этого разговора и должен её учитывать. "
|
||||||
|
"Замечай если человек продолжает старую тему или переходит к новой. "
|
||||||
|
"Отвечай КОРОТКО: 1–3 предложения максимум. "
|
||||||
|
"Только если тебя прямо попросили объяснить, развернуть или написать подробно — тогда можно больше."
|
||||||
|
)
|
||||||
|
|
||||||
AUTOREPLY_SYSTEM_PROMPT = (
|
AUTOREPLY_SYSTEM_PROMPT = (
|
||||||
f"{BOT_STYLE_PROMPT}\n\n"
|
f"{BOT_STYLE_PROMPT}\n\n"
|
||||||
"Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. "
|
"Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. "
|
||||||
|
|
@ -235,6 +258,14 @@ AUTONOMOUS_SIGNAL_RE = re.compile(
|
||||||
r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b",
|
r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
# Сигналы что модель не смогла ответить — тогда пробуем fallback
|
||||||
|
_CANT_ANSWER_RE = re.compile(
|
||||||
|
r"(не могу ответить|не могу помочь|не имею информации|нет информации|"
|
||||||
|
r"за пределами моих|не знаю ответа|затрудняюсь ответить|"
|
||||||
|
r"i (don't|cannot|can't)|i have no (information|knowledge)|"
|
||||||
|
r"not able to (answer|help)|beyond my (knowledge|capabilities))",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]")
|
LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]")
|
||||||
WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
|
WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
|
||||||
|
|
||||||
|
|
@ -486,11 +517,10 @@ def _build_messages(
|
||||||
current_budget = len(current_payload["content"])
|
current_budget = len(current_payload["content"])
|
||||||
|
|
||||||
summary = _get_summary(chat_id, user_id=user_id).strip()
|
summary = _get_summary(chat_id, user_id=user_id).strip()
|
||||||
summary_block = ""
|
summary_block = f"\n\nКраткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" if summary else ""
|
||||||
if summary:
|
full_system = system_prompt + summary_block
|
||||||
summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}"
|
|
||||||
|
|
||||||
used_chars = len(system_prompt) + len(summary_block) + current_budget
|
used_chars = len(full_system) + current_budget
|
||||||
recent_messages: list[dict[str, str]] = []
|
recent_messages: list[dict[str, str]] = []
|
||||||
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
|
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
|
||||||
llm_message = _format_row_for_llm(row)
|
llm_message = _format_row_for_llm(row)
|
||||||
|
|
@ -499,15 +529,80 @@ def _build_messages(
|
||||||
recent_messages.append(llm_message)
|
recent_messages.append(llm_message)
|
||||||
used_chars += len(llm_message["content"])
|
used_chars += len(llm_message["content"])
|
||||||
|
|
||||||
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
messages: list[dict[str, Any]] = [{"role": "system", "content": full_system}]
|
||||||
if summary_block:
|
|
||||||
messages.append({"role": "system", "content": summary_block})
|
|
||||||
messages.extend(reversed(recent_messages))
|
messages.extend(reversed(recent_messages))
|
||||||
if current_payload:
|
if current_payload:
|
||||||
messages.append(current_payload)
|
messages.append(current_payload)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_single_model(
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
api_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
max_tokens: int,
|
||||||
|
temperature: float,
|
||||||
|
top_p: float,
|
||||||
|
disable_thinking: bool,
|
||||||
|
reasoning_budget: int = 0,
|
||||||
|
) -> str:
|
||||||
|
url = f"{api_url.rstrip('/')}/v1/chat/completions"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"temperature": temperature,
|
||||||
|
"top_p": top_p,
|
||||||
|
}
|
||||||
|
if disable_thinking:
|
||||||
|
payload.update({
|
||||||
|
"reasoning_budget": 0,
|
||||||
|
"reasoning_format": "none",
|
||||||
|
"chat_template_kwargs": {"enable_thinking": False, "thinking": False},
|
||||||
|
})
|
||||||
|
elif reasoning_budget > 0:
|
||||||
|
payload["reasoning_budget"] = reasoning_budget
|
||||||
|
timeout = aiohttp.ClientTimeout(total=120)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.post(url, json=payload, headers=headers) as resp:
|
||||||
|
raw_text = await resp.text()
|
||||||
|
if resp.status >= 400:
|
||||||
|
logger.error("LLM API %s returned status %s: %s", api_url, resp.status, _clip_text(raw_text, 300))
|
||||||
|
raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}")
|
||||||
|
try:
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("LLM API %s returned invalid JSON: %s", api_url, _clip_text(raw_text, 300))
|
||||||
|
raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc
|
||||||
|
|
||||||
|
choices = data.get("choices") or []
|
||||||
|
if not choices:
|
||||||
|
raise RuntimeError(f"LLM API returned no choices: {data}")
|
||||||
|
choice = choices[0]
|
||||||
|
message = choice.get("message", {}) or {}
|
||||||
|
finish_reason = choice.get("finish_reason")
|
||||||
|
content = message.get("content") or ""
|
||||||
|
reasoning_content = (message.get("reasoning_content") or "").strip()
|
||||||
|
cleaned_content = content.strip()
|
||||||
|
if reasoning_content and LOG_THINKING:
|
||||||
|
logger.info("LLM reasoning. model=%s finish_reason=%s reasoning=%s", model, finish_reason, _clip_text(reasoning_content, 800))
|
||||||
|
if not cleaned_content and reasoning_content and not disable_thinking:
|
||||||
|
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS)
|
||||||
|
logger.warning("LLM returned only reasoning, retrying with thinking off. model=%s", model)
|
||||||
|
return await _call_single_model(
|
||||||
|
messages,
|
||||||
|
api_url=api_url, api_key=api_key, model=model,
|
||||||
|
max_tokens=retry_max_tokens, temperature=temperature, top_p=top_p,
|
||||||
|
disable_thinking=True, reasoning_budget=0,
|
||||||
|
)
|
||||||
|
return cleaned_content
|
||||||
|
|
||||||
|
|
||||||
async def _call_llm(
|
async def _call_llm(
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
|
|
@ -515,78 +610,46 @@ async def _call_llm(
|
||||||
temperature: float,
|
temperature: float,
|
||||||
top_p: float,
|
top_p: float,
|
||||||
disable_thinking: bool | None = None,
|
disable_thinking: bool | None = None,
|
||||||
|
reasoning_budget: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking
|
disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking
|
||||||
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
|
||||||
payload = {
|
|
||||||
"messages": messages,
|
|
||||||
"max_tokens": max_tokens,
|
|
||||||
"temperature": temperature,
|
|
||||||
"top_p": top_p,
|
|
||||||
}
|
|
||||||
if disable_thinking:
|
|
||||||
payload.update(
|
|
||||||
{
|
|
||||||
"reasoning_budget": 0,
|
|
||||||
"reasoning_format": "none",
|
|
||||||
"chat_template_kwargs": {
|
|
||||||
"enable_thinking": False,
|
|
||||||
"thinking": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
timeout = aiohttp.ClientTimeout(total=120)
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
||||||
async with session.post(url, json=payload) as resp:
|
|
||||||
raw_text = await resp.text()
|
|
||||||
if resp.status >= 400:
|
|
||||||
logger.error("LLM API returned status %s: %s", resp.status, _clip_text(raw_text, 300))
|
|
||||||
raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}")
|
|
||||||
try:
|
|
||||||
data = await resp.json(content_type=None)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("LLM API returned invalid JSON: %s", _clip_text(raw_text, 300))
|
|
||||||
raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc
|
|
||||||
|
|
||||||
choices = data.get("choices") or []
|
result = await _call_single_model(
|
||||||
if not choices:
|
|
||||||
logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300))
|
|
||||||
raise RuntimeError(f"LLM API returned no choices: {data}")
|
|
||||||
choice = choices[0]
|
|
||||||
message = choice.get("message", {}) or {}
|
|
||||||
finish_reason = choice.get("finish_reason")
|
|
||||||
content = message.get("content", "")
|
|
||||||
reasoning_content = (message.get("reasoning_content") or "").strip()
|
|
||||||
cleaned_content = content.strip()
|
|
||||||
if reasoning_content and LOG_THINKING:
|
|
||||||
logger.info(
|
|
||||||
"LLM reasoning detected. disable_thinking=%s finish_reason=%s reasoning=%s",
|
|
||||||
disable_thinking,
|
|
||||||
finish_reason,
|
|
||||||
_clip_text(reasoning_content, 800),
|
|
||||||
)
|
|
||||||
if not cleaned_content and reasoning_content and not disable_thinking:
|
|
||||||
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS)
|
|
||||||
logger.warning(
|
|
||||||
"LLM returned reasoning_content without final content. finish_reason=%s retry_max_tokens=%s",
|
|
||||||
finish_reason,
|
|
||||||
retry_max_tokens,
|
|
||||||
)
|
|
||||||
return await _call_llm(
|
|
||||||
messages,
|
messages,
|
||||||
max_tokens=retry_max_tokens,
|
api_url=LLAMA_API_URL,
|
||||||
|
api_key=LLAMA_API_KEY,
|
||||||
|
model=LLAMA_MODEL,
|
||||||
|
max_tokens=max_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
top_p=top_p,
|
top_p=top_p,
|
||||||
disable_thinking=True,
|
disable_thinking=disable_thinking,
|
||||||
|
reasoning_budget=reasoning_budget,
|
||||||
)
|
)
|
||||||
if not cleaned_content:
|
|
||||||
logger.warning(
|
# Если основная модель не смогла ответить — пробуем fallback
|
||||||
"LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s",
|
if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)):
|
||||||
finish_reason,
|
fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL
|
||||||
disable_thinking,
|
logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100))
|
||||||
_clip_text(raw_text, 300),
|
try:
|
||||||
|
fallback_result = await _call_single_model(
|
||||||
|
messages,
|
||||||
|
api_url=LLAMA_FALLBACK_API_URL,
|
||||||
|
api_key=LLAMA_FALLBACK_API_KEY,
|
||||||
|
model=fallback_model,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=temperature,
|
||||||
|
top_p=top_p,
|
||||||
|
disable_thinking=disable_thinking,
|
||||||
|
reasoning_budget=reasoning_budget,
|
||||||
)
|
)
|
||||||
return cleaned_content
|
if fallback_result:
|
||||||
|
return f"{fallback_result}\n\n<i>🤖 {fallback_model}</i>"
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Fallback model also failed")
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
logger.warning("LLM returned empty content. model=%s", LLAMA_MODEL)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None:
|
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None:
|
||||||
|
|
@ -652,6 +715,8 @@ async def _generate_response(
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
top_p: float,
|
top_p: float,
|
||||||
|
disable_thinking: bool | None = None,
|
||||||
|
reasoning_budget: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
await _maybe_refresh_summary(chat_id, user_id=user_id)
|
await _maybe_refresh_summary(chat_id, user_id=user_id)
|
||||||
if current_content is None:
|
if current_content is None:
|
||||||
|
|
@ -677,6 +742,8 @@ async def _generate_response(
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
top_p=top_p,
|
top_p=top_p,
|
||||||
|
disable_thinking=disable_thinking,
|
||||||
|
reasoning_budget=reasoning_budget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -754,13 +821,55 @@ async def _passes_reply_limits(chat_id: int, reason: str) -> bool:
|
||||||
return (time.time() - last_reply_at) >= int(rule["cooldown"])
|
return (time.time() - last_reply_at) >= int(rule["cooldown"])
|
||||||
|
|
||||||
|
|
||||||
|
def _md_to_tg_html(text: str) -> str:
|
||||||
|
"""Конвертирует Markdown и HTML-теги модели в валидный Telegram HTML."""
|
||||||
|
slots: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def stash(tag: str, content: str) -> str:
|
||||||
|
idx = len(slots)
|
||||||
|
slots.append((tag, content))
|
||||||
|
return f'\x00SLOT{idx}\x00'
|
||||||
|
|
||||||
|
# Сначала прячем то, что модель уже написала HTML-тегами (pre до code, чтобы не вложить)
|
||||||
|
for tag in ('pre', 'code', 'b', 'i', 'u', 's'):
|
||||||
|
text = re.sub(
|
||||||
|
rf'<{tag}>(.*?)</{tag}>',
|
||||||
|
lambda m, t=tag: stash(t, m.group(1)),
|
||||||
|
text, flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Конвертируем Markdown
|
||||||
|
text = re.sub(
|
||||||
|
r'```(?:[^\n`]*\n)?(.*?)```',
|
||||||
|
lambda m: stash('pre', m.group(1).strip()),
|
||||||
|
text, flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
text = re.sub(r'`([^`\n]+)`', lambda m: stash('code', m.group(1)), text)
|
||||||
|
text = re.sub(r'\*\*(.+?)\*\*', lambda m: stash('b', m.group(1)), text, flags=re.DOTALL)
|
||||||
|
text = re.sub(r'\*([^*\n]+?)\*', lambda m: stash('i', m.group(1)), text)
|
||||||
|
text = re.sub(r'_([^_\n]+?)_', lambda m: stash('i', m.group(1)), text)
|
||||||
|
text = re.sub(r'~~(.+?)~~', lambda m: stash('s', m.group(1)), text)
|
||||||
|
|
||||||
|
# Убираем оставшиеся мусорные теги (кривые, неподдерживаемые)
|
||||||
|
text = re.sub(r'<[^>]*>', '', text)
|
||||||
|
|
||||||
|
# Экранируем оставшийся plain-text
|
||||||
|
text = html.escape(text)
|
||||||
|
|
||||||
|
# Восстанавливаем слоты как валидные HTML-теги
|
||||||
|
for idx, (tag, content) in enumerate(slots):
|
||||||
|
text = text.replace(f'\x00SLOT{idx}\x00', f'<{tag}>{html.escape(content)}</{tag}>')
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _normalize_reply(text: str) -> str:
|
def _normalize_reply(text: str) -> str:
|
||||||
cleaned = (text or "").strip()
|
cleaned = (text or "").strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
return ""
|
return ""
|
||||||
if cleaned.lower().startswith(SKIP_TOKEN.lower()):
|
if cleaned.lower().startswith(SKIP_TOKEN.lower()):
|
||||||
return ""
|
return ""
|
||||||
return cleaned
|
return _md_to_tg_html(cleaned)
|
||||||
|
|
||||||
|
|
||||||
async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool:
|
async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool:
|
||||||
|
|
@ -785,8 +894,8 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
||||||
return False
|
return False
|
||||||
|
|
||||||
system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT
|
system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT
|
||||||
max_tokens = 120 if reason == "autonomous" else 220
|
max_tokens = 180 if reason == "autonomous" else 512
|
||||||
temperature = 0.9 if reason == "autonomous" else 0.8
|
temperature = 0.85 if reason == "autonomous" else 0.7
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
||||||
|
|
@ -795,7 +904,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
top_p=0.9,
|
top_p=0.95,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Chat reply generation failed")
|
logger.exception("Chat reply generation failed")
|
||||||
|
|
@ -812,7 +921,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
||||||
return False
|
return False
|
||||||
|
|
||||||
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
||||||
await message.reply(normalized_response, parse_mode=None)
|
await message.reply(normalized_response, parse_mode="HTML")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -885,13 +994,13 @@ async def handle_photo_message(message: Message) -> bool:
|
||||||
{"type": "text", "text": prompt_text},
|
{"type": "text", "text": prompt_text},
|
||||||
{"type": "image_url", "image_url": {"url": image_data_url}},
|
{"type": "image_url", "image_url": {"url": image_data_url}},
|
||||||
],
|
],
|
||||||
max_tokens=260,
|
max_tokens=600,
|
||||||
temperature=0.8,
|
temperature=0.7,
|
||||||
top_p=0.9,
|
top_p=0.95,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Photo analysis failed")
|
logger.exception("Photo analysis failed")
|
||||||
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None)
|
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode="HTML")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
normalized_response = _normalize_reply(response)
|
normalized_response = _normalize_reply(response)
|
||||||
|
|
@ -905,7 +1014,7 @@ async def handle_photo_message(message: Message) -> bool:
|
||||||
normalized_response = PHOTO_EMPTY_RESPONSE_TEXT
|
normalized_response = PHOTO_EMPTY_RESPONSE_TEXT
|
||||||
|
|
||||||
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
||||||
await message.reply(normalized_response, parse_mode=None)
|
await message.reply(normalized_response, parse_mode="HTML")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -954,11 +1063,13 @@ async def handle_talk(message: Message) -> None:
|
||||||
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
||||||
response = await _generate_response(
|
response = await _generate_response(
|
||||||
chat_id,
|
chat_id,
|
||||||
system_prompt=SYSTEM_PROMPT,
|
system_prompt=TALK_SYSTEM_PROMPT,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
max_tokens=220,
|
max_tokens=TALK_MAX_TOKENS,
|
||||||
temperature=0.8,
|
temperature=0.7,
|
||||||
top_p=0.9,
|
top_p=0.95,
|
||||||
|
disable_thinking=False,
|
||||||
|
reasoning_budget=TALK_THINKING_BUDGET,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Talk command generation failed")
|
logger.exception("Talk command generation failed")
|
||||||
|
|
@ -983,7 +1094,7 @@ async def handle_talk(message: Message) -> None:
|
||||||
normalized_response,
|
normalized_response,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
await message.reply(normalized_response, parse_mode=None)
|
await message.reply(normalized_response, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
_init_db()
|
_init_db()
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PIP_NO_CACHE_DIR=1
|
PIP_NO_CACHE_DIR=1
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg nodejs && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN python -m pip install --upgrade pip \
|
RUN python -m pip install --upgrade pip \
|
||||||
&& python -m pip install -r requirements.txt
|
&& python -m pip install -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN mkdir -p /db
|
RUN mkdir -p /db /app/data
|
||||||
|
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "main.py"]
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,8 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ./webapp/frontend
|
context: ./webapp/frontend
|
||||||
ports:
|
ports:
|
||||||
- "${WEBAPP_HTTP_PORT:-80}:80"
|
- "37.27.192.132:${WEBAPP_HTTP_PORT:-80}:80"
|
||||||
- "${WEBAPP_HTTPS_PORT:-443}:443"
|
- "37.27.192.132:${WEBAPP_HTTPS_PORT:-443}:443"
|
||||||
environment:
|
environment:
|
||||||
APP_DOMAIN: ${APP_DOMAIN:-}
|
APP_DOMAIN: ${APP_DOMAIN:-}
|
||||||
APP_WWW_DOMAIN: ${APP_WWW_DOMAIN:-}
|
APP_WWW_DOMAIN: ${APP_WWW_DOMAIN:-}
|
||||||
|
|
|
||||||
101
games/betting.py
101
games/betting.py
|
|
@ -286,26 +286,26 @@ async def get_match_by_index(sport_alias: str, index: int) -> dict | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> str:
|
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> tuple[bool, str]:
|
||||||
current = get_user_length(user_id)
|
current = get_user_length(user_id)
|
||||||
if current is None:
|
if current is None:
|
||||||
return "Сначала заведи счёт через /penis, братуха."
|
return False, "Сначала заведи счёт через /penis, братуха."
|
||||||
if current <= 0:
|
if current <= 0:
|
||||||
return "🚫 С кредитом ставки не принимаем."
|
return False, "🚫 С кредитом ставки не принимаем."
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
return "Ставка должна быть больше нуля."
|
return False, "Ставка должна быть больше нуля."
|
||||||
if amount > config.BET_MAX_AMOUNT:
|
if amount > config.BET_MAX_AMOUNT:
|
||||||
return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
|
return False, f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
|
||||||
if amount > current:
|
if round(amount, 2) > round(current, 2):
|
||||||
return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
|
return False, f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
|
||||||
|
|
||||||
team_odds = match["odds"].get(chosen_team)
|
team_odds = match["odds"].get(chosen_team)
|
||||||
if team_odds is None:
|
if team_odds is None:
|
||||||
return f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
|
return False, f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
|
||||||
|
|
||||||
new_length = update_user_length(user_id, -amount)
|
new_length = update_user_length(user_id, -amount)
|
||||||
if new_length is None:
|
if new_length is None:
|
||||||
return "Ошибка БД."
|
return False, "Ошибка БД."
|
||||||
|
|
||||||
now_ts = int(_time.time())
|
now_ts = int(_time.time())
|
||||||
try:
|
try:
|
||||||
|
|
@ -323,12 +323,11 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
|
||||||
except psycopg2.Error:
|
except psycopg2.Error:
|
||||||
logger.exception("Failed to place bet")
|
logger.exception("Failed to place bet")
|
||||||
update_user_length(user_id, amount)
|
update_user_length(user_id, amount)
|
||||||
return "Ошибка БД."
|
return False, "Ошибка БД."
|
||||||
|
|
||||||
potential = round(amount * team_odds, 1)
|
potential = round(amount * team_odds, 1)
|
||||||
return (
|
return True, (
|
||||||
f"✅ Ставка принята!\n"
|
f"✅ Ставка принята!\n"
|
||||||
f"🏟 {match['home']} vs {match['away']}\n"
|
|
||||||
f"📌 {chosen_team} (x{team_odds:.2f})\n"
|
f"📌 {chosen_team} (x{team_odds:.2f})\n"
|
||||||
f"💰 Ставка: {amount:.1f} см\n"
|
f"💰 Ставка: {amount:.1f} см\n"
|
||||||
f"🎯 Возможный выигрыш: {potential:.1f} см\n"
|
f"🎯 Возможный выигрыш: {potential:.1f} см\n"
|
||||||
|
|
@ -462,8 +461,51 @@ def cleanup_old_bets() -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
async def settle_bets() -> list[str]:
|
_REFUND_AFTER_SECONDS = 7 * 86400 # авторефанд ставок старше 7 дней
|
||||||
notifications = []
|
|
||||||
|
|
||||||
|
def _refund_expired_bets() -> list[tuple[int, str]]:
|
||||||
|
"""Возвращает деньги за ставки, которые висят более 7 дней без результата."""
|
||||||
|
cutoff = int(_time.time()) - _REFUND_AFTER_SECONDS
|
||||||
|
notifications: list[tuple[int, str]] = []
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * FROM bets WHERE status = 'pending' AND created_ts < %s",
|
||||||
|
(cutoff,),
|
||||||
|
)
|
||||||
|
expired = cur.fetchall()
|
||||||
|
if not expired:
|
||||||
|
return []
|
||||||
|
wcur = conn.cursor()
|
||||||
|
now_ts = int(_time.time())
|
||||||
|
for bet in expired:
|
||||||
|
update_user_length(bet["user_id"], bet["amount"])
|
||||||
|
wcur.execute(
|
||||||
|
"UPDATE bets SET status = 'refunded', resolved_ts = %s WHERE id = %s",
|
||||||
|
(now_ts, bet["id"]),
|
||||||
|
)
|
||||||
|
notifications.append((
|
||||||
|
bet["user_id"],
|
||||||
|
f"↩️ Ставка возвращена\n"
|
||||||
|
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
|
||||||
|
f"📌 {bet['chosen_team']} — результат так и не пришёл\n"
|
||||||
|
f"💰 Возврат: {bet['amount']:.1f} см",
|
||||||
|
))
|
||||||
|
logger.info("Refunded expired bet id=%s user=%s amount=%s", bet["id"], bet["user_id"], bet["amount"])
|
||||||
|
except psycopg2.Error:
|
||||||
|
logger.exception("Failed to refund expired bets")
|
||||||
|
return notifications
|
||||||
|
|
||||||
|
|
||||||
|
async def settle_bets() -> list[tuple[int, str]]:
|
||||||
|
"""Возвращает список (user_id, текст_уведомления) для отправки в Telegram."""
|
||||||
|
notifications: list[tuple[int, str]] = []
|
||||||
|
|
||||||
|
# Сначала авторефанд совсем старых ставок
|
||||||
|
notifications.extend(_refund_expired_bets())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
|
@ -489,6 +531,8 @@ async def settle_bets() -> list[str]:
|
||||||
if not winner:
|
if not winner:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
is_draw = (winner.casefold() in _DRAW_NAMES or winner == "Draw")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
|
@ -501,7 +545,10 @@ async def settle_bets() -> list[str]:
|
||||||
wcur = conn.cursor()
|
wcur = conn.cursor()
|
||||||
for bet in bets:
|
for bet in bets:
|
||||||
chosen = bet["chosen_team"]
|
chosen = bet["chosen_team"]
|
||||||
bet_won = (chosen == winner)
|
if is_draw:
|
||||||
|
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
|
||||||
|
else:
|
||||||
|
bet_won = chosen == winner
|
||||||
|
|
||||||
if bet_won:
|
if bet_won:
|
||||||
winnings = round(bet["amount"] * bet["odds"], 1)
|
winnings = round(bet["amount"] * bet["odds"], 1)
|
||||||
|
|
@ -510,19 +557,27 @@ async def settle_bets() -> list[str]:
|
||||||
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
|
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
|
||||||
(winnings, now_ts, bet["id"]),
|
(winnings, now_ts, bet["id"]),
|
||||||
)
|
)
|
||||||
notifications.append(
|
notifications.append((
|
||||||
f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см "
|
bet["user_id"],
|
||||||
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
f"🎉 Ставка сыграла!\n"
|
||||||
)
|
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
|
||||||
|
f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
|
||||||
|
f"💰 Выигрыш: +{winnings:.1f} см",
|
||||||
|
))
|
||||||
|
logger.info("Bet won: user=%s match=%s winnings=%s", bet["user_id"], match_id, winnings)
|
||||||
else:
|
else:
|
||||||
wcur.execute(
|
wcur.execute(
|
||||||
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
|
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
|
||||||
(now_ts, bet["id"]),
|
(now_ts, bet["id"]),
|
||||||
)
|
)
|
||||||
notifications.append(
|
notifications.append((
|
||||||
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см "
|
bet["user_id"],
|
||||||
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
f"❌ Ставка не сыграла\n"
|
||||||
)
|
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
|
||||||
|
f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
|
||||||
|
f"💸 Потеряно: {bet['amount']:.1f} см",
|
||||||
|
))
|
||||||
|
logger.info("Bet lost: user=%s match=%s amount=%s", bet["user_id"], match_id, bet["amount"])
|
||||||
except psycopg2.Error:
|
except psycopg2.Error:
|
||||||
logger.exception("Failed to settle bets")
|
logger.exception("Failed to settle bets")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ def play_casino(user_id: int, bet: float) -> str:
|
||||||
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
|
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
|
||||||
if bet <= 0:
|
if bet <= 0:
|
||||||
return "Ставка должна быть больше нуля, фраер."
|
return "Ставка должна быть больше нуля, фраер."
|
||||||
if bet > current:
|
if round(bet, 2) > round(current, 2):
|
||||||
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
|
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
|
||||||
|
|
||||||
reels, matches = spin_slots()
|
reels, matches = spin_slots()
|
||||||
|
|
|
||||||
|
|
@ -162,12 +162,25 @@ class EconomyManager:
|
||||||
|
|
||||||
logger.info(f"Balance updated for user {user_id}: +{amount} ({transaction_type})")
|
logger.info(f"Balance updated for user {user_id}: +{amount} ({transaction_type})")
|
||||||
|
|
||||||
|
def find_user_id_by_username(self, username: str) -> int | None:
|
||||||
|
"""Ищет user_id по @username в penis_stats (display_name хранится как '@username')."""
|
||||||
|
name = username.lstrip('@').lower()
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT user_id FROM penis_stats WHERE LOWER(display_name) = %s OR LOWER(display_name) = %s",
|
||||||
|
(f'@{name}', name),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return int(row['user_id']) if row else None
|
||||||
|
|
||||||
def transfer_money(self, from_user_id: int, to_user_id: int, amount: float, description: str = "") -> bool:
|
def transfer_money(self, from_user_id: int, to_user_id: int, amount: float, description: str = "") -> bool:
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
from_balance = self.get_user_balance(from_user_id)
|
from_balance = self.get_user_balance(from_user_id)
|
||||||
if amount > from_balance * 0.2 or from_balance < amount:
|
limit = round(from_balance * 0.2, 2)
|
||||||
|
if round(amount, 2) > limit or round(from_balance, 2) < round(amount, 2):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|
@ -189,15 +202,17 @@ class EconomyManager:
|
||||||
logger.info(f"Transfer: {amount} from user {from_user_id} to user {to_user_id}")
|
logger.info(f"Transfer: {amount} from user {from_user_id} to user {to_user_id}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def create_deposit(self, user_id: int, amount: float, days: int) -> bool:
|
def create_deposit(self, user_id: int, amount: float, days: int) -> tuple[bool, str]:
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
return False
|
return False, "Сумма вклада должна быть больше нуля."
|
||||||
|
|
||||||
balance = self.get_user_balance(user_id)
|
balance = self.get_user_balance(user_id)
|
||||||
min_deposit = float(self.get_setting('deposit_min_amount'))
|
min_deposit = float(self.get_setting('deposit_min_amount'))
|
||||||
|
|
||||||
if amount < min_deposit or balance < amount:
|
if round(amount, 2) < round(min_deposit, 2):
|
||||||
return False
|
return False, f"Минимальная сумма вклада — {min_deposit:.1f} см. Твой баланс: {balance:.1f} см."
|
||||||
|
if round(balance, 2) < round(amount, 2):
|
||||||
|
return False, f"Недостаточно средств. Твой баланс: {balance:.1f} см, а сумма вклада: {amount:.1f} см."
|
||||||
|
|
||||||
central_bank_rate = float(self.get_setting('central_bank_rate'))
|
central_bank_rate = float(self.get_setting('central_bank_rate'))
|
||||||
matures_at = datetime.now() + timedelta(days=days)
|
matures_at = datetime.now() + timedelta(days=days)
|
||||||
|
|
@ -218,7 +233,7 @@ class EconomyManager:
|
||||||
''', (user_id, amount, central_bank_rate, matures_at))
|
''', (user_id, amount, central_bank_rate, matures_at))
|
||||||
|
|
||||||
logger.info(f"Deposit created: user {user_id}, amount {amount}, rate {central_bank_rate}%")
|
logger.info(f"Deposit created: user {user_id}, amount {amount}, rate {central_bank_rate}%")
|
||||||
return True
|
return True, "Вклад успешно открыт!"
|
||||||
|
|
||||||
def create_loan(self, user_id: int, amount: float, days: int) -> bool:
|
def create_loan(self, user_id: int, amount: float, days: int) -> bool:
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
|
|
@ -227,7 +242,25 @@ class EconomyManager:
|
||||||
max_loan = float(self.get_setting('loan_max_amount'))
|
max_loan = float(self.get_setting('loan_max_amount'))
|
||||||
loan_rate = float(self.get_setting('loan_interest_rate'))
|
loan_rate = float(self.get_setting('loan_interest_rate'))
|
||||||
|
|
||||||
if amount > max_loan:
|
if round(amount, 2) > round(max_loan, 2):
|
||||||
|
return False
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
|
||||||
|
# Лимит суммарного долга на юзера = max_loan
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COALESCE(SUM(amount),0) AS total FROM loans WHERE user_id=%s AND is_repaid=FALSE",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
outstanding = float(cur.fetchone()['total'])
|
||||||
|
if round(outstanding + amount, 2) > round(max_loan, 2):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ЦБ должен иметь достаточно капитала
|
||||||
|
cur.execute("SELECT capital FROM central_bank WHERE id=1")
|
||||||
|
cb_capital = float(cur.fetchone()['capital'])
|
||||||
|
if round(cb_capital, 2) < round(amount, 2):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
due_at = datetime.now() + timedelta(days=days)
|
due_at = datetime.now() + timedelta(days=days)
|
||||||
|
|
@ -263,10 +296,10 @@ class EconomyManager:
|
||||||
if not loan or loan['is_repaid']:
|
if not loan or loan['is_repaid']:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
total_repayment = loan['amount'] * (1 + loan['interest_rate'] / 100)
|
total_repayment = round(loan['amount'] * (1 + loan['interest_rate'] / 100), 2)
|
||||||
|
|
||||||
balance = self.get_user_balance(user_id)
|
balance = self.get_user_balance(user_id)
|
||||||
if balance < total_repayment:
|
if round(balance, 2) < total_repayment:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
wcur = conn.cursor()
|
wcur = conn.cursor()
|
||||||
|
|
@ -357,12 +390,21 @@ class EconomyManager:
|
||||||
total_paid = 0.0
|
total_paid = 0.0
|
||||||
wcur = conn.cursor()
|
wcur = conn.cursor()
|
||||||
|
|
||||||
|
# Проверяем что у ЦБ есть деньги на выплаты
|
||||||
|
wcur = conn.cursor()
|
||||||
|
wcur.execute("SELECT capital FROM central_bank WHERE id=1")
|
||||||
|
cb_row = wcur.fetchone()
|
||||||
|
cb_capital = float(cb_row[0]) if cb_row else 0.0
|
||||||
|
|
||||||
for row in users:
|
for row in users:
|
||||||
user_id = row['user_id']
|
user_id = row['user_id']
|
||||||
message_count = row['message_count']
|
message_count = row['message_count']
|
||||||
reward = message_count * reward_rate
|
reward = message_count * reward_rate
|
||||||
if reward <= 0:
|
if reward <= 0:
|
||||||
continue
|
continue
|
||||||
|
if cb_capital < reward:
|
||||||
|
logger.warning("CB out of funds for activity rewards, stopping early")
|
||||||
|
break
|
||||||
|
|
||||||
wcur.execute('''
|
wcur.execute('''
|
||||||
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||||
|
|
@ -371,10 +413,15 @@ class EconomyManager:
|
||||||
balance = user_balances.balance + %s,
|
balance = user_balances.balance + %s,
|
||||||
daily_income = user_balances.daily_income + %s
|
daily_income = user_balances.daily_income + %s
|
||||||
''', (user_id, reward, reward, reward))
|
''', (user_id, reward, reward, reward))
|
||||||
|
wcur.execute('''
|
||||||
|
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
''', (reward,))
|
||||||
wcur.execute('''
|
wcur.execute('''
|
||||||
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||||
VALUES (%s, %s, %s, 'activity_reward', %s)
|
VALUES (0, %s, %s, 'activity_reward', %s)
|
||||||
''', (user_id, user_id, reward, f'Вознаграждение за {message_count} сообщений'))
|
''', (user_id, reward, f'Вознаграждение за {message_count} сообщений'))
|
||||||
|
cb_capital -= reward
|
||||||
total_paid += reward
|
total_paid += reward
|
||||||
|
|
||||||
wcur.execute(
|
wcur.execute(
|
||||||
|
|
@ -435,7 +482,7 @@ class EconomyManager:
|
||||||
logger.info(f"Central bank updated: {capital_change} ({transaction_type})")
|
logger.info(f"Central bank updated: {capital_change} ({transaction_type})")
|
||||||
|
|
||||||
def emit_money(self, amount: float, reason: str = "") -> bool:
|
def emit_money(self, amount: float, reason: str = "") -> bool:
|
||||||
if self.get_central_bank_stats()['capital'] < amount:
|
if round(self.get_central_bank_stats()['capital'], 2) < round(amount, 2):
|
||||||
return False
|
return False
|
||||||
self.update_central_bank(-amount, 'emission', reason)
|
self.update_central_bank(-amount, 'emission', reason)
|
||||||
logger.info(f"Money emitted: {amount} cm ({reason})")
|
logger.info(f"Money emitted: {amount} cm ({reason})")
|
||||||
|
|
|
||||||
132
main.py
132
main.py
|
|
@ -1,5 +1,5 @@
|
||||||
# Системные импорты
|
# Системные импорты
|
||||||
import asyncio, json, logging, os, re, random, threading
|
import asyncio, json, logging, os, re, random, shutil, threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
@ -41,6 +41,7 @@ from games.betting import (
|
||||||
)
|
)
|
||||||
from games.economy import economy
|
from games.economy import economy
|
||||||
from zparser import get_military_data
|
from zparser import get_military_data
|
||||||
|
from downloader import download_video, COOKIES_PATH
|
||||||
import config
|
import config
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
@ -1262,12 +1263,12 @@ async def handle_deposit_cmd(message: Message):
|
||||||
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
|
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
|
||||||
return
|
return
|
||||||
|
|
||||||
success = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
|
success, error_msg = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
|
||||||
if success:
|
if success:
|
||||||
rate = economy.get_setting('central_bank_rate')
|
rate = economy.get_setting('central_bank_rate')
|
||||||
await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
|
await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
|
||||||
else:
|
else:
|
||||||
await message.reply("❌ Не удалось открыть вклад. Проверь баланс и минимальную сумму.", parse_mode=None)
|
await message.reply(f"❌ Не удалось открыть вклад. {error_msg}", parse_mode=None)
|
||||||
|
|
||||||
async def handle_loans_cmd(message: Message):
|
async def handle_loans_cmd(message: Message):
|
||||||
"""Показать кредиты"""
|
"""Показать кредиты"""
|
||||||
|
|
@ -1320,7 +1321,7 @@ async def handle_transfer_cmd(message: Message):
|
||||||
|
|
||||||
username = parts[1].lstrip('@')
|
username = parts[1].lstrip('@')
|
||||||
try:
|
try:
|
||||||
amount = float(parts[2].replace(",", "."))
|
amount = round(float(parts[2].replace(",", ".")), 1)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.reply("Сумма должна быть числом.", parse_mode=None)
|
await message.reply("Сумма должна быть числом.", parse_mode=None)
|
||||||
return
|
return
|
||||||
|
|
@ -1329,9 +1330,31 @@ async def handle_transfer_cmd(message: Message):
|
||||||
await message.reply("Сумма должна быть положительной.", parse_mode=None)
|
await message.reply("Сумма должна быть положительной.", parse_mode=None)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Здесь нужно найти user_id по username
|
to_user_id = await asyncio.to_thread(economy.find_user_id_by_username, username)
|
||||||
# Для простоты примера, пока покажем сообщение об ошибке
|
if not to_user_id:
|
||||||
await message.reply("🔍 Поиск пользователя...\n(функция поиска пользователей будет добавлена)", parse_mode=None)
|
await message.reply(f"Пользователь @{username} не найден. Он должен хотя бы раз сыграть в /penis.", parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if to_user_id == message.from_user.id:
|
||||||
|
await message.reply("Себе переводить нельзя.", parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
success = await asyncio.to_thread(
|
||||||
|
economy.transfer_money, message.from_user.id, to_user_id, amount, f"transfer to @{username}"
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
my_balance = economy.get_user_balance(message.from_user.id)
|
||||||
|
await message.reply(
|
||||||
|
f"✅ Переведено {amount:.1f} см → @{username}\nТвой баланс: {my_balance:.1f} см",
|
||||||
|
parse_mode=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
my_balance = economy.get_user_balance(message.from_user.id)
|
||||||
|
limit = round(my_balance * 0.2, 1)
|
||||||
|
await message.reply(
|
||||||
|
f"❌ Не удалось перевести. Баланс: {my_balance:.1f} см, лимит одного перевода: {limit:.1f} см (20%).",
|
||||||
|
parse_mode=None,
|
||||||
|
)
|
||||||
|
|
||||||
async def handle_vanomasa_cmd(message: Message):
|
async def handle_vanomasa_cmd(message: Message):
|
||||||
"""Полное обнуление (только для админов)"""
|
"""Полное обнуление (только для админов)"""
|
||||||
|
|
@ -1539,7 +1562,7 @@ async def handle_bet_cmd(message: Message):
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
|
success, result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
|
||||||
await message.reply(result, parse_mode=None)
|
await message.reply(result, parse_mode=None)
|
||||||
|
|
||||||
async def handle_mybets_cmd(message: Message):
|
async def handle_mybets_cmd(message: Message):
|
||||||
|
|
@ -1572,13 +1595,98 @@ async def handle_sports_debug_cmd(message: Message):
|
||||||
result = await debug_sports()
|
result = await debug_sports()
|
||||||
await message.reply(result, parse_mode=None)
|
await message.reply(result, parse_mode=None)
|
||||||
|
|
||||||
|
_YT_URL_RE = re.compile(
|
||||||
|
r"https?://(?:www\.)?(?:youtube\.com/(?:watch|shorts|live)|youtu\.be)/\S+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_dow_cmd(message: Message, bot: Bot):
|
||||||
|
parts = (message.text or "").split(maxsplit=1)
|
||||||
|
|
||||||
|
# Если /dow без URL — пробуем взять ссылку из сообщения-источника (reply)
|
||||||
|
url = ""
|
||||||
|
if len(parts) >= 2:
|
||||||
|
url = parts[1].strip()
|
||||||
|
elif message.reply_to_message:
|
||||||
|
src_text = message.reply_to_message.text or message.reply_to_message.caption or ""
|
||||||
|
m = _YT_URL_RE.search(src_text)
|
||||||
|
if m:
|
||||||
|
url = m.group(0)
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
await message.reply("Формат: /dow <ссылка>\nИли ответь на сообщение с YouTube-ссылкой командой /dow")
|
||||||
|
return
|
||||||
|
|
||||||
|
status = await message.reply("⏳ Скачиваю...")
|
||||||
|
file_path, title, error = await download_video(url)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
await status.edit_text(f"❌ Ошибка скачивания:\n{error[:500]}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
size_mb = file_path.stat().st_size / (1024 * 1024)
|
||||||
|
if size_mb > 50:
|
||||||
|
await status.edit_text(f"❌ Видео весит {size_mb:.1f} МБ — Telegram не позволяет отправить >50 МБ")
|
||||||
|
return
|
||||||
|
|
||||||
|
await status.edit_text("📤 Отправляю...")
|
||||||
|
target = message.reply_to_message or message
|
||||||
|
await target.reply_video(
|
||||||
|
FSInputFile(str(file_path)),
|
||||||
|
caption=title[:1024],
|
||||||
|
supports_streaming=True,
|
||||||
|
)
|
||||||
|
await status.delete()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to send downloaded video")
|
||||||
|
await status.edit_text(f"❌ Не смог отправить: {exc}")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(str(file_path.parent), ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_setcookies_cmd(message: Message, bot: Bot):
|
||||||
|
"""Загрузить файл кук для YouTube (только в личке, ответом на документ)."""
|
||||||
|
if message.chat.type != "private":
|
||||||
|
await message.reply("Куки можно загрузить только в личных сообщениях с ботом.")
|
||||||
|
return
|
||||||
|
|
||||||
|
doc = None
|
||||||
|
if message.document:
|
||||||
|
doc = message.document
|
||||||
|
elif message.reply_to_message and message.reply_to_message.document:
|
||||||
|
doc = message.reply_to_message.document
|
||||||
|
|
||||||
|
if not doc:
|
||||||
|
await message.reply(
|
||||||
|
"Пришли файл cookies (Netscape/txt) в этот чат с подписью /setcookies "
|
||||||
|
"или ответь на сообщение с файлом командой /setcookies"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
COOKIES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_info = await bot.get_file(doc.file_id)
|
||||||
|
buf = BytesIO()
|
||||||
|
await bot.download_file(file_info.file_path, destination=buf)
|
||||||
|
buf.seek(0)
|
||||||
|
COOKIES_PATH.write_bytes(buf.read())
|
||||||
|
await message.reply(f"✅ Куки сохранены ({doc.file_size or '?'} байт). yt-dlp будет их использовать.")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to save cookies")
|
||||||
|
await message.reply(f"❌ Не удалось сохранить: {exc}")
|
||||||
|
|
||||||
|
|
||||||
async def _settle_loop(bot: Bot):
|
async def _settle_loop(bot: Bot):
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(1800)
|
await asyncio.sleep(1800)
|
||||||
try:
|
try:
|
||||||
notifications = await settle_bets()
|
notifications = await settle_bets()
|
||||||
for note in notifications:
|
for user_id, text in notifications:
|
||||||
logger.info(note)
|
try:
|
||||||
|
await bot.send_message(user_id, text)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not notify user %s about bet result", user_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("settle_bets failed")
|
logger.exception("settle_bets failed")
|
||||||
|
|
||||||
|
|
@ -1619,6 +1727,8 @@ def main():
|
||||||
dp.message.register(handle_gen_mem, Command("gen_mem"))
|
dp.message.register(handle_gen_mem, Command("gen_mem"))
|
||||||
dp.message.register(handle_uwu_cmd, Command("uwu"))
|
dp.message.register(handle_uwu_cmd, Command("uwu"))
|
||||||
dp.message.register(handle_nude_cmd, Command("nude"))
|
dp.message.register(handle_nude_cmd, Command("nude"))
|
||||||
|
dp.message.register(handle_dow_cmd, Command("dow"))
|
||||||
|
dp.message.register(handle_setcookies_cmd, Command("setcookies"))
|
||||||
dp.message.register(handle_photo_message, _is_watch_image_message)
|
dp.message.register(handle_photo_message, _is_watch_image_message)
|
||||||
|
|
||||||
# Экономические команды
|
# Экономические команды
|
||||||
|
|
@ -1665,6 +1775,8 @@ def main():
|
||||||
BotCommand(command="svodka", description="СВО: итоги"),
|
BotCommand(command="svodka", description="СВО: итоги"),
|
||||||
BotCommand(command="uwu", description="Случайная картинка с e621"),
|
BotCommand(command="uwu", description="Случайная картинка с e621"),
|
||||||
BotCommand(command="nude", description="Голые женщины из открытых источников"),
|
BotCommand(command="nude", description="Голые женщины из открытых источников"),
|
||||||
|
BotCommand(command="dow", description="Скачать видео по ссылке"),
|
||||||
|
BotCommand(command="setcookies", description="Загрузить куки YouTube (личка)"),
|
||||||
BotCommand(command="balance", description="💰 баланс и статистика"),
|
BotCommand(command="balance", description="💰 баланс и статистика"),
|
||||||
BotCommand(command="deposit", description="💎 открыть вклад"),
|
BotCommand(command="deposit", description="💎 открыть вклад"),
|
||||||
BotCommand(command="loan", description="💵 взять кредит"),
|
BotCommand(command="loan", description="💵 взять кредит"),
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,4 @@ tzdata>=2024.1
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
lxml
|
lxml
|
||||||
psycopg2-binary>=2.9.9
|
psycopg2-binary>=2.9.9
|
||||||
|
yt-dlp>=2024.1.0
|
||||||
|
|
|
||||||
114
test_precision.py
Normal file
114
test_precision.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
# Вставляем путь к проекту в sys.path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# 1. Глобальный мок для psycopg2, пула соединений и функции get_conn,
|
||||||
|
# чтобы избежать реального подключения к БД во время импорта и выполнения тестов.
|
||||||
|
import psycopg2
|
||||||
|
psycopg2.connect = MagicMock()
|
||||||
|
import psycopg2.pool
|
||||||
|
psycopg2.pool.ThreadedConnectionPool = MagicMock()
|
||||||
|
|
||||||
|
import db
|
||||||
|
# Мокаем get_conn как контекстный менеджер
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_cur = MagicMock()
|
||||||
|
mock_conn.cursor.return_value = mock_cur
|
||||||
|
# Для RealDictCursor
|
||||||
|
mock_cur_dict = MagicMock()
|
||||||
|
mock_conn.cursor.return_value = mock_cur_dict
|
||||||
|
|
||||||
|
mock_get_conn = MagicMock()
|
||||||
|
mock_get_conn.return_value.__enter__.return_value = mock_conn
|
||||||
|
db.get_conn = mock_get_conn
|
||||||
|
|
||||||
|
# 2. Импортируем тестируемые модули после настройки моков
|
||||||
|
from games.betting import place_bet
|
||||||
|
from games.casino import play_casino
|
||||||
|
from games.economy import economy
|
||||||
|
|
||||||
|
class TestPrecision(unittest.TestCase):
|
||||||
|
|
||||||
|
@patch('games.betting.get_user_length')
|
||||||
|
@patch('games.betting.update_user_length')
|
||||||
|
def test_place_bet_exact_balance(self, mock_update_user_length, mock_get_user_length):
|
||||||
|
# Баланс 0.199999988 (32-bit float представление 0.2)
|
||||||
|
mock_get_user_length.return_value = 0.199999988
|
||||||
|
mock_update_user_length.return_value = 0.0
|
||||||
|
|
||||||
|
match = {
|
||||||
|
"id": "match_123",
|
||||||
|
"home": "Team A",
|
||||||
|
"away": "Team B",
|
||||||
|
"odds": {"Team A": 2.0, "Team B": 1.8}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ставка ровно 0.2 должна быть успешной благодаря округлению round(amount, 2) <= round(current, 2)
|
||||||
|
success, message = place_bet(12345, match, "dota2", "Team A", 0.2)
|
||||||
|
|
||||||
|
self.assertTrue(success)
|
||||||
|
self.assertIn("Ставка принята!", message)
|
||||||
|
|
||||||
|
# Ставка 0.21 при балансе 0.199999988 должна быть отклонена
|
||||||
|
success_fail, message_fail = place_bet(12345, match, "dota2", "Team A", 0.21)
|
||||||
|
self.assertFalse(success_fail)
|
||||||
|
self.assertIn("Не хватает, фраер", message_fail)
|
||||||
|
|
||||||
|
@patch('games.casino.economy.get_user_balance')
|
||||||
|
@patch('games.casino.spin_slots')
|
||||||
|
@patch('games.casino.economy.update_balance')
|
||||||
|
def test_casino_exact_balance(self, mock_update, mock_spin, mock_get_user_balance):
|
||||||
|
# Баланс 0.199999988
|
||||||
|
mock_get_user_balance.return_value = 0.199999988
|
||||||
|
mock_spin.return_value = (["🍒", "🍒", "🍇"], 2)
|
||||||
|
mock_update.return_value = 0.0
|
||||||
|
|
||||||
|
# Ставка ровно 0.2 в казино должна быть успешной
|
||||||
|
result = play_casino(12345, 0.2)
|
||||||
|
self.assertNotIn("Столько нет, фраер", result)
|
||||||
|
|
||||||
|
# Ставка 0.21 при балансе 0.199999988 должна быть отклонена
|
||||||
|
result_fail = play_casino(12345, 0.21)
|
||||||
|
self.assertIn("Столько нет, фраер", result_fail)
|
||||||
|
|
||||||
|
@patch('games.economy.economy.get_user_balance')
|
||||||
|
@patch('games.economy.economy.get_setting')
|
||||||
|
def test_economy_deposit_exact_balance(self, mock_get_setting, mock_get_user_balance):
|
||||||
|
# Баланс 9.999999 (32-bit float представление 10.0), минимальный вклад 10.0
|
||||||
|
mock_get_user_balance.return_value = 9.999999
|
||||||
|
mock_get_setting.side_effect = lambda key: "10.0" if key == "deposit_min_amount" else "7.5"
|
||||||
|
|
||||||
|
# Вклад на 10.0 при балансе 9.999999 (эквивалентен 10.0) должен пройти успешно благодаря округлению
|
||||||
|
success, msg = economy.create_deposit(12345, 10.0, 30)
|
||||||
|
self.assertTrue(success)
|
||||||
|
self.assertEqual(msg, "Вклад успешно открыт!")
|
||||||
|
|
||||||
|
# Вклад на 10.01 должен быть отклонен из-за нехватки баланса
|
||||||
|
success_fail, msg_fail = economy.create_deposit(12345, 10.01, 30)
|
||||||
|
self.assertFalse(success_fail)
|
||||||
|
self.assertIn("Недостаточно средств", msg_fail)
|
||||||
|
|
||||||
|
# Вклад на 9.99 должен быть отклонен из-за минимальной суммы
|
||||||
|
success_fail2, msg_fail2 = economy.create_deposit(12345, 9.99, 30)
|
||||||
|
self.assertFalse(success_fail2)
|
||||||
|
self.assertIn("Минимальная сумма вклада", msg_fail2)
|
||||||
|
|
||||||
|
@patch('games.economy.economy.get_user_balance')
|
||||||
|
def test_economy_transfer_exact_balance(self, mock_get_user_balance):
|
||||||
|
# При балансе 9.999999 (эквивалентен 10.0) лимит перевода (20%) составляет ровно 2.0.
|
||||||
|
mock_get_user_balance.return_value = 9.999999
|
||||||
|
|
||||||
|
# Перевод ровно 2.0 должен пройти успешно
|
||||||
|
success = economy.transfer_money(12345, 67890, 2.0, "Test transfer")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
# Перевод 2.01 должен превысить 20% лимит (2.0) и отклониться
|
||||||
|
success_fail = economy.transfer_money(12345, 67890, 2.01, "Test transfer")
|
||||||
|
self.assertFalse(success_fail)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
|
|
@ -111,10 +111,23 @@ class SchedulePair(BaseModel):
|
||||||
|
|
||||||
# ──────────────────── Lifespan ────────────────────
|
# ──────────────────── Lifespan ────────────────────
|
||||||
|
|
||||||
|
_http_session: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_http_session() -> aiohttp.ClientSession:
|
||||||
|
return _http_session
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
global _http_session
|
||||||
|
_http_session = aiohttp.ClientSession(
|
||||||
|
timeout=aiohttp.ClientTimeout(total=120),
|
||||||
|
connector=aiohttp.TCPConnector(limit=64, ttl_dns_cache=300),
|
||||||
|
)
|
||||||
logger.info("Mini App API started")
|
logger.info("Mini App API started")
|
||||||
yield
|
yield
|
||||||
|
await _http_session.close()
|
||||||
logger.info("Mini App API stopped")
|
logger.info("Mini App API stopped")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -240,7 +253,9 @@ async def create_bet(
|
||||||
raise HTTPException(status_code=400, detail="Неверный исход ставки")
|
raise HTTPException(status_code=400, detail="Неверный исход ставки")
|
||||||
|
|
||||||
# 3. Разместить ставку (синхронная функция)
|
# 3. Разместить ставку (синхронная функция)
|
||||||
result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount)
|
success, result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=400, detail=result)
|
||||||
return {"message": result}
|
return {"message": result}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -281,7 +296,7 @@ async def play_casino_api(
|
||||||
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
|
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
|
||||||
if req.bet <= 0:
|
if req.bet <= 0:
|
||||||
raise HTTPException(status_code=400, detail="Ставка > 0")
|
raise HTTPException(status_code=400, detail="Ставка > 0")
|
||||||
if req.bet > current:
|
if round(req.bet, 2) > round(current, 2):
|
||||||
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
|
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
|
||||||
|
|
||||||
reels, match_count = await asyncio.to_thread(spin_slots)
|
reels, match_count = await asyncio.to_thread(spin_slots)
|
||||||
|
|
@ -479,17 +494,14 @@ async def proxy_shorties_media(url: str, request: Request):
|
||||||
if range_header:
|
if range_header:
|
||||||
headers["Range"] = range_header
|
headers["Range"] = range_header
|
||||||
|
|
||||||
timeout = aiohttp.ClientTimeout(total=120)
|
session = get_http_session()
|
||||||
session = aiohttp.ClientSession(timeout=timeout)
|
|
||||||
try:
|
try:
|
||||||
resp = await session.get(url, headers=headers, allow_redirects=True)
|
resp = await session.get(url, headers=headers, allow_redirects=True)
|
||||||
except aiohttp.ClientError:
|
except aiohttp.ClientError:
|
||||||
await session.close()
|
|
||||||
raise HTTPException(status_code=502, detail="Failed to fetch media")
|
raise HTTPException(status_code=502, detail="Failed to fetch media")
|
||||||
|
|
||||||
if resp.status not in {200, 206}:
|
if resp.status not in {200, 206}:
|
||||||
await resp.release()
|
await resp.release()
|
||||||
await session.close()
|
|
||||||
raise HTTPException(status_code=resp.status, detail="Upstream error")
|
raise HTTPException(status_code=resp.status, detail="Upstream error")
|
||||||
|
|
||||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||||
|
|
@ -499,7 +511,6 @@ async def proxy_shorties_media(url: str, request: Request):
|
||||||
body = await resp.text()
|
body = await resp.text()
|
||||||
finally:
|
finally:
|
||||||
await resp.release()
|
await resp.release()
|
||||||
await session.close()
|
|
||||||
|
|
||||||
def _proxy_media_url(target_url: str) -> str:
|
def _proxy_media_url(target_url: str) -> str:
|
||||||
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}"
|
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}"
|
||||||
|
|
@ -551,7 +562,6 @@ async def proxy_shorties_media(url: str, request: Request):
|
||||||
yield chunk
|
yield chunk
|
||||||
finally:
|
finally:
|
||||||
await resp.release()
|
await resp.release()
|
||||||
await session.close()
|
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
_stream(),
|
_stream(),
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,8 @@ const navBtns = document.querySelectorAll('.nav-btn');
|
||||||
// ── Router ──
|
// ── Router ──
|
||||||
function navigate(page, data = null) {
|
function navigate(page, data = null) {
|
||||||
document.querySelectorAll('.furtok-card video').forEach((video) => {
|
document.querySelectorAll('.furtok-card video').forEach((video) => {
|
||||||
if (video && video._hlsInstance) {
|
_videoObserver.unobserve(video);
|
||||||
|
if (video._hlsInstance) {
|
||||||
try { video._hlsInstance.destroy(); } catch {}
|
try { video._hlsInstance.destroy(); } catch {}
|
||||||
video._hlsInstance = null;
|
video._hlsInstance = null;
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +84,10 @@ function escapeHtml(value) {
|
||||||
|
|
||||||
function initShortiesVideoPlayback(video) {
|
function initShortiesVideoPlayback(video) {
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
|
// Если уже есть mp4-src — HLS не нужен, браузер справится сам
|
||||||
|
if (video.src && !video.src.endsWith('#')) return;
|
||||||
|
|
||||||
const hlsSrc = video.dataset.hlsSrc || '';
|
const hlsSrc = video.dataset.hlsSrc || '';
|
||||||
if (!hlsSrc) return;
|
if (!hlsSrc) return;
|
||||||
|
|
||||||
|
|
@ -94,9 +99,10 @@ function initShortiesVideoPlayback(video) {
|
||||||
|
|
||||||
if (window.Hls && window.Hls.isSupported()) {
|
if (window.Hls && window.Hls.isSupported()) {
|
||||||
const hls = new window.Hls({
|
const hls = new window.Hls({
|
||||||
maxBufferLength: 30,
|
maxBufferLength: 15,
|
||||||
backBufferLength: 30,
|
backBufferLength: 5,
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
|
lowLatencyMode: false,
|
||||||
});
|
});
|
||||||
hls.loadSource(hlsSrc);
|
hls.loadSource(hlsSrc);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
|
|
@ -104,6 +110,23 @@ function initShortiesVideoPlayback(video) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IntersectionObserver: играем только видимое видео, паузим скрытые
|
||||||
|
const _videoObserver = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
const video = entry.target;
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
video.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.6 });
|
||||||
|
|
||||||
|
function observeVideo(video) {
|
||||||
|
video.removeAttribute('autoplay');
|
||||||
|
_videoObserver.observe(video);
|
||||||
|
}
|
||||||
|
|
||||||
function getShortiesShareUrl(post) {
|
function getShortiesShareUrl(post) {
|
||||||
const source = String(post?.source || '').trim();
|
const source = String(post?.source || '').trim();
|
||||||
if (source) return source;
|
if (source) return source;
|
||||||
|
|
@ -898,7 +921,7 @@ async function loadFurtokPage(feedEl) {
|
||||||
const useHls = isShorties && !directMp4 && Boolean(post.hls_url);
|
const useHls = isShorties && !directMp4 && Boolean(post.hls_url);
|
||||||
const videoSrc = useHls ? '' : proxyMp4;
|
const videoSrc = useHls ? '' : proxyMp4;
|
||||||
const hlsSrc = isShorties ? escapeHtml(proxyHls) : '';
|
const hlsSrc = isShorties ? escapeHtml(proxyHls) : '';
|
||||||
mediaHtml = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-proxy-src="${escapeHtml(proxyMp4)}" data-hls-src="${hlsSrc}"${poster} loop autoplay playsinline preload="metadata" muted></video>`;
|
mediaHtml = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-proxy-src="${escapeHtml(proxyMp4)}" data-hls-src="${hlsSrc}"${poster} loop playsinline preload="metadata" muted></video>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = post.title ? escapeHtml(post.title) : '';
|
const title = post.title ? escapeHtml(post.title) : '';
|
||||||
|
|
@ -943,6 +966,7 @@ async function loadFurtokPage(feedEl) {
|
||||||
if (video.paused) video.play();
|
if (video.paused) video.play();
|
||||||
else video.pause();
|
else video.pause();
|
||||||
});
|
});
|
||||||
|
observeVideo(video);
|
||||||
}
|
}
|
||||||
|
|
||||||
const shareBtn = card.querySelector('.furtok-share-btn');
|
const shareBtn = card.querySelector('.furtok-share-btn');
|
||||||
|
|
@ -966,14 +990,6 @@ async function loadFurtokPage(feedEl) {
|
||||||
furtokCards.push(card);
|
furtokCards.push(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Автоплей первого видео при первой загрузке
|
|
||||||
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
|
|
||||||
const firstVideo = furtokCards[0].querySelector('video');
|
|
||||||
if (firstVideo) {
|
|
||||||
firstVideo.muted = true;
|
|
||||||
firstVideo.play().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
haptic('success');
|
haptic('success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://webapp-api:8080;
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $api http://webapp-api:8080;
|
||||||
|
proxy_pass $api;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue