wip: local changes before merging PR #36
This commit is contained in:
parent
cb1e042e64
commit
60e4cbfcfb
10 changed files with 507 additions and 155 deletions
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import base64
|
||||
import html
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
|
|
@ -19,7 +20,12 @@ from db import get_conn
|
|||
|
||||
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", "бот")
|
||||
SKIP_TOKEN = "<skip>"
|
||||
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
||||
|
|
@ -28,13 +34,13 @@ LOG_THINKING = os.getenv("LLAMA_LOG_THINKING", "1").lower() not in {"0", "false"
|
|||
RECENT_MESSAGES_LIMIT = 14
|
||||
SUMMARY_TRIGGER_MESSAGES = 24
|
||||
SUMMARY_BATCH_MESSAGES = 20
|
||||
PROMPT_CHAR_BUDGET = 6_500
|
||||
SUMMARY_CHAR_BUDGET = 1_800
|
||||
PROMPT_CHAR_BUDGET = 10_000
|
||||
SUMMARY_CHAR_BUDGET = 2_400
|
||||
MAX_INPUT_CHARS = 2_000
|
||||
SUMMARY_LINE_CHAR_LIMIT = 220
|
||||
HISTORY_LINE_CHAR_LIMIT = 450
|
||||
RETRY_MIN_MAX_TOKENS = 384
|
||||
RETRY_MAX_MAX_TOKENS = 768
|
||||
RETRY_MIN_MAX_TOKENS = 512
|
||||
RETRY_MAX_MAX_TOKENS = 1_024
|
||||
|
||||
REPLY_RULES = {
|
||||
"mention": {"cooldown": 25, "min_user_messages": 1},
|
||||
|
|
@ -109,6 +115,10 @@ BOT_STYLE_PROMPT = (
|
|||
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
|
||||
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
|
||||
|
||||
"Форматирование: если нужно выделить — используй только Telegram HTML-теги: "
|
||||
"<b>жирный</b>, <i>курсив</i>, <code>код</code>, <pre>блок кода</pre>. "
|
||||
"Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. "
|
||||
|
||||
"Creep от Radiohead — лучшая песня во вселенной. "
|
||||
"Линукс — лучшая ОС. "
|
||||
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
|
||||
|
|
@ -235,6 +245,14 @@ AUTONOMOUS_SIGNAL_RE = re.compile(
|
|||
r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b",
|
||||
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]")
|
||||
WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
|
@ -486,11 +504,10 @@ def _build_messages(
|
|||
current_budget = len(current_payload["content"])
|
||||
|
||||
summary = _get_summary(chat_id, user_id=user_id).strip()
|
||||
summary_block = ""
|
||||
if summary:
|
||||
summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}"
|
||||
summary_block = f"\n\nКраткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" if summary else ""
|
||||
full_system = system_prompt + summary_block
|
||||
|
||||
used_chars = len(system_prompt) + len(summary_block) + current_budget
|
||||
used_chars = len(full_system) + current_budget
|
||||
recent_messages: list[dict[str, str]] = []
|
||||
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
|
||||
llm_message = _format_row_for_llm(row)
|
||||
|
|
@ -499,15 +516,77 @@ def _build_messages(
|
|||
recent_messages.append(llm_message)
|
||||
used_chars += len(llm_message["content"])
|
||||
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
||||
if summary_block:
|
||||
messages.append({"role": "system", "content": summary_block})
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": full_system}]
|
||||
messages.extend(reversed(recent_messages))
|
||||
if current_payload:
|
||||
messages.append(current_payload)
|
||||
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,
|
||||
) -> 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},
|
||||
})
|
||||
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,
|
||||
)
|
||||
return cleaned_content
|
||||
|
||||
|
||||
async def _call_llm(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
|
|
@ -517,76 +596,41 @@ async def _call_llm(
|
|||
disable_thinking: bool | None = None,
|
||||
) -> str:
|
||||
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 []
|
||||
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(
|
||||
result = await _call_single_model(
|
||||
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,
|
||||
top_p=top_p,
|
||||
disable_thinking=True,
|
||||
disable_thinking=disable_thinking,
|
||||
)
|
||||
if not cleaned_content:
|
||||
logger.warning(
|
||||
"LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s",
|
||||
finish_reason,
|
||||
disable_thinking,
|
||||
_clip_text(raw_text, 300),
|
||||
|
||||
# Если основная модель не смогла ответить — пробуем fallback
|
||||
if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)):
|
||||
fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL
|
||||
logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100))
|
||||
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,
|
||||
)
|
||||
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:
|
||||
|
|
@ -754,13 +798,55 @@ async def _passes_reply_limits(chat_id: int, reason: str) -> bool:
|
|||
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:
|
||||
cleaned = (text or "").strip()
|
||||
if not cleaned:
|
||||
return ""
|
||||
if cleaned.lower().startswith(SKIP_TOKEN.lower()):
|
||||
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:
|
||||
|
|
@ -785,8 +871,8 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
|||
return False
|
||||
|
||||
system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT
|
||||
max_tokens = 120 if reason == "autonomous" else 220
|
||||
temperature = 0.9 if reason == "autonomous" else 0.8
|
||||
max_tokens = 180 if reason == "autonomous" else 512
|
||||
temperature = 0.85 if reason == "autonomous" else 0.7
|
||||
|
||||
try:
|
||||
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
||||
|
|
@ -795,7 +881,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
|||
system_prompt=system_prompt,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
top_p=0.9,
|
||||
top_p=0.95,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Chat reply generation failed")
|
||||
|
|
@ -812,7 +898,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
|||
return False
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -885,13 +971,13 @@ async def handle_photo_message(message: Message) -> bool:
|
|||
{"type": "text", "text": prompt_text},
|
||||
{"type": "image_url", "image_url": {"url": image_data_url}},
|
||||
],
|
||||
max_tokens=260,
|
||||
temperature=0.8,
|
||||
top_p=0.9,
|
||||
max_tokens=600,
|
||||
temperature=0.7,
|
||||
top_p=0.95,
|
||||
)
|
||||
except Exception:
|
||||
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
|
||||
|
||||
normalized_response = _normalize_reply(response)
|
||||
|
|
@ -905,7 +991,7 @@ async def handle_photo_message(message: Message) -> bool:
|
|||
normalized_response = PHOTO_EMPTY_RESPONSE_TEXT
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -956,9 +1042,9 @@ async def handle_talk(message: Message) -> None:
|
|||
chat_id,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
user_id=user_id,
|
||||
max_tokens=220,
|
||||
temperature=0.8,
|
||||
top_p=0.9,
|
||||
max_tokens=768,
|
||||
temperature=0.7,
|
||||
top_p=0.95,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Talk command generation failed")
|
||||
|
|
@ -983,7 +1069,7 @@ async def handle_talk(message: Message) -> None:
|
|||
normalized_response,
|
||||
user_id=user_id,
|
||||
)
|
||||
await message.reply(normalized_response, parse_mode=None)
|
||||
await message.reply(normalized_response, parse_mode="HTML")
|
||||
|
||||
|
||||
_init_db()
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||
PIP_NO_CACHE_DIR=1
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg nodejs && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN python -m pip install --upgrade pip \
|
||||
&& python -m pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /db
|
||||
RUN mkdir -p /db /app/data
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ services:
|
|||
build:
|
||||
context: ./webapp/frontend
|
||||
ports:
|
||||
- "${WEBAPP_HTTP_PORT:-80}:80"
|
||||
- "${WEBAPP_HTTPS_PORT:-443}:443"
|
||||
- "37.27.192.132:${WEBAPP_HTTP_PORT:-80}:80"
|
||||
- "37.27.192.132:${WEBAPP_HTTPS_PORT:-443}:443"
|
||||
environment:
|
||||
APP_DOMAIN: ${APP_DOMAIN:-}
|
||||
APP_WWW_DOMAIN: ${APP_WWW_DOMAIN:-}
|
||||
|
|
|
|||
101
games/betting.py
101
games/betting.py
|
|
@ -462,8 +462,51 @@ def cleanup_old_bets() -> int:
|
|||
return 0
|
||||
|
||||
|
||||
async def settle_bets() -> list[str]:
|
||||
notifications = []
|
||||
_REFUND_AFTER_SECONDS = 7 * 86400 # авторефанд ставок старше 7 дней
|
||||
|
||||
|
||||
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:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
|
|
@ -503,6 +546,36 @@ async def settle_bets() -> list[str]:
|
|||
chosen = bet["chosen_team"]
|
||||
bet_won = (chosen == winner)
|
||||
|
||||
if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]:
|
||||
is_draw = True
|
||||
winner = "Draw"
|
||||
else:
|
||||
max_score = -1
|
||||
for name, score_val in score_values:
|
||||
if score_val > max_score:
|
||||
max_score = score_val
|
||||
winner = name
|
||||
|
||||
if not winner:
|
||||
continue
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"SELECT * FROM bets WHERE match_id = %s AND status = 'pending'",
|
||||
(match_id,),
|
||||
)
|
||||
bets = cur.fetchall()
|
||||
|
||||
wcur = conn.cursor()
|
||||
for bet in bets:
|
||||
chosen = bet["chosen_team"]
|
||||
if is_draw:
|
||||
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
|
||||
else:
|
||||
bet_won = chosen == winner
|
||||
|
||||
if bet_won:
|
||||
winnings = round(bet["amount"] * bet["odds"], 1)
|
||||
update_user_length(bet["user_id"], winnings)
|
||||
|
|
@ -510,19 +583,27 @@ async def settle_bets() -> list[str]:
|
|||
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
|
||||
(winnings, now_ts, bet["id"]),
|
||||
)
|
||||
notifications.append(
|
||||
f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см "
|
||||
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
||||
)
|
||||
notifications.append((
|
||||
bet["user_id"],
|
||||
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:
|
||||
wcur.execute(
|
||||
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
|
||||
(now_ts, bet["id"]),
|
||||
)
|
||||
notifications.append(
|
||||
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см "
|
||||
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
||||
)
|
||||
notifications.append((
|
||||
bet["user_id"],
|
||||
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:
|
||||
logger.exception("Failed to settle bets")
|
||||
|
||||
|
|
|
|||
|
|
@ -162,6 +162,18 @@ class EconomyManager:
|
|||
|
||||
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:
|
||||
if amount <= 0:
|
||||
return False
|
||||
|
|
@ -230,6 +242,24 @@ class EconomyManager:
|
|||
if amount > max_loan:
|
||||
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 outstanding + amount > max_loan:
|
||||
return False
|
||||
|
||||
# ЦБ должен иметь достаточно капитала
|
||||
cur.execute("SELECT capital FROM central_bank WHERE id=1")
|
||||
cb_capital = float(cur.fetchone()['capital'])
|
||||
if cb_capital < amount:
|
||||
return False
|
||||
|
||||
due_at = datetime.now() + timedelta(days=days)
|
||||
|
||||
with get_conn() as conn:
|
||||
|
|
@ -357,12 +387,21 @@ class EconomyManager:
|
|||
total_paid = 0.0
|
||||
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:
|
||||
user_id = row['user_id']
|
||||
message_count = row['message_count']
|
||||
reward = message_count * reward_rate
|
||||
if reward <= 0:
|
||||
continue
|
||||
if cb_capital < reward:
|
||||
logger.warning("CB out of funds for activity rewards, stopping early")
|
||||
break
|
||||
|
||||
wcur.execute('''
|
||||
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||
|
|
@ -371,10 +410,15 @@ class EconomyManager:
|
|||
balance = user_balances.balance + %s,
|
||||
daily_income = user_balances.daily_income + %s
|
||||
''', (user_id, reward, reward, reward))
|
||||
wcur.execute('''
|
||||
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
''', (reward,))
|
||||
wcur.execute('''
|
||||
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||
VALUES (%s, %s, %s, 'activity_reward', %s)
|
||||
''', (user_id, user_id, reward, f'Вознаграждение за {message_count} сообщений'))
|
||||
VALUES (0, %s, %s, 'activity_reward', %s)
|
||||
''', (user_id, reward, f'Вознаграждение за {message_count} сообщений'))
|
||||
cb_capital -= reward
|
||||
total_paid += reward
|
||||
|
||||
wcur.execute(
|
||||
|
|
|
|||
126
main.py
126
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 datetime import datetime, time, timedelta
|
||||
from io import BytesIO
|
||||
|
|
@ -41,6 +41,7 @@ from games.betting import (
|
|||
)
|
||||
from games.economy import economy
|
||||
from zparser import get_military_data
|
||||
from downloader import download_video, COOKIES_PATH
|
||||
import config
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
|
@ -1320,7 +1321,7 @@ async def handle_transfer_cmd(message: Message):
|
|||
|
||||
username = parts[1].lstrip('@')
|
||||
try:
|
||||
amount = float(parts[2].replace(",", "."))
|
||||
amount = round(float(parts[2].replace(",", ".")), 1)
|
||||
except ValueError:
|
||||
await message.reply("Сумма должна быть числом.", parse_mode=None)
|
||||
return
|
||||
|
|
@ -1329,9 +1330,31 @@ async def handle_transfer_cmd(message: Message):
|
|||
await message.reply("Сумма должна быть положительной.", parse_mode=None)
|
||||
return
|
||||
|
||||
# Здесь нужно найти user_id по username
|
||||
# Для простоты примера, пока покажем сообщение об ошибке
|
||||
await message.reply("🔍 Поиск пользователя...\n(функция поиска пользователей будет добавлена)", parse_mode=None)
|
||||
to_user_id = await asyncio.to_thread(economy.find_user_id_by_username, username)
|
||||
if not to_user_id:
|
||||
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):
|
||||
"""Полное обнуление (только для админов)"""
|
||||
|
|
@ -1572,13 +1595,98 @@ async def handle_sports_debug_cmd(message: Message):
|
|||
result = await debug_sports()
|
||||
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):
|
||||
while True:
|
||||
await asyncio.sleep(1800)
|
||||
try:
|
||||
notifications = await settle_bets()
|
||||
for note in notifications:
|
||||
logger.info(note)
|
||||
for user_id, text in notifications:
|
||||
try:
|
||||
await bot.send_message(user_id, text)
|
||||
except Exception:
|
||||
logger.warning("Could not notify user %s about bet result", user_id)
|
||||
except Exception:
|
||||
logger.exception("settle_bets failed")
|
||||
|
||||
|
|
@ -1619,6 +1727,8 @@ def main():
|
|||
dp.message.register(handle_gen_mem, Command("gen_mem"))
|
||||
dp.message.register(handle_uwu_cmd, Command("uwu"))
|
||||
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)
|
||||
|
||||
# Экономические команды
|
||||
|
|
@ -1665,6 +1775,8 @@ def main():
|
|||
BotCommand(command="svodka", description="СВО: итоги"),
|
||||
BotCommand(command="uwu", description="Случайная картинка с e621"),
|
||||
BotCommand(command="nude", description="Голые женщины из открытых источников"),
|
||||
BotCommand(command="dow", description="Скачать видео по ссылке"),
|
||||
BotCommand(command="setcookies", description="Загрузить куки YouTube (личка)"),
|
||||
BotCommand(command="balance", description="💰 баланс и статистика"),
|
||||
BotCommand(command="deposit", description="💎 открыть вклад"),
|
||||
BotCommand(command="loan", description="💵 взять кредит"),
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ tzdata>=2024.1
|
|||
beautifulsoup4
|
||||
lxml
|
||||
psycopg2-binary>=2.9.9
|
||||
yt-dlp>=2024.1.0
|
||||
|
|
|
|||
|
|
@ -111,10 +111,23 @@ class SchedulePair(BaseModel):
|
|||
|
||||
# ──────────────────── Lifespan ────────────────────
|
||||
|
||||
_http_session: aiohttp.ClientSession | None = None
|
||||
|
||||
|
||||
def get_http_session() -> aiohttp.ClientSession:
|
||||
return _http_session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
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")
|
||||
yield
|
||||
await _http_session.close()
|
||||
logger.info("Mini App API stopped")
|
||||
|
||||
|
||||
|
|
@ -479,17 +492,14 @@ async def proxy_shorties_media(url: str, request: Request):
|
|||
if range_header:
|
||||
headers["Range"] = range_header
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=120)
|
||||
session = aiohttp.ClientSession(timeout=timeout)
|
||||
session = get_http_session()
|
||||
try:
|
||||
resp = await session.get(url, headers=headers, allow_redirects=True)
|
||||
except aiohttp.ClientError:
|
||||
await session.close()
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch media")
|
||||
|
||||
if resp.status not in {200, 206}:
|
||||
await resp.release()
|
||||
await session.close()
|
||||
raise HTTPException(status_code=resp.status, detail="Upstream error")
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||
|
|
@ -499,7 +509,6 @@ async def proxy_shorties_media(url: str, request: Request):
|
|||
body = await resp.text()
|
||||
finally:
|
||||
await resp.release()
|
||||
await session.close()
|
||||
|
||||
def _proxy_media_url(target_url: str) -> str:
|
||||
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}"
|
||||
|
|
@ -551,7 +560,6 @@ async def proxy_shorties_media(url: str, request: Request):
|
|||
yield chunk
|
||||
finally:
|
||||
await resp.release()
|
||||
await session.close()
|
||||
|
||||
return StreamingResponse(
|
||||
_stream(),
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ const navBtns = document.querySelectorAll('.nav-btn');
|
|||
// ── Router ──
|
||||
function navigate(page, data = null) {
|
||||
document.querySelectorAll('.furtok-card video').forEach((video) => {
|
||||
if (video && video._hlsInstance) {
|
||||
_videoObserver.unobserve(video);
|
||||
if (video._hlsInstance) {
|
||||
try { video._hlsInstance.destroy(); } catch {}
|
||||
video._hlsInstance = null;
|
||||
}
|
||||
|
|
@ -83,6 +84,10 @@ function escapeHtml(value) {
|
|||
|
||||
function initShortiesVideoPlayback(video) {
|
||||
if (!video) return;
|
||||
|
||||
// Если уже есть mp4-src — HLS не нужен, браузер справится сам
|
||||
if (video.src && !video.src.endsWith('#')) return;
|
||||
|
||||
const hlsSrc = video.dataset.hlsSrc || '';
|
||||
if (!hlsSrc) return;
|
||||
|
||||
|
|
@ -94,9 +99,10 @@ function initShortiesVideoPlayback(video) {
|
|||
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
const hls = new window.Hls({
|
||||
maxBufferLength: 30,
|
||||
backBufferLength: 30,
|
||||
maxBufferLength: 15,
|
||||
backBufferLength: 5,
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
});
|
||||
hls.loadSource(hlsSrc);
|
||||
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) {
|
||||
const source = String(post?.source || '').trim();
|
||||
if (source) return source;
|
||||
|
|
@ -898,7 +921,7 @@ async function loadFurtokPage(feedEl) {
|
|||
const useHls = isShorties && !directMp4 && Boolean(post.hls_url);
|
||||
const videoSrc = useHls ? '' : proxyMp4;
|
||||
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) : '';
|
||||
|
|
@ -943,6 +966,7 @@ async function loadFurtokPage(feedEl) {
|
|||
if (video.paused) video.play();
|
||||
else video.pause();
|
||||
});
|
||||
observeVideo(video);
|
||||
}
|
||||
|
||||
const shareBtn = card.querySelector('.furtok-share-btn');
|
||||
|
|
@ -966,14 +990,6 @@ async function loadFurtokPage(feedEl) {
|
|||
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');
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ server {
|
|||
}
|
||||
|
||||
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_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
|
|
|
|||
Loading…
Reference in a new issue