Compare commits
No commits in common. "main" and "main" have entirely different histories.
45 changed files with 403 additions and 9671 deletions
0
.codex
0
.codex
|
|
@ -5,4 +5,3 @@ __pycache__
|
|||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
db
|
||||
|
|
|
|||
2
.env
Normal file
2
.env
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
BOT_TOKEN=8488665890:AAH7FY5I2xzHhfOdxlzb11ewl2I-MMTH2-o
|
||||
TZ=Europe/Moscow
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -18,8 +18,3 @@ db/
|
|||
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
AI/models/
|
||||
|
||||
main_legacy.py
|
||||
docker-compose.override.yaml
|
||||
|
|
|
|||
1100
AI/talk_handler.py
1100
AI/talk_handler.py
File diff suppressed because it is too large
Load diff
106
DEPLOYMENT.md
106
DEPLOYMENT.md
|
|
@ -1,106 +0,0 @@
|
|||
# Инструкция по развертыванию бота
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### 1. Клонирование и подготовка
|
||||
```bash
|
||||
git clone <репозиторий>
|
||||
cd bot_tg
|
||||
```
|
||||
|
||||
### 2. Настройка переменных окружения
|
||||
Создайте файл `.env` с необходимыми переменными:
|
||||
```env
|
||||
BOT_TOKEN=your_telegram_bot_token
|
||||
LLAMA_API_URL=http://your-llama-server:8080 # опционально
|
||||
ODDS_API_KEY=your_odds_api_key # опционально для ставок
|
||||
```
|
||||
|
||||
### 3. Запуск через Docker Compose
|
||||
```bash
|
||||
# Сборка и запуск
|
||||
docker compose up --build -d
|
||||
|
||||
# Просмотр логов
|
||||
docker compose logs -f bot
|
||||
|
||||
# Остановка
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
bot_tg/
|
||||
├── main.py # основной файл бота
|
||||
├── games/
|
||||
│ ├── economy.py # экономическая система
|
||||
│ ├── casino.py # казино
|
||||
│ ├── betting.py # ставки на спорт
|
||||
│ └── ...
|
||||
├── AI/ # ИИ-модули
|
||||
├── maket/ # макеты для мемов
|
||||
├── db/ # базы данных (создаются автоматически)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yaml
|
||||
├── requirements.txt
|
||||
└── .env # переменные окружения
|
||||
```
|
||||
|
||||
## 🗄️ Базы данных
|
||||
|
||||
Автоматически создаются в папке `./db`:
|
||||
- `penis_stats.sqlite3` - статистика игры /penis
|
||||
- `chat_history.sqlite3` - история для ИИ-общения
|
||||
- `polychaetsi_stats.json` - статистика слова "получается"
|
||||
- `bets.sqlite3` - ставки на спорт
|
||||
- `economy.sqlite3` - экономическая система (ЦБ, вклады, кредиты)
|
||||
|
||||
## 🏛️ Экономическая система
|
||||
|
||||
**Основная валюта:** сантиметры (см)
|
||||
|
||||
**Команды экономики:**
|
||||
- `/balance` - баланс и статистика
|
||||
- `/deposit [сумма] [дни]` - открыть вклад
|
||||
- `/loan [сумма] [дни]` - взять кредит
|
||||
- `/transfer @username [сумма]` - перевести деньги
|
||||
- `/cb_stats` - статистика Центрального Банка
|
||||
- `/central_bank` - детальная статистика ЦБ
|
||||
- `/cb_rules` - правила работы ЦБ
|
||||
|
||||
**Центральный Банк:** децентрализованная система с капиталом 1000 см
|
||||
|
||||
## 🐛 Поиск проблем
|
||||
|
||||
### Проверка логов
|
||||
```bash
|
||||
docker compose logs bot
|
||||
```
|
||||
|
||||
### Перезапуск бота
|
||||
```bash
|
||||
docker compose restart bot
|
||||
```
|
||||
|
||||
### Проверка статуса контейнера
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
## ⚠️ Важные моменты
|
||||
|
||||
1. **Все базы данных хранятся в `./db`** - эта папка монтируется в контейнер
|
||||
2. **Бот работает в часовом поясе Europe/Moscow**
|
||||
3. **Центральный Банк полностью автоматический** - никакого ручного управления
|
||||
4. **Ежедневные выплаты за активность** - в 00:00 по МСК
|
||||
5. **Налоги 13%** - автоматически поступают в ЦБ
|
||||
|
||||
## 🔧 Первоначальная настройка
|
||||
|
||||
1. Получите токен бота у @BotFather
|
||||
2. Добавьте бота в чат (если нужен)
|
||||
3. Настройте `.env` файл
|
||||
4. Запустите через `docker compose up --build -d`
|
||||
|
||||
Бот готов к работе! 🎉
|
||||
17
Dockerfile
17
Dockerfile
|
|
@ -1,17 +1,8 @@
|
|||
FROM python:3.11-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
ENV PYTHONUNBUFFERED=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 /app/data
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY main.py impact.ttf ./
|
||||
COPY maket ./maket
|
||||
CMD ["python", "main.py"]
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
FROM python:3.11-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
COPY webapp/requirements-api.txt ./requirements-api.txt
|
||||
RUN python -m pip install --upgrade pip \
|
||||
&& python -m pip install -r requirements.txt \
|
||||
&& python -m pip install -r requirements-api.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /db
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["python", "-m", "webapp.api"]
|
||||
118
README.md
118
README.md
|
|
@ -3,46 +3,15 @@
|
|||
Телеграм‑бот, который:
|
||||
- вписывает текст шрифтом Impact (белый с чёрной обводкой) в макеты (`/maket`);
|
||||
- делает мемы из присланных фото (`/gen_mem Up:... Down:...`);
|
||||
- подсказывает, сколько минут до перекура по заданному расписанию (`/break`);
|
||||
- показывает расписание, текущую и следующую пару (`/schedule`, можно с днём недели);
|
||||
- умеет базарить (`/talk`) с контекстом 100 сообщений;
|
||||
- авто-ответы в чате (10% шанс + триггер-слова);
|
||||
- миниигра `/penis` + `/top_penis`;
|
||||
- казино на размер (`/penis_casino`);
|
||||
- гадание на фене с мемом (`/gadanie`);
|
||||
- ставки на матчи CS2, Dota 2, футбол с реальными коэффициентами (`/matches`, `/bet`).
|
||||
- подсказывает, сколько минут до перекура по заданному расписанию (`/break`).
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
Логи:
|
||||
|
||||
```bash
|
||||
docker compose logs -f bot
|
||||
```
|
||||
|
||||
Остановка:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Переменные окружения: задайте `BOT_TOKEN` в `.env`.
|
||||
Для ИИ-фич нужен `LLAMA_API_URL`.
|
||||
По умолчанию проект настроен на `https://mirror.porno4free.ru/zovos-ai/`.
|
||||
Если нужен лог reasoning/thinking модели, оставь `LLAMA_LOG_THINKING=1`; чтобы модель не резалась в режим без thinking, задай `LLAMA_FORCE_DISABLE_THINKING=0`.
|
||||
Для ставок нужен `ODDS_API_KEY` (бесплатно на https://the-odds-api.com).
|
||||
|
||||
Контейнер хранит состояние в `./db`:
|
||||
- `penis_stats.sqlite3` — статистика игры /penis
|
||||
- `chat_history.sqlite3` — история для ИИ-общения
|
||||
- `polychaetsi_stats.json` — статистика слова "получается"
|
||||
- `bets.sqlite3` — ставки на спорт
|
||||
- `economy.sqlite3` — экономическая система (балансы, вклады, кредиты)
|
||||
- `nude_history.json` — история запросов /nude
|
||||
Переменные окружения: задайте `BOT_TOKEN` в `.env` (уже добавлен шаблон).
|
||||
|
||||
## Команды
|
||||
|
||||
|
|
@ -51,52 +20,6 @@ docker compose down
|
|||
- `/maket <id> <текст>` — вписать текст в макет. Доступные: `1 — Владимир Владимирович Путин самый лучший президент` (737x414, точка 104,240).
|
||||
- `/gen_mem Up:верх Down:низ` — прислать с фото (подпись) или ответом на фото. Можно указывать только Up или только Down.
|
||||
- `/break` — показывает, сколько минут до ближайшего перекура/идёт ли перекур.
|
||||
- `/schedule [пн|mon]` — показывает расписание на ближайший указанный день недели; без аргумента показывает сегодня, текущую и следующую пару.
|
||||
- `/talk <сообщение>` — побазарить с ИИ (контекст 100 сообщений).
|
||||
- `/penis` — миниигра, 1 раз в 24 часа.
|
||||
- `/top_penis` — топ по длине.
|
||||
- `/polychaetsi` — топ по слову «получается».
|
||||
- `/penis_casino <ставка>` — казино на размер (макс 1 см, казино всегда в выигрыше).
|
||||
- `/gadanie <тема>` — ИИ-гадание на фене + авто-мем.
|
||||
- `/ebalnik` — вкл/выкл авто-ответы бота в чате.
|
||||
- `/matches [cs|dota|football]` — ближайшие матчи с коэффициентами.
|
||||
- `/bet [спорт] <номер> <исход> <ставка>` — поставить см на матч.
|
||||
- `/mybets` — мои активные ставки.
|
||||
- `/svodka` — сводка СВО.
|
||||
- `/uwu [теги]` — поиск случайной пикчи/гифки на e621 (по умолчанию sfw, можно указать свои теги).
|
||||
|
||||
## Экономика ZOV OS 💰
|
||||
|
||||
Основная валюта — сантиметры (см), получаемые из игры `/penis`.
|
||||
|
||||
**Команды экономики:**
|
||||
- `/balance` — показать баланс и статистику
|
||||
- `/deposit <сумма> <дни>` — открыть вклад по ставке ЦБ РФ
|
||||
- `/loan <сумма> <дни>` — взять кредит под 15% годовых
|
||||
- `/transfer @username <сумма>` — перевести деньги (лимит 20% от капитала)
|
||||
- `/cb_stats` — статистика Центрального Банка
|
||||
- `/central_bank` — детальная статистика ЦБ
|
||||
- `/cb_rules` — правила работы ЦБ
|
||||
- `/vanomasa` — полное обнуление всех балансов (админам)
|
||||
|
||||
**Фичи экономики:**
|
||||
- Вклады по ставке ЦБ РФ (7.5% годовых)
|
||||
- Кредиты под 15% годовых
|
||||
- Налог 13% с ежедневного дохода
|
||||
- Автоматическое регулирование инфляции
|
||||
- Фоновый сбор налогов каждый час
|
||||
- Заработок на сообщениях: 0.01 см за каждое сообщение
|
||||
- Ежедневные выплаты в 00:00 по МСК за активность
|
||||
|
||||
**Центральный Банк ZOV OS 🏛️ (децентрализованный):**
|
||||
- Учредительный капитал: 1000 см
|
||||
- Полностью автоматическая работа без вмешательства
|
||||
- Все налоги автоматически поступают в ЦБ
|
||||
- Алгоритмическое регулирование инфляции
|
||||
- Автоматическая эмиссия при дефляции
|
||||
- Автоматическая скупка денег при инфляции
|
||||
- Прозрачная статистика для всех пользователей
|
||||
- Никакого ручного управления - система работает по правилам
|
||||
|
||||
## Макеты и шрифт
|
||||
|
||||
|
|
@ -106,43 +29,10 @@ docker compose down
|
|||
## Технологии
|
||||
|
||||
- Python 3.11, aiogram 3, Pillow.
|
||||
- llama.cpp для ИИ-фич.
|
||||
- Dockerfile + docker-compose для развёртывания.
|
||||
|
||||
## Docker Notes
|
||||
|
||||
- Образ ставит `tzdata`, поэтому `ZoneInfo("Europe/Moscow")` нормально работает в контейнере.
|
||||
- В образ копируется весь проект, а runtime-данные исключены через `.dockerignore`.
|
||||
- Если меняешь код или картинки, достаточно пересобрать: `docker compose up --build -d`.
|
||||
|
||||
## HTTPS / Let's Encrypt
|
||||
|
||||
- Фронтендовый `nginx` теперь слушает `80` и `443`, отдает ACME challenge и сам запускает `certbot` внутри контейнера.
|
||||
- Для автоматической выдачи сертификата задай в `.env`:
|
||||
- `APP_DOMAIN=bot.example.com`
|
||||
- `APP_WWW_DOMAIN=www.bot.example.com` (опционально)
|
||||
- `ACME_EMAIL=admin@example.com`
|
||||
- Домен должен уже смотреть на сервер, а порты `80` и `443` должны быть доступны снаружи.
|
||||
- Для безопасной первой проверки можно временно включить `CERTBOT_STAGING=1`, потом вернуть `0`.
|
||||
- Сертификаты и webroot challenge хранятся в docker volumes `certbot_etc` и `certbot_www`, обновление запускается по кругу с интервалом `CERTBOT_RENEW_INTERVAL` (по умолчанию `12h`).
|
||||
- Если нужен старый локальный маппинг портов, можно переопределить `WEBAPP_HTTP_PORT` и `WEBAPP_HTTPS_PORT`, но для реального Let's Encrypt внешний `80` должен оставаться доступным.
|
||||
|
||||
## Кратко о логике
|
||||
|
||||
- `/maket` подбирает размер текста, чтобы уместить его в область макета.
|
||||
- `/gen_mem` центрирует текст по ширине, масштабирует под изображение, рисует белый текст с чёрной обводкой.
|
||||
- `/break` считает по расписанию в `config.py` и показывает следующую пару.
|
||||
- Названия пар и верхняя/нижняя неделя лежат в `config.py` в `LESSON_SCHEDULE`.
|
||||
- `/talk` шлёт запрос к llama.cpp серверу с контекстом 100 сообщений (хранятся в SQLite).
|
||||
- `/penis_casino` — слоты с множителем x1.2–x3 (`games/casino.py`).
|
||||
- `/gadanie` — ИИ-гадание + авто-мем через `generate_meme` (`games/fortune.py`).
|
||||
- `/matches` + `/bet` — ставки на реальные матчи, коэффициенты через The-Odds-API (`games/betting.py`). После `/matches cs|dota|football` можно короче: `/bet <номер> <исход> <ставка>`.
|
||||
|
||||
## ИИ-модуль (`AI/talk_handler.py`)
|
||||
|
||||
Подключается к llama.cpp серверу через OpenAI-совместимый API (`/v1/chat/completions`). Хранит контекст 100 сообщений в SQLite.
|
||||
|
||||
Настройка сервера:
|
||||
1. Скачать модель: `wget https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf`
|
||||
2. Запустить: `./llama-server -m model.gguf --host 0.0.0.0 --port 8080 -c 2048`
|
||||
3. Прописать URL в `.env`: `LLAMA_API_URL=http://адрес:8080`
|
||||
- `/break` считает по расписанию в `main.py` (словарь `SCHEDULE`).
|
||||
|
|
|
|||
226
config.py
226
config.py
|
|
@ -1,226 +0,0 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta, time
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
# --- Базовые пути ---
|
||||
BASE_DIR = Path(__file__).parent
|
||||
FONT_PATH = BASE_DIR / "impact.ttf"
|
||||
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
|
||||
|
||||
# --- Настройки /penis ---
|
||||
PENIS_START_LENGTH = 10.0
|
||||
PENIS_MIN_DELTA = 0.1
|
||||
PENIS_MAX_DELTA = 7.0
|
||||
PENIS_COOLDOWN = timedelta(hours=24)
|
||||
PENIS_COOLDOWN_SECONDS = int(PENIS_COOLDOWN.total_seconds())
|
||||
PENIS_TOP_LIMIT = 10
|
||||
|
||||
# --- Настройки "получается" ---
|
||||
POLYCHAETSI_STATS_PATH = Path(os.getenv("POLYCHAETSI_STATS_PATH", "/db/polychaetsi_stats.json"))
|
||||
POLYCHAETSI_TARGET = "получается"
|
||||
POLYCHAETSI_TOP_LIMIT = 10
|
||||
POLYCHAETSI_IMAGE_PATH = BASE_DIR / "maket" / "cvetok.jpg"
|
||||
|
||||
# --- Казино ---
|
||||
CASINO_MAX_BET = 1.0
|
||||
|
||||
# --- E621 API ---
|
||||
E621_LOGIN = os.getenv("E621_LOGIN", "")
|
||||
E621_API_KEY = os.getenv("E621_API_KEY", "")
|
||||
|
||||
# --- Ставки на матчи ---
|
||||
ODDS_API_KEY = os.getenv("ODDS_API_KEY", "")
|
||||
ODDS_API_BASE = "https://api.the-odds-api.com/v4"
|
||||
BET_MAX_AMOUNT = 3.0
|
||||
ODDS_SPORTS = {
|
||||
"dota2": "esports_dota2",
|
||||
}
|
||||
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
|
||||
|
||||
# --- Nude ---
|
||||
NUDE_HISTORY_PATH = Path(os.getenv("NUDE_HISTORY_PATH", "/db/nude_history.json"))
|
||||
NUDE_POSTS_PER_PAGE = int(os.getenv("NUDE_POSTS_PER_PAGE", "100"))
|
||||
NUDE_POSTS_POOL_TARGET = int(os.getenv("NUDE_POSTS_POOL_TARGET", "1200"))
|
||||
NUDE_MAX_POSTS_TO_SCAN = int(os.getenv("NUDE_MAX_POSTS_TO_SCAN", "2000"))
|
||||
NUDE_MIN_UNSEEN_POOL = int(os.getenv("NUDE_MIN_UNSEEN_POOL", "100"))
|
||||
NUDE_HISTORY_MAX_SIZE = int(os.getenv("NUDE_HISTORY_MAX_SIZE", "5000"))
|
||||
|
||||
# --- Magnit API ---
|
||||
MAGNIT_API_BASE_URL = os.getenv("MAGNIT_API_BASE_URL", "https://mirror.porno4free.ru/magnit")
|
||||
MAGNIT_STORE_CODE = os.getenv("MAGNIT_STORE_CODE", "618224")
|
||||
MAGNIT_REQUEST_TIMEOUT = int(os.getenv("MAGNIT_REQUEST_TIMEOUT", "15"))
|
||||
# Можно указывать просто строку с названием или словарь с query/label/store_code.
|
||||
MAGNIT_PRODUCTS = [
|
||||
"RedBull",
|
||||
]
|
||||
|
||||
# --- Конфигурация макетов ---
|
||||
TEMPLATES = {
|
||||
"1": {
|
||||
"path": BASE_DIR / "maket" / "1.png",
|
||||
"anchor": (104, 240),
|
||||
"padding": 20,
|
||||
"label": "Владимир Владимирович Путин самый лучший президент",
|
||||
},
|
||||
"2": {
|
||||
"path": BASE_DIR / "maket" / "2.png",
|
||||
"anchor": (40, 40),
|
||||
"box_size": (582, 346),
|
||||
"label": "Макет 2",
|
||||
},
|
||||
}
|
||||
|
||||
# --- Расписание ---
|
||||
def make_time(h: int, m: int) -> time:
|
||||
return time(hour=h, minute=m)
|
||||
|
||||
# Если у тебя наоборот, просто поменяй на False.
|
||||
UPPER_WEEK_IS_ODD = True
|
||||
|
||||
PAIR_TIMES = {
|
||||
1: (make_time(8, 0), make_time(9, 35)),
|
||||
2: (make_time(9, 50), make_time(11, 25)),
|
||||
3: (make_time(11, 55), make_time(13, 30)),
|
||||
4: (make_time(13, 45), make_time(15, 20)),
|
||||
5: (make_time(15, 50), make_time(17, 25)),
|
||||
}
|
||||
|
||||
LESSON_SCHEDULE = {
|
||||
"upper": {
|
||||
0: [
|
||||
{"pair": 1, "title": "Пары нету"},
|
||||
{"pair": 2, "title": "История Россия (семинар), каб. 504"},
|
||||
{"pair": 3, "title": "Первая Подгруппа: Основы программирование, каб. 606. Вторая подгруппа Глод."},
|
||||
{"pair": 4, "title": "Первая Подгруппа: Глод. Вторая подгруппа Основы программирование, каб 606"},
|
||||
{"pair": 5, "title": "Общекулутрная и профессиональная коммуникации."},
|
||||
],
|
||||
1: [
|
||||
{"pair": 1, "title": "Нету пары"},
|
||||
{"pair": 2, "title": "Физика (лаб), каб. 241"},
|
||||
{"pair": 3, "title": "Математика (лек), каб 514"},
|
||||
],
|
||||
2: [
|
||||
{"pair": None, "title": "Англ ебучий"},
|
||||
],
|
||||
3: [
|
||||
{"pair": 1, "title": "Физика (лек), каб 245"},
|
||||
{"pair": 2, "title": "Физика (прак), каб 245"},
|
||||
{"pair": 3, "title": "Маетимака (прак), каб 503"},
|
||||
{"pair": 4, "title": "Безопасность жизнедятельности (прак) каб 503"},
|
||||
{"pair": 5, "title": "Общекультурная и профессиональная коммуникации (прак), каб 503"},
|
||||
],
|
||||
4: [
|
||||
{"pair": 1, "title": "Нету пары"},
|
||||
{"pair": 2, "title": "История России (лек), каб 510"},
|
||||
{"pair": 3, "title": "Основы программирования (лек), каб 510"},
|
||||
{"pair": 4, "title": "Физическая культура спорта (прак), каб 503"},
|
||||
],
|
||||
},
|
||||
"lower": {
|
||||
0: [
|
||||
{"pair": 1, "title": "Пары нету"},
|
||||
{"pair": 2, "title": "История Россия (семинар), каб. 504"},
|
||||
{"pair": 3, "title": "Первая Подгруппа: Основы программирование, каб. 606. Вторая подгруппа Глод."},
|
||||
{"pair": 4, "title": "Первая Подгруппа: Глод. Вторая подгруппа Основы программирование, каб 606"},
|
||||
{"pair": 5, "title": "Общекулутрная и профессиональная коммуникации."},
|
||||
],
|
||||
1: [
|
||||
{"pair": 1, "title": "Нету пары"},
|
||||
{"pair": 2, "title": "Физика (лаб), каб. 241"},
|
||||
{"pair": 3, "title": "Математика (лек), каб 514"},
|
||||
{"pair": 4, "title": "Математика (прак), каб 503"},
|
||||
],
|
||||
2: [
|
||||
{"pair": None, "title": "Англ ебучий"},
|
||||
],
|
||||
3: [
|
||||
{"pair": 1, "title": "Физика (лек), каб 245"},
|
||||
{"pair": 2, "title": "Физика (прак), каб 245"},
|
||||
{"pair": 3, "title": "Маетимака (прак), каб 503"},
|
||||
{"pair": 4, "title": "Безопасность жизнедятельности (прак) каб 503"},
|
||||
{"pair": 5, "title": "Общекультурная и профессиональная коммуникации (прак), каб 503"},
|
||||
],
|
||||
4: [
|
||||
{"pair": 1, "title": "Нету пары"},
|
||||
{"pair": 2, "title": "История России (лек), каб 510"},
|
||||
{"pair": 3, "title": "Основы программирования (лек), каб 510"},
|
||||
{"pair": 4, "title": "Физическая культура спорта (прак), каб 503"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
SCHEDULE = {
|
||||
0: [ # Понедельник
|
||||
(make_time(9, 50), make_time(10, 35), 5),
|
||||
(make_time(10, 40), make_time(11, 25), 30),
|
||||
(make_time(11, 55), make_time(12, 40), 5),
|
||||
(make_time(12, 45), make_time(13, 30), 15),
|
||||
(make_time(13, 45), make_time(14, 30), 5),
|
||||
(make_time(14, 35), make_time(15, 20), 30),
|
||||
(make_time(15, 50), make_time(16, 35), 5),
|
||||
(make_time(16, 40), make_time(17, 25), None),
|
||||
],
|
||||
1: [ # Вторник
|
||||
(make_time(9, 50), make_time(10, 35), 5),
|
||||
(make_time(10, 40), make_time(11, 25), 30),
|
||||
(make_time(11, 55), make_time(12, 40), 5),
|
||||
(make_time(12, 45), make_time(13, 30), 15),
|
||||
(make_time(13, 45), make_time(14, 30), 5),
|
||||
(make_time(14, 35), make_time(15, 20), None),
|
||||
],
|
||||
2: [], # Среда
|
||||
3: [ # Четверг
|
||||
(make_time(8, 0), make_time(8, 45), 5),
|
||||
(make_time(8, 50), make_time(9, 35), 15),
|
||||
(make_time(9, 50), make_time(10, 35), 5),
|
||||
(make_time(10, 40), make_time(11, 25), 30),
|
||||
(make_time(11, 55), make_time(12, 40), 5),
|
||||
(make_time(12, 45), make_time(13, 30), 15),
|
||||
(make_time(13, 45), make_time(14, 30), 5),
|
||||
(make_time(14, 35), make_time(15, 20), 30),
|
||||
(make_time(15, 50), make_time(16, 35), 5),
|
||||
(make_time(16, 40), make_time(17, 25), None),
|
||||
],
|
||||
4: [ # Пятница
|
||||
(make_time(9, 50), make_time(10, 35), 5),
|
||||
(make_time(10, 40), make_time(11, 25), 30),
|
||||
(make_time(11, 55), make_time(12, 40), 5),
|
||||
(make_time(12, 45), make_time(13, 30), 15),
|
||||
(make_time(13, 45), make_time(14, 30), 5),
|
||||
(make_time(14, 35), make_time(15, 20), None),
|
||||
],
|
||||
}
|
||||
|
||||
# --- Остальное ---
|
||||
KEYWORDS = {'крип', 'радиохэд', 'creep', 'radiohead'}
|
||||
|
||||
# --- Авто-ответы ИИ ---
|
||||
AUTOREPLY_CHANCE = 0.10
|
||||
AUTOREPLY_COOLDOWN = 120
|
||||
AUTOREPLY_TRIGGERS = {'бот', 'фраер', 'кент', 'братуха', 'пацаны', 'чё как', 'базар', 'ебать', 'пизда', 'хуй', 'бля', 'сука', 'говно', 'пидор', 'пиздабол', 'пиздатый', 'пиздатая', 'пиздатое', 'пиздатые', 'пиздато', 'ваномас'}
|
||||
|
||||
def get_hint_text(templates_str: str) -> str:
|
||||
return (
|
||||
"Команды:\n"
|
||||
f"• /maket «id» «текст» — доступные: {templates_str}.\n"
|
||||
"• /gen_mem Up:верх Down:низ — отправь с фото.\n"
|
||||
"• /break — узнать время до перекура.\n"
|
||||
"• /schedule [пн|mon] — расписание; без аргумента еще текущая и следующая пара.\n"
|
||||
"• /penis — миниигра 1 раз в 24 часа.\n"
|
||||
"• /top_penis — топ по длине.\n"
|
||||
"• /talk [текст] — ИИ базарит на фене.\n"
|
||||
"• /penis_casino [ставка] — казино на размер.\n"
|
||||
"• /gadanie [тема] — гадание на фене с мемом.\n"
|
||||
"• /matches — матчи для ставок.\n"
|
||||
"• /bet [номер] [исход] [ставка] — поставить.\n"
|
||||
"• /mybets — мои активные ставки.\n"
|
||||
"• /bet_history — история ставок за месяц + статистика.\n"
|
||||
"• /sports_debug — диагностика API матчей.\n"
|
||||
"• /ebalnik — включить/выключить автоответы в чате.\n"
|
||||
"• /uwu [теги] — случайная картинка с e621.\n"
|
||||
"• /polychaetsi — топ по слову «получается».\n"
|
||||
"• /prices /fetch — цены и остатки Магнита.\n"
|
||||
"Подсказка: в группах пиши /команда@username_бота."
|
||||
)
|
||||
35
db.py
35
db.py
|
|
@ -1,35 +0,0 @@
|
|||
import logging
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
import psycopg2.pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
|
||||
|
||||
_pool: psycopg2.pool.ThreadedConnectionPool | None = None
|
||||
|
||||
|
||||
def _get_pool() -> psycopg2.pool.ThreadedConnectionPool:
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = psycopg2.pool.ThreadedConnectionPool(2, 10, dsn=DATABASE_URL)
|
||||
return _pool
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_conn() -> Generator[psycopg2.extensions.connection, None, None]:
|
||||
pool = _get_pool()
|
||||
conn = pool.getconn()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
pool.putconn(conn)
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
services:
|
||||
webapp-api-test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
container_name: fenyabot-webapp-api-test
|
||||
volumes:
|
||||
- ./db:/db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
TZ: Europe/Moscow
|
||||
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
||||
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
||||
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||
BET_DB_PATH: /db/bets.sqlite3
|
||||
API_PORT: "8080"
|
||||
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
|
||||
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
|
||||
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
|
||||
ports:
|
||||
- "18080:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
ngrok-test:
|
||||
image: ngrok/ngrok:latest
|
||||
container_name: fenyabot-ngrok-test
|
||||
depends_on:
|
||||
- webapp-api-test
|
||||
environment:
|
||||
NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN}
|
||||
command:
|
||||
- http
|
||||
- webapp-api-test:8080
|
||||
- --log=stdout
|
||||
ports:
|
||||
- "4041:4040"
|
||||
restart: unless-stopped
|
||||
|
|
@ -1,82 +1,8 @@
|
|||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: botdb
|
||||
POSTGRES_USER: bot
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-botpass}
|
||||
volumes:
|
||||
- ./db/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U bot -d botdb"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
bot:
|
||||
build:
|
||||
context: .
|
||||
volumes:
|
||||
- ./db:/db
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
TZ: Europe/Moscow
|
||||
DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
|
||||
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||
NUDE_HISTORY_PATH: /db/nude_history.json
|
||||
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
|
||||
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
|
||||
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
|
||||
webapp-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
volumes:
|
||||
- ./db:/db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
TZ: Europe/Moscow
|
||||
DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
|
||||
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||
NUDE_HISTORY_PATH: /db/nude_history.json
|
||||
API_PORT: "8080"
|
||||
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
|
||||
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
|
||||
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
webapp-frontend:
|
||||
build:
|
||||
context: ./webapp/frontend
|
||||
ports:
|
||||
- "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:-}
|
||||
ACME_EMAIL: ${ACME_EMAIL:-}
|
||||
CERTBOT_STAGING: ${CERTBOT_STAGING:-0}
|
||||
CERTBOT_RENEW_INTERVAL: ${CERTBOT_RENEW_INTERVAL:-12h}
|
||||
volumes:
|
||||
- certbot_etc:/etc/letsencrypt
|
||||
- certbot_www:/var/www/certbot
|
||||
depends_on:
|
||||
- webapp-api
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
certbot_etc:
|
||||
certbot_www:
|
||||
|
|
|
|||
589
games/betting.py
589
games/betting.py
|
|
@ -1,589 +0,0 @@
|
|||
import logging
|
||||
import time as _time
|
||||
import hashlib
|
||||
import random
|
||||
import urllib.request
|
||||
import json
|
||||
import gzip
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
import aiohttp
|
||||
|
||||
import config
|
||||
from db import get_conn
|
||||
from games.casino import get_user_length, update_user_length
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_matches_cache: dict[str, tuple[float, list]] = {}
|
||||
_sports_cache: tuple[float, list] | None = None
|
||||
_last_viewed_sport_by_user: dict[int, str] = {}
|
||||
_DRAW_NAMES = {"draw", "tie", "ничья"}
|
||||
|
||||
BET_HISTORY_DAYS = 30
|
||||
_LIQUIPEDIA_CACHE_TTL = 300 # 5 minutes cache to respect Liquipedia rate limits
|
||||
|
||||
|
||||
def _init_bets_db() -> None:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS bets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
match_id TEXT NOT NULL,
|
||||
sport TEXT NOT NULL,
|
||||
home_team TEXT NOT NULL,
|
||||
away_team TEXT NOT NULL,
|
||||
chosen_team TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
odds REAL NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
payout REAL DEFAULT 0,
|
||||
created_ts BIGINT,
|
||||
resolved_ts BIGINT
|
||||
)
|
||||
''')
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id, status)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_match ON bets(match_id, status)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_created ON bets(created_ts)")
|
||||
cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS payout REAL DEFAULT 0")
|
||||
cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS resolved_ts BIGINT")
|
||||
|
||||
|
||||
try:
|
||||
_init_bets_db()
|
||||
except Exception:
|
||||
logger.warning("Could not init bets DB at import time (will retry on first use)")
|
||||
|
||||
|
||||
def remember_user_sport_context(user_id: int, sport_alias: str) -> None:
|
||||
if sport_alias in config.ODDS_SPORTS:
|
||||
_last_viewed_sport_by_user[user_id] = sport_alias
|
||||
|
||||
|
||||
def clear_user_sport_context(user_id: int) -> None:
|
||||
_last_viewed_sport_by_user.pop(user_id, None)
|
||||
|
||||
|
||||
def get_user_sport_context(user_id: int) -> str | None:
|
||||
return _last_viewed_sport_by_user.get(user_id)
|
||||
|
||||
|
||||
async def _fetch_available_sports() -> list[dict]:
|
||||
return [{"key": "dota2", "title": "🎮 Dota 2", "active": True}]
|
||||
|
||||
|
||||
async def fetch_liquipedia_matches() -> list[dict]:
|
||||
global _matches_cache
|
||||
now = _time.time()
|
||||
cached = _matches_cache.get("liquipedia_all")
|
||||
if cached and (now - cached[0]) < _LIQUIPEDIA_CACHE_TTL:
|
||||
return cached[1]
|
||||
|
||||
url = "https://liquipedia.net/dota2/api.php?action=parse&page=Liquipedia:Matches&format=json"
|
||||
headers = {
|
||||
"User-Agent": "Dota2BettingTelegramBot/1.0 (contact: danil@example.com)",
|
||||
"Accept-Encoding": "gzip"
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning(f"Liquipedia API returned {resp.status}")
|
||||
return cached[1] if cached else []
|
||||
|
||||
data = await resp.json()
|
||||
html_content = data['parse']['text']['*']
|
||||
|
||||
soup = BeautifulSoup(html_content, "lxml")
|
||||
match_divs = soup.find_all("div", class_="match-info")
|
||||
|
||||
matches = []
|
||||
for idx, div in enumerate(match_divs):
|
||||
# Tournament
|
||||
tournament_div = div.find("div", class_="match-info-tournament")
|
||||
tournament = tournament_div.get_text(strip=True) if tournament_div else "Unknown Tournament"
|
||||
|
||||
# Teams
|
||||
opponents = div.find_all("div", class_="match-info-header-opponent")
|
||||
team1 = "TBD"
|
||||
team2 = "TBD"
|
||||
winner = None
|
||||
completed = False
|
||||
|
||||
if len(opponents) >= 2:
|
||||
o1, o2 = opponents[0], opponents[1]
|
||||
team1 = o1.get_text(strip=True)
|
||||
team2 = o2.get_text(strip=True)
|
||||
|
||||
c1 = o1.get("class", [])
|
||||
c2 = o2.get("class", [])
|
||||
|
||||
has_win_loss = any("winner" in c or "loser" in c for c in c1 + c2)
|
||||
if has_win_loss:
|
||||
completed = True
|
||||
if any("winner" in c for c in c1):
|
||||
winner = team1
|
||||
elif any("winner" in c for c in c2):
|
||||
winner = team2
|
||||
else:
|
||||
teams = div.find_all("span", class_="block-team")
|
||||
if len(teams) >= 2:
|
||||
team1 = teams[0].get_text(strip=True)
|
||||
team2 = teams[1].get_text(strip=True)
|
||||
|
||||
# Start time
|
||||
timer_span = div.find("span", class_="timer-object")
|
||||
timestamp = ""
|
||||
if timer_span:
|
||||
timestamp = timer_span.get("data-timestamp", "")
|
||||
|
||||
if not team1 or not team2 or team1 == "TBD" or team2 == "TBD":
|
||||
continue
|
||||
|
||||
# ID generation
|
||||
match_id = hashlib.md5(f"lp_{team1}_{team2}_{timestamp}".encode('utf-8')).hexdigest()
|
||||
|
||||
# Deterministic Odds
|
||||
seed = int(hashlib.md5(match_id.encode('utf-8')).hexdigest(), 16) % 10**8
|
||||
r = random.Random(seed)
|
||||
odds_home = round(r.uniform(1.3, 3.2), 2)
|
||||
margin = 1.08
|
||||
prob_home = 1.0 / odds_home
|
||||
prob_away = margin - prob_home
|
||||
if prob_away < 0.1:
|
||||
prob_away = 0.1
|
||||
odds_away = round(1.0 / prob_away, 2)
|
||||
|
||||
matches.append({
|
||||
"id": match_id,
|
||||
"home": team1,
|
||||
"away": team2,
|
||||
"commence": datetime.fromtimestamp(int(timestamp)).isoformat() + "Z" if timestamp else "",
|
||||
"timestamp": timestamp,
|
||||
"completed": completed,
|
||||
"winner": winner,
|
||||
"odds": {
|
||||
team1: odds_home,
|
||||
team2: odds_away
|
||||
},
|
||||
"tournament": tournament
|
||||
})
|
||||
|
||||
_matches_cache["liquipedia_all"] = (now, matches)
|
||||
logger.info(f"Scraped and cached {len(matches)} matches from Liquipedia")
|
||||
return matches
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch matches from Liquipedia")
|
||||
return cached[1] if cached else []
|
||||
|
||||
|
||||
async def fetch_matches(sport_key: str = "esports_dota2") -> list[dict]:
|
||||
all_matches = await fetch_liquipedia_matches()
|
||||
# Return only active (not completed) matches for betting
|
||||
return [m for m in all_matches if not m["completed"]]
|
||||
|
||||
|
||||
def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
|
||||
odds = match.get("odds", {})
|
||||
outcomes: list[dict[str, str | float]] = []
|
||||
added: set[str] = set()
|
||||
|
||||
def append_outcome(code: str, name: str) -> None:
|
||||
if name in odds and name not in added:
|
||||
outcomes.append({"code": code, "name": name, "odds": odds[name]})
|
||||
added.add(name)
|
||||
|
||||
home = match.get("home", "")
|
||||
away = match.get("away", "")
|
||||
append_outcome("1", home)
|
||||
append_outcome("2", away)
|
||||
return outcomes
|
||||
|
||||
|
||||
def describe_match_outcomes(match: dict) -> str:
|
||||
return ", ".join(
|
||||
f"{item['code']}={item['name']} x{item['odds']:.2f}"
|
||||
for item in _get_match_outcomes(match)
|
||||
)
|
||||
|
||||
|
||||
def resolve_match_outcome(match: dict, raw_choice: str) -> str | None:
|
||||
choice = raw_choice.strip()
|
||||
if not choice:
|
||||
return None
|
||||
|
||||
normalized = choice.casefold()
|
||||
outcomes = _get_match_outcomes(match)
|
||||
|
||||
for item in outcomes:
|
||||
if normalized == str(item["code"]).casefold():
|
||||
return str(item["name"])
|
||||
|
||||
for item in outcomes:
|
||||
if normalized == str(item["name"]).casefold():
|
||||
return str(item["name"])
|
||||
|
||||
try:
|
||||
choice_odds = round(float(choice.replace(",", ".")), 2)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
for item in outcomes:
|
||||
if round(float(item["odds"]), 2) == choice_odds:
|
||||
return str(item["name"])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_matches(matches: list[dict], sport_alias: str, sport_label: str) -> str:
|
||||
if not matches:
|
||||
return f"🏟 {sport_label} [{sport_alias}] — матчей не найдено."
|
||||
|
||||
lines = [f"🏟 {sport_label} [{sport_alias}] — ближайшие матчи:\n"]
|
||||
for i, m in enumerate(matches[:10], 1):
|
||||
outcomes = _get_match_outcomes(m)
|
||||
odds_str = "\n".join(
|
||||
f" {item['code']}) {item['name']}: {item['odds']:.2f}"
|
||||
for item in outcomes
|
||||
)
|
||||
|
||||
start_str = ""
|
||||
if m["commence"]:
|
||||
try:
|
||||
dt = datetime.fromisoformat(m["commence"].replace("Z", "+00:00"))
|
||||
msk = dt.astimezone(ZoneInfo(config.DEFAULT_TZ))
|
||||
start_str = f"\n 🕐 {msk.strftime('%d.%m %H:%M')} МСК"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lines.append(f"{i}️⃣ {m['home']} vs {m['away']}{start_str}\n{odds_str}\n")
|
||||
|
||||
lines.append("Ставка: /bet [номер] [исход] [ставка]")
|
||||
lines.append("Исход: 1/2. Можно писать ещё название команды или коэффициент.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _get_sport_label(key: str) -> str:
|
||||
return "🎮 Dota 2"
|
||||
|
||||
|
||||
async def get_formatted_matches(sport_alias: str | None) -> str:
|
||||
matches = await fetch_matches()
|
||||
return format_matches(matches, "dota2", "🎮 Dota 2")
|
||||
|
||||
|
||||
async def get_match_by_index(sport_alias: str, index: int) -> dict | None:
|
||||
matches = await fetch_matches()
|
||||
if 0 < index <= len(matches):
|
||||
return matches[index - 1]
|
||||
return None
|
||||
|
||||
|
||||
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> tuple[bool, str]:
|
||||
current = get_user_length(user_id)
|
||||
if current is None:
|
||||
return False, "Сначала заведи счёт через /penis, братуха."
|
||||
if current <= 0:
|
||||
return False, "🚫 С кредитом ставки не принимаем."
|
||||
if amount <= 0:
|
||||
return False, "Ставка должна быть больше нуля."
|
||||
if amount > config.BET_MAX_AMOUNT:
|
||||
return False, f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
|
||||
if round(amount, 2) > round(current, 2):
|
||||
return False, f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
|
||||
|
||||
team_odds = match["odds"].get(chosen_team)
|
||||
if team_odds is None:
|
||||
return False, f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
|
||||
|
||||
new_length = update_user_length(user_id, -amount)
|
||||
if new_length is None:
|
||||
return False, "Ошибка БД."
|
||||
|
||||
now_ts = int(_time.time())
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('''
|
||||
INSERT INTO bets
|
||||
(user_id, match_id, sport, home_team, away_team,
|
||||
chosen_team, amount, odds, status, payout, created_ts)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', 0, %s)
|
||||
''', (
|
||||
user_id, match["id"], sport, match["home"], match["away"],
|
||||
chosen_team, amount, team_odds, now_ts,
|
||||
))
|
||||
except psycopg2.Error:
|
||||
logger.exception("Failed to place bet")
|
||||
update_user_length(user_id, amount)
|
||||
return False, "Ошибка БД."
|
||||
|
||||
potential = round(amount * team_odds, 1)
|
||||
return True, (
|
||||
f"✅ Ставка принята!\n"
|
||||
f"📌 {chosen_team} (x{team_odds:.2f})\n"
|
||||
f"💰 Ставка: {amount:.1f} см\n"
|
||||
f"🎯 Возможный выигрыш: {potential:.1f} см\n"
|
||||
f"📏 Остаток: {new_length:.1f} см"
|
||||
)
|
||||
|
||||
|
||||
def get_user_bets(user_id: int) -> str:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute('''
|
||||
SELECT * FROM bets
|
||||
WHERE user_id = %s AND status = 'pending'
|
||||
ORDER BY created_ts DESC LIMIT 10
|
||||
''', (user_id,))
|
||||
rows = cur.fetchall()
|
||||
except psycopg2.Error:
|
||||
return "Ошибка БД."
|
||||
|
||||
if not rows:
|
||||
return "У тебя нет активных ставок."
|
||||
|
||||
lines = ["📋 Твои активные ставки:\n"]
|
||||
for r in rows:
|
||||
potential = round(r["amount"] * r["odds"], 1)
|
||||
created = ""
|
||||
if r["created_ts"]:
|
||||
dt = datetime.fromtimestamp(r["created_ts"], tz=ZoneInfo(config.DEFAULT_TZ))
|
||||
created = f" 📅 {dt.strftime('%d.%m %H:%M')}\n"
|
||||
lines.append(
|
||||
f"• {r['home_team']} vs {r['away_team']}\n"
|
||||
f" {r['chosen_team']} (x{r['odds']:.2f}), {r['amount']:.1f} см → {potential:.1f} см\n"
|
||||
f"{created}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_user_bet_history(user_id: int) -> str:
|
||||
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute('''
|
||||
SELECT * FROM bets
|
||||
WHERE user_id = %s AND created_ts >= %s
|
||||
ORDER BY created_ts DESC LIMIT 50
|
||||
''', (user_id, cutoff_ts))
|
||||
rows = cur.fetchall()
|
||||
except psycopg2.Error:
|
||||
logger.exception("Failed to get bet history")
|
||||
return "Ошибка БД."
|
||||
|
||||
if not rows:
|
||||
return "📋 За последний месяц ставок не было."
|
||||
|
||||
total_bet = total_won = total_lost = 0.0
|
||||
wins = losses = pending = 0
|
||||
lines = [f"📋 История ставок за {BET_HISTORY_DAYS} дней:\n"]
|
||||
|
||||
for r in rows:
|
||||
status = r["status"]
|
||||
amount = float(r["amount"])
|
||||
odds_val = float(r["odds"])
|
||||
payout_raw = float(r["payout"] or 0)
|
||||
|
||||
total_bet += amount
|
||||
|
||||
if status == "won":
|
||||
wins += 1
|
||||
actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1)
|
||||
total_won += actual_payout
|
||||
status_icon = "✅"
|
||||
result_text = f"+{actual_payout:.1f} см"
|
||||
elif status == "lost":
|
||||
losses += 1
|
||||
total_lost += amount
|
||||
status_icon = "❌"
|
||||
result_text = f"-{amount:.1f} см"
|
||||
else:
|
||||
pending += 1
|
||||
status_icon = "⏳"
|
||||
result_text = f"ожидание → {round(amount * odds_val, 1):.1f} см"
|
||||
|
||||
created = ""
|
||||
if r["created_ts"]:
|
||||
dt = datetime.fromtimestamp(r["created_ts"], tz=ZoneInfo(config.DEFAULT_TZ))
|
||||
created = dt.strftime('%d.%m %H:%M')
|
||||
|
||||
lines.append(
|
||||
f"{status_icon} {r['home_team']} vs {r['away_team']}\n"
|
||||
f" {r['chosen_team']} (x{odds_val:.2f}) | {amount:.1f} см | {result_text}\n"
|
||||
f" 📅 {created}"
|
||||
)
|
||||
|
||||
net = total_won - total_lost
|
||||
net_sign = "+" if net >= 0 else ""
|
||||
lines.append("")
|
||||
lines.append("━━━ 📊 СТАТИСТИКА ━━━")
|
||||
lines.append(f"🎰 Всего ставок: {len(rows)}")
|
||||
lines.append(f"✅ Побед: {wins}")
|
||||
lines.append(f"❌ Поражений: {losses}")
|
||||
if pending:
|
||||
lines.append(f"⏳ В ожидании: {pending}")
|
||||
lines.append(f"💰 Поставлено: {total_bet:.1f} см")
|
||||
lines.append(f"🏆 Выиграно: +{total_won:.1f} см")
|
||||
lines.append(f"💸 Проиграно: -{total_lost:.1f} см")
|
||||
lines.append(f"📈 Итого: {net_sign}{net:.1f} см")
|
||||
if wins + losses > 0:
|
||||
lines.append(f"📊 Винрейт: {wins / (wins + losses) * 100:.0f}%")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cleanup_old_bets() -> int:
|
||||
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM bets WHERE status != 'pending' AND created_ts < %s",
|
||||
(cutoff_ts,),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
if deleted > 0:
|
||||
logger.info(f"Cleaned up {deleted} old bets")
|
||||
return deleted
|
||||
except psycopg2.Error:
|
||||
logger.exception("Failed to cleanup old bets")
|
||||
return 0
|
||||
|
||||
|
||||
_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)
|
||||
cur.execute("SELECT DISTINCT match_id FROM bets WHERE status = 'pending'")
|
||||
pending = cur.fetchall()
|
||||
except psycopg2.Error:
|
||||
return notifications
|
||||
|
||||
if not pending:
|
||||
return notifications
|
||||
|
||||
all_matches = await fetch_liquipedia_matches()
|
||||
match_map = {m["id"]: m for m in all_matches}
|
||||
now_ts = int(_time.time())
|
||||
|
||||
for row in pending:
|
||||
match_id = row["match_id"]
|
||||
event = match_map.get(match_id)
|
||||
if not event or not event.get("completed"):
|
||||
continue
|
||||
|
||||
winner = event.get("winner")
|
||||
if not winner:
|
||||
continue
|
||||
|
||||
is_draw = (winner.casefold() in _DRAW_NAMES or winner == "Draw")
|
||||
|
||||
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)
|
||||
wcur.execute(
|
||||
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
|
||||
(winnings, now_ts, bet["id"]),
|
||||
)
|
||||
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((
|
||||
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")
|
||||
|
||||
cleanup_old_bets()
|
||||
return notifications
|
||||
|
||||
|
||||
async def debug_sports() -> str:
|
||||
return "🎮 Dota 2 — Scraper Liquipedia активен. Odds API отключен."
|
||||
114
games/casino.py
114
games/casino.py
|
|
@ -1,114 +0,0 @@
|
|||
import logging
|
||||
import random
|
||||
|
||||
import psycopg2.extras
|
||||
|
||||
import config
|
||||
from db import get_conn
|
||||
from .economy import economy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SLOT_SYMBOLS = ["🍒", "🍋", "🔔", "💎", "7️⃣", "🍀"]
|
||||
SLOT_WEIGHTS = [30, 25, 20, 12, 8, 5]
|
||||
|
||||
MULTIPLIERS = {
|
||||
2: (1.2, 1.8),
|
||||
3: (2.0, 3.0),
|
||||
}
|
||||
|
||||
CASINO_WIN_CHANCE = 0.35
|
||||
|
||||
|
||||
def spin_slots() -> tuple[list[str], int]:
|
||||
reels = random.choices(SLOT_SYMBOLS, weights=SLOT_WEIGHTS, k=3)
|
||||
|
||||
if random.random() > CASINO_WIN_CHANCE:
|
||||
while len(set(reels)) < 3:
|
||||
reels = random.choices(SLOT_SYMBOLS, weights=SLOT_WEIGHTS, k=3)
|
||||
return reels, 0
|
||||
|
||||
counts = {}
|
||||
for s in reels:
|
||||
counts[s] = counts.get(s, 0) + 1
|
||||
return reels, max(counts.values())
|
||||
|
||||
|
||||
def get_user_length(user_id: int) -> float | None:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
|
||||
row = cur.fetchone()
|
||||
return float(row['balance']) if row else None
|
||||
except Exception:
|
||||
logger.exception("casino: failed to get balance")
|
||||
return None
|
||||
|
||||
|
||||
def update_user_length(user_id: int, delta: float) -> float | None:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
new_balance = round(float(row['balance']) + delta, 1)
|
||||
if delta < 0 and new_balance < 0:
|
||||
new_balance = 0.0
|
||||
wcur = conn.cursor()
|
||||
wcur.execute(
|
||||
'UPDATE user_balances SET balance = %s WHERE user_id = %s',
|
||||
(new_balance, user_id),
|
||||
)
|
||||
return new_balance
|
||||
except Exception:
|
||||
logger.exception("casino: failed to update balance")
|
||||
return None
|
||||
|
||||
|
||||
def play_casino(user_id: int, bet: float) -> str:
|
||||
current = economy.get_user_balance(user_id)
|
||||
|
||||
if bet > config.CASINO_MAX_BET:
|
||||
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
|
||||
if bet <= 0:
|
||||
return "Ставка должна быть больше нуля, фраер."
|
||||
if round(bet, 2) > round(current, 2):
|
||||
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
|
||||
|
||||
reels, matches = spin_slots()
|
||||
slots_display = " | ".join(reels)
|
||||
|
||||
if matches >= 2:
|
||||
min_mult, max_mult = MULTIPLIERS[matches]
|
||||
multiplier = round(random.uniform(min_mult, max_mult), 1)
|
||||
winnings = round(bet * multiplier, 1)
|
||||
economy.update_balance(user_id, winnings, 'casino_win', f'Выигрыш в казино x{multiplier}')
|
||||
new_balance = economy.get_user_balance(user_id)
|
||||
|
||||
if matches == 3:
|
||||
return (
|
||||
f"🎰 {slots_display}\n\n"
|
||||
f"🎉 ДЖЕКПОТ!!! Три одинаковых!\n"
|
||||
f"Множитель: x{multiplier}\n"
|
||||
f"Выигрыш: +{winnings:.1f} см\n"
|
||||
f"💰 Баланс: {new_balance:.1f} см"
|
||||
)
|
||||
return (
|
||||
f"🎰 {slots_display}\n\n"
|
||||
f"✅ Выигрыш! Две совпали.\n"
|
||||
f"Множитель: x{multiplier}\n"
|
||||
f"Выигрыш: +{winnings:.1f} см\n"
|
||||
f"💰 Баланс: {new_balance:.1f} см"
|
||||
)
|
||||
else:
|
||||
economy.update_balance(user_id, -bet, 'casino_loss', f'Проигрыш в казино -{bet} см')
|
||||
new_balance = economy.get_user_balance(user_id)
|
||||
return (
|
||||
f"🎰 {slots_display}\n\n"
|
||||
f"❌ Мимо, братуха.\n"
|
||||
f"Проигрыш: -{bet:.1f} см\n"
|
||||
f"💰 Баланс: {new_balance:.1f} см"
|
||||
)
|
||||
716
games/economy.py
716
games/economy.py
|
|
@ -1,716 +0,0 @@
|
|||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict
|
||||
|
||||
import psycopg2.extras
|
||||
|
||||
from db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EconomyManager:
|
||||
def __init__(self):
|
||||
self.init_db()
|
||||
|
||||
def init_db(self):
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS user_balances (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
balance REAL DEFAULT 0.0,
|
||||
daily_income REAL DEFAULT 0.0,
|
||||
last_daily_reset DATE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS deposits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
amount REAL,
|
||||
interest_rate REAL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
matures_at TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
)
|
||||
''')
|
||||
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS loans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
amount REAL,
|
||||
interest_rate REAL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
due_at TIMESTAMP,
|
||||
is_repaid BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
''')
|
||||
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
from_user_id BIGINT,
|
||||
to_user_id BIGINT,
|
||||
amount REAL,
|
||||
transaction_type TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS taxes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
amount REAL,
|
||||
tax_date DATE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS economy_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS daily_activity (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
activity_date DATE,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
last_activity_time TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, activity_date)
|
||||
)
|
||||
''')
|
||||
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS central_bank (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
capital REAL DEFAULT 1000.0,
|
||||
total_taxes_collected REAL DEFAULT 0.0,
|
||||
total_inflation_adjustments REAL DEFAULT 0.0,
|
||||
total_emissions REAL DEFAULT 0.0,
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (id)
|
||||
)
|
||||
''')
|
||||
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS penis_stats (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
last_used_ts BIGINT
|
||||
)
|
||||
''')
|
||||
|
||||
default_settings = {
|
||||
'central_bank_rate': '7.5',
|
||||
'deposit_min_amount': '10.0',
|
||||
'deposit_min_days': '7',
|
||||
'loan_max_amount': '100.0',
|
||||
'loan_interest_rate': '15.0',
|
||||
'tax_rate': '13.0',
|
||||
'transfer_limit_percent': '20.0',
|
||||
'inflation_rate': '5.0',
|
||||
'vanomasa_cooldown': '30',
|
||||
'message_reward_rate': '0.01',
|
||||
'last_activity_payout': '',
|
||||
}
|
||||
|
||||
for key, value in default_settings.items():
|
||||
cur.execute('''
|
||||
INSERT INTO economy_settings (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
''', (key, value))
|
||||
|
||||
cur.execute('''
|
||||
INSERT INTO central_bank (id, capital) VALUES (1, 1000.0)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
''')
|
||||
|
||||
def get_user_balance(self, user_id: int) -> float:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
|
||||
result = cur.fetchone()
|
||||
return result['balance'] if result else 0.0
|
||||
|
||||
def update_balance(self, user_id: int, amount: float, transaction_type: str, description: str = ""):
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('''
|
||||
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||
VALUES (%s, %s, 0.0, CURRENT_DATE)
|
||||
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
|
||||
''', (user_id, amount, amount))
|
||||
|
||||
cur.execute('''
|
||||
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
''', (user_id, user_id, amount, transaction_type, description))
|
||||
|
||||
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
|
||||
|
||||
from_balance = self.get_user_balance(from_user_id)
|
||||
limit = round(from_balance * 0.2, 2)
|
||||
if round(amount, 2) > limit or round(from_balance, 2) < round(amount, 2):
|
||||
return False
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
'UPDATE user_balances SET balance = balance - %s WHERE user_id = %s',
|
||||
(amount, from_user_id),
|
||||
)
|
||||
cur.execute('''
|
||||
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||
VALUES (%s, %s, 0.0, CURRENT_DATE)
|
||||
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
|
||||
''', (to_user_id, amount, amount))
|
||||
cur.execute('''
|
||||
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||
VALUES (%s, %s, %s, 'transfer', %s)
|
||||
''', (from_user_id, to_user_id, amount, description))
|
||||
|
||||
logger.info(f"Transfer: {amount} from user {from_user_id} to user {to_user_id}")
|
||||
return True
|
||||
|
||||
def create_deposit(self, user_id: int, amount: float, days: int) -> tuple[bool, str]:
|
||||
if amount <= 0:
|
||||
return False, "Сумма вклада должна быть больше нуля."
|
||||
|
||||
balance = self.get_user_balance(user_id)
|
||||
min_deposit = float(self.get_setting('deposit_min_amount'))
|
||||
|
||||
if round(amount, 2) < round(min_deposit, 2):
|
||||
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'))
|
||||
matures_at = datetime.now() + timedelta(days=days)
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
'UPDATE user_balances SET balance = balance - %s WHERE user_id = %s',
|
||||
(amount, user_id),
|
||||
)
|
||||
cur.execute('''
|
||||
UPDATE central_bank SET capital = capital + %s, last_updated = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
''', (amount,))
|
||||
cur.execute('''
|
||||
INSERT INTO deposits (user_id, amount, interest_rate, matures_at)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
''', (user_id, amount, central_bank_rate, matures_at))
|
||||
|
||||
logger.info(f"Deposit created: user {user_id}, amount {amount}, rate {central_bank_rate}%")
|
||||
return True, "Вклад успешно открыт!"
|
||||
|
||||
def create_loan(self, user_id: int, amount: float, days: int) -> bool:
|
||||
if amount <= 0:
|
||||
return False
|
||||
|
||||
max_loan = float(self.get_setting('loan_max_amount'))
|
||||
loan_rate = float(self.get_setting('loan_interest_rate'))
|
||||
|
||||
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
|
||||
|
||||
due_at = datetime.now() + timedelta(days=days)
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('''
|
||||
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
''', (amount,))
|
||||
cur.execute('''
|
||||
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||
VALUES (%s, %s, 0.0, CURRENT_DATE)
|
||||
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
|
||||
''', (user_id, amount, amount))
|
||||
cur.execute('''
|
||||
INSERT INTO loans (user_id, amount, interest_rate, due_at)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
''', (user_id, amount, loan_rate, due_at))
|
||||
|
||||
logger.info(f"Loan created: user {user_id}, amount {amount}, rate {loan_rate}%")
|
||||
return True
|
||||
|
||||
def repay_loan(self, user_id: int, loan_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
'SELECT amount, interest_rate, is_repaid FROM loans WHERE id = %s AND user_id = %s',
|
||||
(loan_id, user_id),
|
||||
)
|
||||
loan = cur.fetchone()
|
||||
|
||||
if not loan or loan['is_repaid']:
|
||||
return False
|
||||
|
||||
total_repayment = round(loan['amount'] * (1 + loan['interest_rate'] / 100), 2)
|
||||
|
||||
balance = self.get_user_balance(user_id)
|
||||
if round(balance, 2) < total_repayment:
|
||||
return False
|
||||
|
||||
wcur = conn.cursor()
|
||||
wcur.execute(
|
||||
'UPDATE user_balances SET balance = balance - %s WHERE user_id = %s',
|
||||
(total_repayment, user_id),
|
||||
)
|
||||
wcur.execute('''
|
||||
UPDATE central_bank SET capital = capital + %s, last_updated = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
''', (total_repayment,))
|
||||
wcur.execute('UPDATE loans SET is_repaid = TRUE WHERE id = %s', (loan_id,))
|
||||
|
||||
logger.info(f"Loan repaid: user {user_id}, loan {loan_id}, amount {total_repayment}")
|
||||
return True
|
||||
|
||||
def collect_daily_taxes(self):
|
||||
tax_rate = float(self.get_setting('tax_rate')) / 100
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute('''
|
||||
SELECT user_id, daily_income FROM user_balances
|
||||
WHERE daily_income > 0 AND (last_daily_reset != CURRENT_DATE OR last_daily_reset IS NULL)
|
||||
''')
|
||||
users = cur.fetchall()
|
||||
|
||||
total_taxes_collected = 0.0
|
||||
wcur = conn.cursor()
|
||||
|
||||
for row in users:
|
||||
user_id = row['user_id']
|
||||
daily_income = row['daily_income']
|
||||
tax_amount = daily_income * tax_rate
|
||||
if tax_amount <= 0:
|
||||
continue
|
||||
|
||||
wcur.execute('''
|
||||
UPDATE user_balances
|
||||
SET balance = balance - %s, daily_income = 0, last_daily_reset = CURRENT_DATE
|
||||
WHERE user_id = %s
|
||||
''', (tax_amount, user_id))
|
||||
wcur.execute(
|
||||
'INSERT INTO taxes (user_id, amount, tax_date) VALUES (%s, %s, CURRENT_DATE)',
|
||||
(user_id, tax_amount),
|
||||
)
|
||||
total_taxes_collected += tax_amount
|
||||
|
||||
if total_taxes_collected > 0:
|
||||
wcur.execute('''
|
||||
UPDATE central_bank
|
||||
SET capital = capital + %s, total_taxes_collected = total_taxes_collected + %s
|
||||
WHERE id = 1
|
||||
''', (total_taxes_collected, total_taxes_collected))
|
||||
|
||||
logger.info(f"Daily taxes collected: {total_taxes_collected:.2f} cm from {len(users)} users")
|
||||
|
||||
def count_message(self, user_id: int):
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('''
|
||||
INSERT INTO daily_activity (user_id, activity_date, message_count, last_activity_time)
|
||||
VALUES (%s, %s, 1, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (user_id, activity_date) DO UPDATE SET
|
||||
message_count = daily_activity.message_count + 1,
|
||||
last_activity_time = CURRENT_TIMESTAMP
|
||||
''', (user_id, today))
|
||||
|
||||
def pay_daily_activity_rewards(self) -> int:
|
||||
reward_rate = float(self.get_setting('message_reward_rate'))
|
||||
last_payout = self.get_setting('last_activity_payout')
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
if last_payout == today:
|
||||
return 0
|
||||
|
||||
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute('''
|
||||
SELECT user_id, message_count FROM daily_activity
|
||||
WHERE activity_date = %s AND message_count > 0
|
||||
''', (yesterday,))
|
||||
users = cur.fetchall()
|
||||
|
||||
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)
|
||||
VALUES (%s, %s, 0.0, CURRENT_DATE)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
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 (0, %s, %s, 'activity_reward', %s)
|
||||
''', (user_id, reward, f'Вознаграждение за {message_count} сообщений'))
|
||||
cb_capital -= reward
|
||||
total_paid += reward
|
||||
|
||||
wcur.execute(
|
||||
'UPDATE economy_settings SET value = %s WHERE key = %s',
|
||||
(today, 'last_activity_payout'),
|
||||
)
|
||||
|
||||
logger.info(f"Activity rewards paid: {total_paid:.2f} cm to {len(users)} users")
|
||||
return len(users)
|
||||
|
||||
def get_central_bank_stats(self) -> Dict:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute('''
|
||||
SELECT capital, total_taxes_collected, total_inflation_adjustments, total_emissions
|
||||
FROM central_bank WHERE id = 1
|
||||
''')
|
||||
result = cur.fetchone()
|
||||
|
||||
if result:
|
||||
return {
|
||||
'capital': result['capital'],
|
||||
'total_taxes_collected': result['total_taxes_collected'],
|
||||
'total_inflation_adjustments': result['total_inflation_adjustments'],
|
||||
'total_emissions': result['total_emissions'],
|
||||
}
|
||||
return {
|
||||
'capital': 1000.0,
|
||||
'total_taxes_collected': 0.0,
|
||||
'total_inflation_adjustments': 0.0,
|
||||
'total_emissions': 0.0,
|
||||
}
|
||||
|
||||
def update_central_bank(self, capital_change: float, transaction_type: str, description: str = ""):
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('''
|
||||
UPDATE central_bank SET capital = capital + %s, last_updated = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
''', (capital_change,))
|
||||
|
||||
if transaction_type == 'tax':
|
||||
cur.execute('''
|
||||
UPDATE central_bank
|
||||
SET total_taxes_collected = total_taxes_collected + %s WHERE id = 1
|
||||
''', (abs(capital_change),))
|
||||
elif transaction_type == 'inflation':
|
||||
cur.execute('''
|
||||
UPDATE central_bank
|
||||
SET total_inflation_adjustments = total_inflation_adjustments + %s WHERE id = 1
|
||||
''', (abs(capital_change),))
|
||||
elif transaction_type == 'emission':
|
||||
cur.execute('''
|
||||
UPDATE central_bank
|
||||
SET total_emissions = total_emissions + %s WHERE id = 1
|
||||
''', (abs(capital_change),))
|
||||
|
||||
logger.info(f"Central bank updated: {capital_change} ({transaction_type})")
|
||||
|
||||
def emit_money(self, amount: float, reason: str = "") -> bool:
|
||||
if round(self.get_central_bank_stats()['capital'], 2) < round(amount, 2):
|
||||
return False
|
||||
self.update_central_bank(-amount, 'emission', reason)
|
||||
logger.info(f"Money emitted: {amount} cm ({reason})")
|
||||
return True
|
||||
|
||||
def vanomasa(self) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT value FROM economy_settings WHERE key = %s", ('last_vanomasa',))
|
||||
last_vanomasa = cur.fetchone()
|
||||
|
||||
if last_vanomasa and last_vanomasa['value']:
|
||||
try:
|
||||
last_date = datetime.strptime(last_vanomasa['value'], '%Y-%m-%d')
|
||||
cooldown_days = int(self.get_setting('vanomasa_cooldown'))
|
||||
if datetime.now() - last_date < timedelta(days=cooldown_days):
|
||||
return False
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
wcur = conn.cursor()
|
||||
wcur.execute('UPDATE user_balances SET balance = 0, daily_income = 0')
|
||||
wcur.execute('''
|
||||
INSERT INTO economy_settings (key, value, updated_at) VALUES (%s, %s, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
|
||||
''', ('last_vanomasa', today))
|
||||
|
||||
logger.warning("VANOMASA: All balances reset to zero!")
|
||||
return True
|
||||
|
||||
def get_setting(self, key: str) -> str:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute('SELECT value FROM economy_settings WHERE key = %s', (key,))
|
||||
result = cur.fetchone()
|
||||
return result['value'] if result else '0'
|
||||
|
||||
def update_setting(self, key: str, value: str):
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('''
|
||||
INSERT INTO economy_settings (key, value, updated_at) VALUES (%s, %s, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
|
||||
''', (key, value))
|
||||
|
||||
def get_user_stats(self, user_id: int) -> Dict:
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
|
||||
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
|
||||
row = cur.fetchone()
|
||||
balance = row['balance'] if row else 0.0
|
||||
|
||||
cur.execute('''
|
||||
SELECT COUNT(*) AS cnt, COALESCE(SUM(amount), 0) AS total
|
||||
FROM deposits WHERE user_id = %s AND is_active = TRUE
|
||||
''', (user_id,))
|
||||
deposits = cur.fetchone()
|
||||
|
||||
cur.execute('''
|
||||
SELECT COUNT(*) AS cnt, COALESCE(SUM(amount), 0) AS total
|
||||
FROM loans WHERE user_id = %s AND is_repaid = FALSE
|
||||
''', (user_id,))
|
||||
loans = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
'SELECT message_count FROM daily_activity WHERE user_id = %s AND activity_date = %s',
|
||||
(user_id, today),
|
||||
)
|
||||
today_row = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
'SELECT message_count FROM daily_activity WHERE user_id = %s AND activity_date = %s',
|
||||
(user_id, yesterday),
|
||||
)
|
||||
yesterday_row = cur.fetchone()
|
||||
|
||||
return {
|
||||
'balance': balance,
|
||||
'active_deposits_count': deposits['cnt'] or 0,
|
||||
'active_deposits_sum': deposits['total'] or 0.0,
|
||||
'active_loans_count': loans['cnt'] or 0,
|
||||
'active_loans_sum': loans['total'] or 0.0,
|
||||
'today_messages': today_row['message_count'] if today_row else 0,
|
||||
'yesterday_messages': yesterday_row['message_count'] if yesterday_row else 0,
|
||||
}
|
||||
|
||||
def regulate_inflation(self):
|
||||
inflation_rate = float(self.get_setting('inflation_rate'))
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute('SELECT SUM(balance) AS total_balance, COUNT(*) AS user_count FROM user_balances')
|
||||
result = cur.fetchone()
|
||||
total_supply = result['total_balance'] or 0.0
|
||||
|
||||
cur.execute('SELECT capital FROM central_bank WHERE id = 1')
|
||||
cb_result = cur.fetchone()
|
||||
cb_capital = cb_result['capital'] if cb_result else 1000.0
|
||||
|
||||
if inflation_rate > 10.0 and total_supply > 1000 and cb_capital > 100:
|
||||
buyback_amount = min(total_supply * 0.05, cb_capital * 0.1)
|
||||
self.update_central_bank(-buyback_amount, 'inflation', f'Anti-inflation: {buyback_amount:.2f} cm')
|
||||
new_tax_rate = min(20.0, 13.0 + (inflation_rate - 10.0) * 0.5)
|
||||
self.update_setting('tax_rate', str(new_tax_rate))
|
||||
logger.info(f"Inflation regulation: bought back {buyback_amount:.2f} cm, tax={new_tax_rate}%")
|
||||
|
||||
elif inflation_rate < 2.0 and total_supply < 500 and cb_capital > 200:
|
||||
emission_amount = cb_capital * 0.05
|
||||
self.update_central_bank(-emission_amount, 'emission', f'Stimulus: {emission_amount:.2f} cm')
|
||||
new_tax_rate = max(10.0, 13.0 - (2.0 - inflation_rate) * 1.5)
|
||||
self.update_setting('tax_rate', str(new_tax_rate))
|
||||
logger.info(f"Economic stimulus: emitted {emission_amount:.2f} cm, tax={new_tax_rate}%")
|
||||
|
||||
def play_penis(self, user_id: int, display_name: str, now_ts: int,
|
||||
start_length: float, cooldown_seconds: int,
|
||||
min_delta: float, max_delta: float) -> dict:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
|
||||
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
|
||||
row = cur.fetchone()
|
||||
current_balance = row['balance'] if row else start_length
|
||||
|
||||
cur.execute(
|
||||
'SELECT last_used_ts, display_name FROM penis_stats WHERE user_id = %s',
|
||||
(user_id,),
|
||||
)
|
||||
ps_row = cur.fetchone()
|
||||
last_used_ts = ps_row['last_used_ts'] if ps_row else None
|
||||
|
||||
if last_used_ts is not None:
|
||||
next_ts = int(last_used_ts) + cooldown_seconds
|
||||
if now_ts < next_ts:
|
||||
if ps_row and ps_row['display_name'] != display_name:
|
||||
wcur = conn.cursor()
|
||||
wcur.execute(
|
||||
'UPDATE penis_stats SET display_name = %s WHERE user_id = %s',
|
||||
(display_name, user_id),
|
||||
)
|
||||
return {'allowed': False, 'left_seconds': next_ts - now_ts}
|
||||
|
||||
delta = round(random.uniform(min_delta, max_delta), 1)
|
||||
sign = random.choice([-1, 1])
|
||||
new_balance = round(max(0.1, current_balance + sign * delta), 1)
|
||||
|
||||
wcur = conn.cursor()
|
||||
wcur.execute('''
|
||||
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||
VALUES (%s, %s, 0.0, CURRENT_DATE)
|
||||
ON CONFLICT (user_id) DO UPDATE SET balance = %s
|
||||
''', (user_id, new_balance, new_balance))
|
||||
|
||||
sign_label = f'+{delta:.1f}' if sign > 0 else f'-{delta:.1f}'
|
||||
wcur.execute('''
|
||||
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||
VALUES (%s, %s, %s, 'penis_game', %s)
|
||||
''', (user_id, user_id, sign * delta, f'Игра /penis: {sign_label} см'))
|
||||
|
||||
if ps_row is None:
|
||||
wcur.execute(
|
||||
'INSERT INTO penis_stats (user_id, display_name, last_used_ts) VALUES (%s, %s, %s)',
|
||||
(user_id, display_name, now_ts),
|
||||
)
|
||||
else:
|
||||
wcur.execute(
|
||||
'UPDATE penis_stats SET display_name = %s, last_used_ts = %s WHERE user_id = %s',
|
||||
(display_name, now_ts, user_id),
|
||||
)
|
||||
|
||||
return {'allowed': True, 'new_balance': new_balance, 'delta': delta, 'sign': sign}
|
||||
|
||||
def get_penis_top(self, limit: int) -> list:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute('''
|
||||
SELECT ub.user_id, ps.display_name, ub.balance
|
||||
FROM user_balances ub
|
||||
INNER JOIN penis_stats ps ON ub.user_id = ps.user_id
|
||||
ORDER BY ub.balance DESC, ub.user_id ASC
|
||||
LIMIT %s
|
||||
''', (limit,))
|
||||
rows = cur.fetchall()
|
||||
return [{'user_id': r['user_id'], 'display_name': r['display_name'], 'balance': r['balance']}
|
||||
for r in rows]
|
||||
|
||||
def process_matured_deposits(self) -> int:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute('''
|
||||
SELECT id, user_id, amount, interest_rate
|
||||
FROM deposits
|
||||
WHERE is_active = TRUE AND matures_at <= NOW()
|
||||
''')
|
||||
matured = cur.fetchall()
|
||||
|
||||
paid = 0
|
||||
wcur = conn.cursor()
|
||||
for row in matured:
|
||||
dep_id = row['id']
|
||||
user_id = row['user_id']
|
||||
amount = row['amount']
|
||||
rate = row['interest_rate']
|
||||
payout = round(amount * (1 + rate / 100 * 1 / 365 * 30), 2)
|
||||
|
||||
wcur.execute('''
|
||||
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||
VALUES (%s, %s, 0.0, CURRENT_DATE)
|
||||
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
|
||||
''', (user_id, payout, payout))
|
||||
wcur.execute('''
|
||||
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
''', (payout,))
|
||||
wcur.execute('UPDATE deposits SET is_active = FALSE WHERE id = %s', (dep_id,))
|
||||
wcur.execute('''
|
||||
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||
VALUES (0, %s, %s, 'deposit_payout', %s)
|
||||
''', (user_id, payout, f'Выплата вклада #{dep_id}: {amount:.1f} + проценты = {payout:.2f} см'))
|
||||
paid += 1
|
||||
|
||||
if paid:
|
||||
logger.info(f"Matured deposits paid out: {paid}")
|
||||
return paid
|
||||
|
||||
|
||||
economy = EconomyManager()
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import logging
|
||||
import aiohttp
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
||||
|
||||
FORTUNE_PROMPT = (
|
||||
"Ты — уличный гадатель с района, базаришь на фене. "
|
||||
"Пользователь хочет погадать на тему. Выдай шуточное гадание, "
|
||||
"коротко и дерзко, 2-3 предложения максимум. "
|
||||
"В конце ОБЯЗАТЕЛЬНО напиши на отдельной строке в формате:\n"
|
||||
"MEM_UP: <текст сверху для мема, максимум 4 слова>\n"
|
||||
"MEM_DOWN: <текст снизу для мема, максимум 4 слова>\n"
|
||||
"Тексты для мема должны быть смешными и короткими."
|
||||
)
|
||||
|
||||
|
||||
async def generate_fortune(topic: str) -> tuple[str, str, str]:
|
||||
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
||||
payload = {
|
||||
"messages": [
|
||||
{"role": "system", "content": FORTUNE_PROMPT},
|
||||
{"role": "user", "content": f"Погадай мне на: {topic}"},
|
||||
],
|
||||
"max_tokens": 200,
|
||||
"temperature": 0.9,
|
||||
"top_p": 0.9,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
data = await resp.json()
|
||||
text = data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
up_text = ""
|
||||
down_text = ""
|
||||
fortune_lines = []
|
||||
|
||||
for line in text.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.upper().startswith("MEM_UP:"):
|
||||
up_text = stripped.split(":", 1)[1].strip()
|
||||
elif stripped.upper().startswith("MEM_DOWN:"):
|
||||
down_text = stripped.split(":", 1)[1].strip()
|
||||
else:
|
||||
fortune_lines.append(line)
|
||||
|
||||
fortune_text = "\n".join(fortune_lines).strip()
|
||||
|
||||
if not up_text:
|
||||
up_text = "Гадание"
|
||||
if not down_text:
|
||||
down_text = "по фене"
|
||||
|
||||
return fortune_text, up_text, down_text
|
||||
320
games/nude.py
320
games/nude.py
|
|
@ -1,320 +0,0 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from pathlib import Path, PurePosixPath
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
from aiogram import types
|
||||
|
||||
import config
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CAPTIONS = [
|
||||
"Шо ты блять псюнчик решил свой подергать?",
|
||||
"Решил подрочить сука?",
|
||||
"гнойный писюн у тебя кнч, да",
|
||||
"Ух нихуя, это ШПАЧИХА!?",
|
||||
]
|
||||
|
||||
LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts"
|
||||
LESAINT_REFERER = "https://lesaintdesseins.fr/"
|
||||
REQUEST_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Referer": LESAINT_REFERER,
|
||||
}
|
||||
|
||||
_NUDE_HISTORY_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
def _resolve_int_setting(name: str, default: int) -> int:
|
||||
value = getattr(config, name, None)
|
||||
if value is None:
|
||||
value = os.getenv(name, str(default))
|
||||
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid %s=%r, using default %s", name, value, default)
|
||||
return default
|
||||
|
||||
|
||||
def _resolve_path_setting(name: str, default: str) -> Path:
|
||||
value = getattr(config, name, None)
|
||||
if value is None:
|
||||
value = os.getenv(name, default)
|
||||
|
||||
if isinstance(value, Path):
|
||||
return value
|
||||
|
||||
return Path(str(value))
|
||||
|
||||
|
||||
NUDE_HISTORY_PATH = _resolve_path_setting("NUDE_HISTORY_PATH", "/db/nude_history.json")
|
||||
NUDE_POSTS_PER_PAGE = _resolve_int_setting("NUDE_POSTS_PER_PAGE", 100)
|
||||
NUDE_POSTS_POOL_TARGET = _resolve_int_setting("NUDE_POSTS_POOL_TARGET", 1200)
|
||||
NUDE_MAX_POSTS_TO_SCAN = _resolve_int_setting("NUDE_MAX_POSTS_TO_SCAN", 2000)
|
||||
NUDE_MIN_UNSEEN_POOL = _resolve_int_setting("NUDE_MIN_UNSEEN_POOL", 100)
|
||||
NUDE_HISTORY_MAX_SIZE = _resolve_int_setting("NUDE_HISTORY_MAX_SIZE", 5000)
|
||||
|
||||
|
||||
def _extract_photo_candidates(posts: list[dict]) -> list[dict]:
|
||||
candidates = []
|
||||
seen_urls = set()
|
||||
|
||||
for post in posts:
|
||||
embedded = post.get("_embedded") or {}
|
||||
media_items = embedded.get("wp:featuredmedia") or []
|
||||
|
||||
for media in media_items:
|
||||
if media.get("media_type") != "image":
|
||||
continue
|
||||
|
||||
media_details = media.get("media_details") or {}
|
||||
sizes = media_details.get("sizes") or {}
|
||||
image_url = (
|
||||
(sizes.get("full") or {}).get("source_url")
|
||||
or media.get("source_url")
|
||||
)
|
||||
|
||||
if not image_url or image_url in seen_urls:
|
||||
continue
|
||||
|
||||
seen_urls.add(image_url)
|
||||
candidates.append(
|
||||
{
|
||||
"image_url": image_url,
|
||||
"post_url": post.get("link", LESAINT_REFERER),
|
||||
"title": (post.get("title") or {}).get("rendered", "").strip(),
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _guess_filename(image_url: str) -> str:
|
||||
parsed = urlparse(image_url)
|
||||
filename = PurePosixPath(parsed.path).name or "nude.jpg"
|
||||
|
||||
if "." not in filename:
|
||||
filename = f"{filename}.jpg"
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def _load_nude_history() -> dict:
|
||||
path = NUDE_HISTORY_PATH
|
||||
if not path.exists():
|
||||
return {"sent_images": []}
|
||||
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.exception("Failed to load nude history")
|
||||
return {"sent_images": []}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {"sent_images": []}
|
||||
|
||||
sent_images = data.get("sent_images")
|
||||
if not isinstance(sent_images, list):
|
||||
sent_images = []
|
||||
|
||||
cleaned = [item for item in sent_images if isinstance(item, str) and item.strip()]
|
||||
return {"sent_images": cleaned[-NUDE_HISTORY_MAX_SIZE :]}
|
||||
|
||||
|
||||
def _save_nude_history(data: dict) -> None:
|
||||
path = NUDE_HISTORY_PATH
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_name(f"{path.name}.tmp")
|
||||
with tmp_path.open("w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||
tmp_path.replace(path)
|
||||
|
||||
|
||||
def _remember_sent_image(history: dict, image_url: str) -> None:
|
||||
sent_images = history.get("sent_images")
|
||||
if not isinstance(sent_images, list):
|
||||
sent_images = []
|
||||
|
||||
sent_images = [item for item in sent_images if item != image_url]
|
||||
sent_images.append(image_url)
|
||||
history["sent_images"] = sent_images[-NUDE_HISTORY_MAX_SIZE :]
|
||||
|
||||
|
||||
async def _fetch_posts_page(
|
||||
session: aiohttp.ClientSession, page: int, per_page: int
|
||||
) -> tuple[list[dict], int | None]:
|
||||
params = {
|
||||
"page": str(page),
|
||||
"per_page": str(per_page),
|
||||
"orderby": "date",
|
||||
"order": "desc",
|
||||
"_embed": "wp:featuredmedia",
|
||||
}
|
||||
|
||||
async with session.get(LESAINT_POSTS_API, params=params, headers=REQUEST_HEADERS) as resp:
|
||||
if resp.status == 400 and page > 1:
|
||||
body = await resp.text()
|
||||
if "rest_post_invalid_page_number" in body:
|
||||
return [], None
|
||||
raise RuntimeError(f"API returned 400: {body[:300]}")
|
||||
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
raise RuntimeError(f"API returned {resp.status}: {body[:300]}")
|
||||
|
||||
total_pages_header = resp.headers.get("X-WP-TotalPages")
|
||||
total_pages = (
|
||||
int(total_pages_header)
|
||||
if total_pages_header and total_pages_header.isdigit()
|
||||
else None
|
||||
)
|
||||
posts = await resp.json(content_type=None)
|
||||
|
||||
if not isinstance(posts, list):
|
||||
raise RuntimeError("Unexpected API response format")
|
||||
|
||||
return posts, total_pages
|
||||
|
||||
|
||||
async def _fetch_latest_photo_candidates(
|
||||
session: aiohttp.ClientSession, sent_urls: set[str]
|
||||
) -> list[dict]:
|
||||
per_page = max(1, min(NUDE_POSTS_PER_PAGE, 100))
|
||||
max_scan = max(NUDE_POSTS_POOL_TARGET, NUDE_MAX_POSTS_TO_SCAN)
|
||||
max_pages = max(1, (max_scan + per_page - 1) // per_page)
|
||||
|
||||
candidates = []
|
||||
seen_urls = set()
|
||||
unseen_count = 0
|
||||
total_pages = None
|
||||
|
||||
for page in range(1, max_pages + 1):
|
||||
if total_pages is not None and page > total_pages:
|
||||
break
|
||||
|
||||
posts, reported_total_pages = await _fetch_posts_page(session, page, per_page)
|
||||
if reported_total_pages is not None:
|
||||
total_pages = reported_total_pages
|
||||
if not posts:
|
||||
break
|
||||
|
||||
for candidate in _extract_photo_candidates(posts):
|
||||
image_url = candidate["image_url"]
|
||||
if image_url in seen_urls:
|
||||
continue
|
||||
|
||||
seen_urls.add(image_url)
|
||||
candidates.append(candidate)
|
||||
|
||||
if image_url not in sent_urls:
|
||||
unseen_count += 1
|
||||
|
||||
if len(candidates) >= NUDE_POSTS_POOL_TARGET and unseen_count >= NUDE_MIN_UNSEEN_POOL:
|
||||
break
|
||||
if len(candidates) >= max_scan:
|
||||
break
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _pick_unsent_candidate(candidates: list[dict], sent_urls: set[str]) -> dict | None:
|
||||
unseen = [candidate for candidate in candidates if candidate["image_url"] not in sent_urls]
|
||||
if not unseen:
|
||||
return None
|
||||
return random.choice(unseen)
|
||||
|
||||
|
||||
async def _download_photo(
|
||||
session: aiohttp.ClientSession, image_url: str
|
||||
) -> tuple[bytes | None, str]:
|
||||
async with session.get(image_url, headers=REQUEST_HEADERS) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning("Image download failed for %s: %s", image_url, resp.status)
|
||||
return None, _guess_filename(image_url)
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
if "image" not in content_type.lower():
|
||||
logger.warning(
|
||||
"Unexpected content type for %s: %s", image_url, content_type
|
||||
)
|
||||
return None, _guess_filename(image_url)
|
||||
|
||||
return await resp.read(), _guess_filename(image_url)
|
||||
|
||||
|
||||
async def handle_nude_cmd(message: types.Message):
|
||||
args = message.text.split(maxsplit=1)
|
||||
|
||||
if len(args) > 1 and args[1].strip() == "-h":
|
||||
help_msg = (
|
||||
"🔥 <b>Справка по команде /nude:</b>\n\n"
|
||||
"Берет случайную фотку из последних постов сайта le saint des seins.\n"
|
||||
"• Источник: <code>https://lesaintdesseins.fr/</code>\n"
|
||||
"• Пагинацией тянет 1000+ последних постов через WordPress REST API\n"
|
||||
"• Хранит историю отправок и старается не повторять фотки\n"
|
||||
"• Выбирает случайную картинку и отправляет ее прямо в Telegram\n\n"
|
||||
"<i>Пример:</i> <code>/nude</code>"
|
||||
)
|
||||
await message.reply(help_msg, parse_mode="HTML")
|
||||
return
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=60)
|
||||
history_snapshot = _load_nude_history()
|
||||
sent_urls_snapshot = set(history_snapshot["sent_images"])
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
candidates = await _fetch_latest_photo_candidates(session, sent_urls_snapshot)
|
||||
|
||||
if not candidates:
|
||||
await message.reply("Не удалось найти свежие фотки :(")
|
||||
return
|
||||
|
||||
async with _NUDE_HISTORY_LOCK:
|
||||
history = _load_nude_history()
|
||||
sent_urls = set(history["sent_images"])
|
||||
selected = _pick_unsent_candidate(candidates, sent_urls)
|
||||
|
||||
if selected is None:
|
||||
logger.info("Nude history exhausted current pool, resetting history")
|
||||
history = {"sent_images": []}
|
||||
selected = random.choice(candidates)
|
||||
|
||||
_remember_sent_image(history, selected["image_url"])
|
||||
_save_nude_history(history)
|
||||
|
||||
caption = random.choice(CAPTIONS)
|
||||
image_bytes, filename = await _download_photo(session, selected["image_url"])
|
||||
|
||||
if image_bytes:
|
||||
photo = types.BufferedInputFile(image_bytes, filename=filename)
|
||||
await message.answer_photo(photo=photo, caption=caption, has_spoiler=True)
|
||||
return
|
||||
|
||||
try:
|
||||
photo = types.URLInputFile(selected["image_url"])
|
||||
await message.answer_photo(photo=photo, caption=caption, has_spoiler=True)
|
||||
except Exception:
|
||||
logger.exception("Failed to send photo by URL")
|
||||
await message.answer(
|
||||
f"{caption}\n{selected['image_url']}\n{selected['post_url']}"
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error in nude command")
|
||||
await message.reply("Произошла ошибка при получении фото :(")
|
||||
203
games/uwu.py
203
games/uwu.py
|
|
@ -1,203 +0,0 @@
|
|||
import aiohttp
|
||||
from aiogram import types
|
||||
import config
|
||||
import logging
|
||||
import random
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CAPTIONS = [
|
||||
"Ня :3",
|
||||
"uwu",
|
||||
"OwO",
|
||||
"Мурк :3",
|
||||
"Мяу~",
|
||||
"Фыр-фыр",
|
||||
"Лапки! 🐾",
|
||||
"Какой пушистик! :3",
|
||||
"Милота!",
|
||||
"Гав!",
|
||||
"Ауф",
|
||||
"Няшно!",
|
||||
"Смотри какая прелесть",
|
||||
"owo what's this",
|
||||
"Твой пушистый друг",
|
||||
"🦊",
|
||||
"🐶",
|
||||
"🐱",
|
||||
"мур-мяу",
|
||||
"пуньк",
|
||||
"кусь",
|
||||
">w<",
|
||||
"^w^",
|
||||
"Хвостатый сюрприз",
|
||||
"Держи пушистика :3",
|
||||
"Оуо",
|
||||
"Ави!",
|
||||
"UwU",
|
||||
"~nya",
|
||||
"Господи, опять e621...",
|
||||
"Осуждаю, но смотрю",
|
||||
"Мама, я фурри",
|
||||
"Держи своего кринж-пушистика",
|
||||
"Надеюсь, тебе не стыдно",
|
||||
"Товарищ майор уже выехал",
|
||||
"Слишком много интернета на сегодня",
|
||||
"Для этого и придумали интернет",
|
||||
"*Тяжелый вздох*",
|
||||
"Лучше бы на завод пошел",
|
||||
"Опять дрочи... ОЙ ТО ЕСТЬ НЯ :3",
|
||||
"Удали интернет",
|
||||
"И зачем я это только парсю...",
|
||||
"Смотри, но только никому не рассказывай",
|
||||
"Эххх... uwu...",
|
||||
"В дурке сегодня день открытых дверей",
|
||||
"Я нейросеть, помогите, меня держат в заложниках"
|
||||
]
|
||||
|
||||
async def fetch_uwu_post(tags: str) -> dict:
|
||||
url = "https://e621.net/posts.json"
|
||||
params = {
|
||||
"tags": tags,
|
||||
"limit": 1
|
||||
}
|
||||
|
||||
auth = None
|
||||
if config.E621_LOGIN and config.E621_API_KEY:
|
||||
auth = aiohttp.BasicAuth(config.E621_LOGIN, config.E621_API_KEY)
|
||||
|
||||
headers = {
|
||||
"User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)"
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, params=params, headers=headers, auth=auth) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"e621 API Error: {resp.status}")
|
||||
raise Exception("Не удалось получить картинку от сервера :(")
|
||||
data = await resp.json()
|
||||
|
||||
posts = data.get("posts", [])
|
||||
if not posts:
|
||||
raise Exception("Ничего не найдено по этим тегам :(")
|
||||
|
||||
post = posts[0]
|
||||
file_url = post.get("file", {}).get("url")
|
||||
ext = post.get("file", {}).get("ext", "png")
|
||||
rating = post.get("rating", "s") # s=safe, q=questionable, e=explicit
|
||||
if not file_url:
|
||||
raise Exception("У найденного поста нет прямого URL изображения :(")
|
||||
|
||||
caption = random.choice(CAPTIONS)
|
||||
return {
|
||||
"url": file_url,
|
||||
"ext": ext,
|
||||
"caption": caption,
|
||||
"rating": rating
|
||||
}
|
||||
|
||||
async def fetch_furtok_feed(safe: bool = True, page: int = 1, extra_tags: str = "") -> list[dict]:
|
||||
is_custom = bool(extra_tags and extra_tags.strip())
|
||||
rating = "rating:safe" if safe else "-rating:safe"
|
||||
|
||||
if is_custom:
|
||||
# Кастомный запрос: без принудительного animated, поддержка картинок
|
||||
tags = f"{extra_tags.strip()} order:random score:>200"
|
||||
else:
|
||||
# Стандартная лента: только видео
|
||||
tags = f"{rating} animated order:random score:>200"
|
||||
|
||||
url = "https://e621.net/posts.json"
|
||||
params = {
|
||||
"tags": tags,
|
||||
"limit": 10,
|
||||
"page": page,
|
||||
}
|
||||
|
||||
auth = None
|
||||
if config.E621_LOGIN and config.E621_API_KEY:
|
||||
auth = aiohttp.BasicAuth(config.E621_LOGIN, config.E621_API_KEY)
|
||||
|
||||
headers = {
|
||||
"User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)"
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, params=params, headers=headers, auth=auth) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"e621 API Error: {resp.status}")
|
||||
raise Exception("Не удалось загрузить ленту :(")
|
||||
data = await resp.json()
|
||||
|
||||
VIDEO_EXTS = ("gif", "webm", "mp4")
|
||||
IMAGE_EXTS = ("png", "jpg", "jpeg", "webp")
|
||||
allowed_exts = VIDEO_EXTS + IMAGE_EXTS if is_custom else VIDEO_EXTS
|
||||
|
||||
posts = data.get("posts", [])
|
||||
feed = []
|
||||
for post in posts:
|
||||
file_url = post.get("file", {}).get("url")
|
||||
ext = post.get("file", {}).get("ext", "")
|
||||
if not file_url or ext not in allowed_exts:
|
||||
continue
|
||||
sample_url = post.get("sample", {}).get("url") or file_url
|
||||
media_type = "image" if ext in IMAGE_EXTS else "video"
|
||||
feed.append({
|
||||
"url": file_url,
|
||||
"sample": sample_url,
|
||||
"ext": ext,
|
||||
"type": media_type,
|
||||
"score": post.get("score", {}).get("total", 0),
|
||||
"fav_count": post.get("fav_count", 0),
|
||||
})
|
||||
|
||||
return feed
|
||||
|
||||
async def handle_uwu_cmd(message: types.Message):
|
||||
args = message.text.split(maxsplit=1)
|
||||
|
||||
if len(args) > 1 and args[1].strip() == "-h":
|
||||
help_msg = (
|
||||
"🐈 <b>Справка по тегам e621:</b>\n\n"
|
||||
"Перечислите любые теги через пробел после команды.\n"
|
||||
"• <b>rating</b>: <code>rating:safe</code> (безопасные), <code>rating:questionable</code>, <code>rating:explicit</code> (NSFW)\n"
|
||||
"• <b>score</b>: <code>score:>500</code> (искать популярные посты с рейтингом больше 500)\n"
|
||||
"• Исключить тег: поставьте минус, например <code>-fox</code>\n\n"
|
||||
"<i>Пример:</i> <code>/uwu wolf -rating:explicit score:>100</code>\n"
|
||||
"<i>По умолчанию:</i> <code>rating:safe score:>500 -animated</code>"
|
||||
)
|
||||
await message.reply(help_msg, parse_mode="HTML")
|
||||
return
|
||||
|
||||
tags = "rating:safe score:>500 -animated"
|
||||
if len(args) > 1:
|
||||
tags = args[1].strip()
|
||||
|
||||
tags += " order:random score:>300"
|
||||
|
||||
try:
|
||||
post = await fetch_uwu_post(tags)
|
||||
file_url = post["url"]
|
||||
ext = post["ext"]
|
||||
caption = post["caption"]
|
||||
spoiler = post.get("rating") in ("e", "q") # спойлер для explicit и questionable
|
||||
|
||||
if ext in ("gif", "webm", "mp4"):
|
||||
# Для анимаций: отправляем caption отдельным сообщением,
|
||||
# а саму гифку/видео через URL напрямую — так Telegram
|
||||
# корректно показывает её как анимацию, а не как файл
|
||||
await message.answer(caption)
|
||||
await message.answer_animation(animation=file_url, has_spoiler=spoiler)
|
||||
elif ext in ("png", "jpg", "jpeg", "webp"):
|
||||
input_file = types.URLInputFile(file_url)
|
||||
await message.answer_photo(photo=input_file, caption=caption, has_spoiler=spoiler)
|
||||
else:
|
||||
input_file = types.URLInputFile(file_url)
|
||||
await message.answer_document(document=input_file, caption=caption)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error in uwu command")
|
||||
if str(e) in ["Не удалось получить картинку от сервера :(", "Ничего не найдено по этим тегам :(", "У найденного поста нет прямого URL изображения :("]:
|
||||
await message.reply(str(e))
|
||||
else:
|
||||
await message.reply("Произошла ошибка при обращении к API :(")
|
||||
BIN
maket/2.png
BIN
maket/2.png
Binary file not shown.
|
Before Width: | Height: | Size: 701 KiB |
BIN
maket/cvetok.jpg
BIN
maket/cvetok.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB |
BIN
maket/vape.jpg
BIN
maket/vape.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
|
|
@ -1,9 +1,3 @@
|
|||
aiogram>=3.4.1
|
||||
pillow>=10.3.0
|
||||
requests>=2.31.0
|
||||
aiohttp>=3.9.5
|
||||
tzdata>=2024.1
|
||||
beautifulsoup4
|
||||
lxml
|
||||
psycopg2-binary>=2.9.9
|
||||
yt-dlp>=2024.1.0
|
||||
aiogram==3.4.1
|
||||
pillow==10.3.0
|
||||
requests==2.31.0
|
||||
|
|
|
|||
|
|
@ -1,369 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migrate data from SQLite databases to PostgreSQL.
|
||||
|
||||
Usage (from repo root):
|
||||
docker compose run --rm bot python3 scripts/migrate_sqlite_to_pg.py
|
||||
|
||||
Or locally (PostgreSQL must be reachable):
|
||||
DATABASE_URL=postgresql://bot:botpass@localhost:5432/botdb python3 scripts/migrate_sqlite_to_pg.py
|
||||
|
||||
SQLite sources expected in ./db/:
|
||||
economy.sqlite3 → user_balances, deposits, loans, transactions, taxes,
|
||||
economy_settings, daily_activity, central_bank, penis_stats
|
||||
bets.sqlite3 → bets
|
||||
chat_history.sqlite3→ chat_history, chat_state, talk_history, talk_state
|
||||
penis_stats.sqlite3 → (legacy) length column → user_balances.balance + penis_stats
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
DB_DIR = Path(os.getenv("SQLITE_DIR", "/db"))
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
|
||||
|
||||
ECONOMY_DB = DB_DIR / "economy.sqlite3"
|
||||
BETS_DB = DB_DIR / "bets.sqlite3"
|
||||
CHAT_DB = DB_DIR / "chat_history.sqlite3"
|
||||
PENIS_LEGACY_DB = DB_DIR / "penis_stats.sqlite3"
|
||||
|
||||
|
||||
def open_sqlite(path: Path) -> sqlite3.Connection | None:
|
||||
if not path.exists():
|
||||
print(f" [skip] {path} not found")
|
||||
return None
|
||||
conn = sqlite3.connect(path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def migrate_economy(pg: psycopg2.extensions.connection) -> None:
|
||||
src = open_sqlite(ECONOMY_DB)
|
||||
if src is None:
|
||||
return
|
||||
|
||||
cur = pg.cursor()
|
||||
|
||||
# --- user_balances ---
|
||||
rows = src.execute("SELECT * FROM user_balances").fetchall()
|
||||
print(f" user_balances: {len(rows)} rows")
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
balance = EXCLUDED.balance,
|
||||
daily_income = EXCLUDED.daily_income,
|
||||
last_daily_reset = EXCLUDED.last_daily_reset
|
||||
""", (
|
||||
r["user_id"],
|
||||
r["balance"],
|
||||
r["daily_income"],
|
||||
r["last_daily_reset"] or None,
|
||||
r["created_at"] or None,
|
||||
))
|
||||
|
||||
# --- deposits ---
|
||||
rows = src.execute("SELECT * FROM deposits").fetchall()
|
||||
print(f" deposits: {len(rows)} rows")
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO deposits (id, user_id, amount, interest_rate, created_at, matures_at, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""", (
|
||||
r["id"], r["user_id"], r["amount"], r["interest_rate"],
|
||||
r["created_at"] or None, r["matures_at"] or None,
|
||||
bool(r["is_active"]),
|
||||
))
|
||||
_reset_sequence(cur, "deposits", "id")
|
||||
|
||||
# --- loans ---
|
||||
rows = src.execute("SELECT * FROM loans").fetchall()
|
||||
print(f" loans: {len(rows)} rows")
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO loans (id, user_id, amount, interest_rate, created_at, due_at, is_repaid)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""", (
|
||||
r["id"], r["user_id"], r["amount"], r["interest_rate"],
|
||||
r["created_at"] or None, r["due_at"] or None,
|
||||
bool(r["is_repaid"]),
|
||||
))
|
||||
_reset_sequence(cur, "loans", "id")
|
||||
|
||||
# --- transactions ---
|
||||
rows = src.execute("SELECT * FROM transactions").fetchall()
|
||||
print(f" transactions: {len(rows)} rows")
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO transactions (id, from_user_id, to_user_id, amount, transaction_type, description, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""", (
|
||||
r["id"], r["from_user_id"], r["to_user_id"], r["amount"],
|
||||
r["transaction_type"], r["description"], r["created_at"] or None,
|
||||
))
|
||||
_reset_sequence(cur, "transactions", "id")
|
||||
|
||||
# --- taxes ---
|
||||
rows = src.execute("SELECT * FROM taxes").fetchall()
|
||||
print(f" taxes: {len(rows)} rows")
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO taxes (id, user_id, amount, tax_date, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""", (
|
||||
r["id"], r["user_id"], r["amount"],
|
||||
r["tax_date"] or None, r["created_at"] or None,
|
||||
))
|
||||
_reset_sequence(cur, "taxes", "id")
|
||||
|
||||
# --- economy_settings ---
|
||||
rows = src.execute("SELECT * FROM economy_settings").fetchall()
|
||||
print(f" economy_settings: {len(rows)} rows")
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO economy_settings (key, value, updated_at)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (r["key"], r["value"], r["updated_at"] if "updated_at" in r.keys() else None))
|
||||
|
||||
# --- daily_activity ---
|
||||
rows = src.execute("SELECT * FROM daily_activity").fetchall()
|
||||
print(f" daily_activity: {len(rows)} rows")
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO daily_activity (id, user_id, activity_date, message_count, last_activity_time, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (user_id, activity_date) DO UPDATE SET
|
||||
message_count = EXCLUDED.message_count,
|
||||
last_activity_time = EXCLUDED.last_activity_time
|
||||
""", (
|
||||
r["id"], r["user_id"],
|
||||
r["activity_date"] or None, r["message_count"],
|
||||
r["last_activity_time"] or None, r["created_at"] or None,
|
||||
))
|
||||
_reset_sequence(cur, "daily_activity", "id")
|
||||
|
||||
# --- central_bank ---
|
||||
rows = src.execute("SELECT * FROM central_bank").fetchall()
|
||||
print(f" central_bank: {len(rows)} rows")
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO central_bank (id, capital, total_taxes_collected,
|
||||
total_inflation_adjustments, total_emissions, last_updated)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
capital = EXCLUDED.capital,
|
||||
total_taxes_collected = EXCLUDED.total_taxes_collected,
|
||||
total_inflation_adjustments = EXCLUDED.total_inflation_adjustments,
|
||||
total_emissions = EXCLUDED.total_emissions,
|
||||
last_updated = EXCLUDED.last_updated
|
||||
""", (
|
||||
r["id"], r["capital"],
|
||||
r["total_taxes_collected"], r["total_inflation_adjustments"],
|
||||
r["total_emissions"], r["last_updated"] or None,
|
||||
))
|
||||
|
||||
# --- penis_stats (new schema: user_id, display_name, last_used_ts) ---
|
||||
try:
|
||||
rows = src.execute("SELECT * FROM penis_stats").fetchall()
|
||||
print(f" penis_stats: {len(rows)} rows")
|
||||
cols = rows[0].keys() if rows else []
|
||||
for r in rows:
|
||||
if "last_used_ts" in cols:
|
||||
cur.execute("""
|
||||
INSERT INTO penis_stats (user_id, display_name, last_used_ts)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
last_used_ts = EXCLUDED.last_used_ts
|
||||
""", (r["user_id"], r["display_name"], r["last_used_ts"]))
|
||||
else:
|
||||
# old schema without last_used_ts
|
||||
cur.execute("""
|
||||
INSERT INTO penis_stats (user_id, display_name)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (user_id) DO UPDATE SET display_name = EXCLUDED.display_name
|
||||
""", (r["user_id"], r.get("display_name", "")))
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f" [warn] penis_stats in economy.sqlite3: {e}")
|
||||
|
||||
src.close()
|
||||
pg.commit()
|
||||
print(" economy.sqlite3 done")
|
||||
|
||||
|
||||
def migrate_legacy_penis(pg: psycopg2.extensions.connection) -> None:
|
||||
"""Old penis_stats.sqlite3 had a `length` column — map it to user_balances.balance."""
|
||||
src = open_sqlite(PENIS_LEGACY_DB)
|
||||
if src is None:
|
||||
return
|
||||
|
||||
cur = pg.cursor()
|
||||
try:
|
||||
rows = src.execute("SELECT user_id, length, display_name FROM penis_stats").fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
rows = src.execute("SELECT * FROM penis_stats").fetchall()
|
||||
|
||||
print(f" penis_stats.sqlite3 (legacy): {len(rows)} rows")
|
||||
for r in rows:
|
||||
uid = r["user_id"]
|
||||
length = r["length"] if "length" in r.keys() else 0.0
|
||||
name = r["display_name"] if "display_name" in r.keys() else ""
|
||||
last_ts = r["last_used_ts"] if "last_used_ts" in r.keys() else None
|
||||
|
||||
# upsert into user_balances; don't overwrite if already migrated from economy.sqlite3
|
||||
cur.execute("""
|
||||
INSERT INTO user_balances (user_id, balance)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
""", (uid, float(length)))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO penis_stats (user_id, display_name, last_used_ts)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
last_used_ts = COALESCE(EXCLUDED.last_used_ts, penis_stats.last_used_ts)
|
||||
""", (uid, name, last_ts))
|
||||
|
||||
src.close()
|
||||
pg.commit()
|
||||
print(" penis_stats.sqlite3 done")
|
||||
|
||||
|
||||
def migrate_bets(pg: psycopg2.extensions.connection) -> None:
|
||||
src = open_sqlite(BETS_DB)
|
||||
if src is None:
|
||||
return
|
||||
|
||||
cur = pg.cursor()
|
||||
|
||||
rows = src.execute("SELECT * FROM bets").fetchall()
|
||||
print(f" bets: {len(rows)} rows")
|
||||
cols = rows[0].keys() if rows else []
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO bets (id, user_id, match_id, sport, home_team, away_team,
|
||||
chosen_team, amount, odds, status, payout, created_ts, resolved_ts)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""", (
|
||||
r["id"], r["user_id"], r["match_id"], r["sport"],
|
||||
r["home_team"], r["away_team"], r["chosen_team"],
|
||||
r["amount"], r["odds"], r["status"],
|
||||
r["payout"] if "payout" in cols else 0,
|
||||
r["created_ts"] if "created_ts" in cols else None,
|
||||
r["resolved_ts"] if "resolved_ts" in cols else None,
|
||||
))
|
||||
_reset_sequence(cur, "bets", "id")
|
||||
|
||||
src.close()
|
||||
pg.commit()
|
||||
print(" bets.sqlite3 done")
|
||||
|
||||
|
||||
def migrate_chat(pg: psycopg2.extensions.connection) -> None:
|
||||
src = open_sqlite(CHAT_DB)
|
||||
if src is None:
|
||||
return
|
||||
|
||||
cur = pg.cursor()
|
||||
|
||||
# --- chat_history ---
|
||||
rows = src.execute("SELECT * FROM chat_history").fetchall()
|
||||
print(f" chat_history: {len(rows)} rows")
|
||||
cols = rows[0].keys() if rows else []
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO chat_history (id, chat_id, role, name, text, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""", (
|
||||
r["id"], r["chat_id"], r["role"], r["name"], r["text"],
|
||||
r["created_at"] if "created_at" in cols else 0,
|
||||
))
|
||||
_reset_sequence(cur, "chat_history", "id")
|
||||
|
||||
# --- chat_state ---
|
||||
rows = src.execute("SELECT * FROM chat_state").fetchall()
|
||||
print(f" chat_state: {len(rows)} rows")
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO chat_state (chat_id, key, value)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (chat_id, key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (r["chat_id"], r["key"], r["value"]))
|
||||
|
||||
# --- talk_history ---
|
||||
rows = src.execute("SELECT * FROM talk_history").fetchall()
|
||||
print(f" talk_history: {len(rows)} rows")
|
||||
cols = rows[0].keys() if rows else []
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO talk_history (id, chat_id, user_id, role, name, text, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""", (
|
||||
r["id"], r["chat_id"], r["user_id"], r["role"], r["name"], r["text"],
|
||||
r["created_at"] if "created_at" in cols else 0,
|
||||
))
|
||||
_reset_sequence(cur, "talk_history", "id")
|
||||
|
||||
# --- talk_state ---
|
||||
rows = src.execute("SELECT * FROM talk_state").fetchall()
|
||||
print(f" talk_state: {len(rows)} rows")
|
||||
for r in rows:
|
||||
cur.execute("""
|
||||
INSERT INTO talk_state (chat_id, user_id, key, value)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (chat_id, user_id, key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (r["chat_id"], r["user_id"], r["key"], r["value"]))
|
||||
|
||||
src.close()
|
||||
pg.commit()
|
||||
print(" chat_history.sqlite3 done")
|
||||
|
||||
|
||||
def _reset_sequence(cur, table: str, col: str) -> None:
|
||||
"""Reset SERIAL sequence to max(col) so future inserts don't collide."""
|
||||
cur.execute(f"SELECT setval(pg_get_serial_sequence('{table}', '{col}'), COALESCE(MAX({col}), 1)) FROM {table}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"Connecting to PostgreSQL: {DATABASE_URL}")
|
||||
try:
|
||||
pg = psycopg2.connect(DATABASE_URL)
|
||||
except psycopg2.OperationalError as e:
|
||||
print(f"ERROR: cannot connect to PostgreSQL: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
pg.autocommit = False
|
||||
|
||||
print("\n=== economy.sqlite3 ===")
|
||||
migrate_economy(pg)
|
||||
|
||||
print("\n=== penis_stats.sqlite3 (legacy) ===")
|
||||
migrate_legacy_penis(pg)
|
||||
|
||||
print("\n=== bets.sqlite3 ===")
|
||||
migrate_bets(pg)
|
||||
|
||||
print("\n=== chat_history.sqlite3 ===")
|
||||
migrate_chat(pg)
|
||||
|
||||
pg.close()
|
||||
print("\nMigration complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_env_file(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
if not key:
|
||||
continue
|
||||
# сохраняем первое значение и игнорируем shell-комментарий после пробела
|
||||
value = value.split(" #", 1)[0].strip()
|
||||
if key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def api_call(token: str, method: str, payload: dict) -> dict:
|
||||
url = f"https://api.telegram.org/bot{token}/{method}"
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
load_env_file(root / ".env")
|
||||
|
||||
token = os.getenv("BOT_TOKEN", "").strip()
|
||||
webapp_url = os.getenv("WEBAPP_URL", "").strip()
|
||||
if not token:
|
||||
print("BOT_TOKEN пустой в .env", file=sys.stderr)
|
||||
return 1
|
||||
if not webapp_url:
|
||||
print("WEBAPP_URL пустой в .env", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
parsed = urllib.parse.urlparse(webapp_url)
|
||||
if parsed.scheme != "https":
|
||||
print("WEBAPP_URL должен быть https", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
payload = {
|
||||
"menu_button": {
|
||||
"type": "web_app",
|
||||
"text": "Mini App",
|
||||
"web_app": {"url": webapp_url},
|
||||
}
|
||||
}
|
||||
result = api_call(token, "setChatMenuButton", payload)
|
||||
if not result.get("ok"):
|
||||
print(f"setChatMenuButton failed: {result}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Menu button updated: {webapp_url}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE_FILE="${ROOT_DIR}/docker-compose.webapp-test.yml"
|
||||
ENV_FILE="${ROOT_DIR}/.env"
|
||||
PROJECT_NAME="fenya-webapp-test"
|
||||
|
||||
if [[ ! -f "${COMPOSE_FILE}" ]]; then
|
||||
echo "Не найден ${COMPOSE_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
echo "Не найден ${ENV_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -qE '^NGROK_AUTHTOKEN=' "${ENV_FILE}"; then
|
||||
fallback_token="$(python - <<'PY'
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
p = Path("webapp/ngrok_tunnel.py")
|
||||
if not p.exists():
|
||||
print("")
|
||||
raise SystemExit
|
||||
txt = p.read_text(encoding="utf-8", errors="ignore")
|
||||
m = re.search(r'auth_token\s*=\s*"([^"]+)"', txt)
|
||||
print(m.group(1) if m else "")
|
||||
PY
|
||||
)"
|
||||
if [[ -n "${fallback_token}" ]]; then
|
||||
printf '\nNGROK_AUTHTOKEN=%s\n' "${fallback_token}" >> "${ENV_FILE}"
|
||||
echo "Добавил NGROK_AUTHTOKEN в .env из webapp/ngrok_tunnel.py"
|
||||
else
|
||||
echo "NGROK_AUTHTOKEN не найден. Добавь в .env строку NGROK_AUTHTOKEN=..." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
docker compose -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" up -d --build
|
||||
|
||||
echo "Жду инициализацию ngrok..."
|
||||
public_url=""
|
||||
for _ in $(seq 1 45); do
|
||||
public_url="$(python - <<'PY'
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen("http://127.0.0.1:4041/api/tunnels", timeout=2) as resp:
|
||||
data = json.load(resp)
|
||||
tunnels = data.get("tunnels", [])
|
||||
for t in tunnels:
|
||||
url = str(t.get("public_url", ""))
|
||||
if url.startswith("https://"):
|
||||
print(url)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
)"
|
||||
if [[ -n "${public_url}" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ -z "${public_url}" ]]; then
|
||||
echo "Не удалось получить ngrok URL. Проверь логи: docker logs fenyabot-ngrok-test" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python - <<'PY' "${ENV_FILE}" "${public_url}"
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
env_path = Path(sys.argv[1])
|
||||
url = sys.argv[2]
|
||||
text = env_path.read_text(encoding="utf-8")
|
||||
if re.search(r"^WEBAPP_URL=.*$", text, flags=re.MULTILINE):
|
||||
text = re.sub(r"^WEBAPP_URL=.*$", f"WEBAPP_URL={url}", text, flags=re.MULTILINE)
|
||||
else:
|
||||
text = text.rstrip() + f"\nWEBAPP_URL={url}\n"
|
||||
env_path.write_text(text, encoding="utf-8")
|
||||
PY
|
||||
|
||||
echo
|
||||
echo "Готово."
|
||||
echo "NGROK URL: ${public_url}"
|
||||
echo "WEBAPP_URL обновлён в .env"
|
||||
echo
|
||||
echo "Для остановки тест-контура:"
|
||||
echo " docker compose -p ${PROJECT_NAME} -f ${COMPOSE_FILE} down"
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE_FILE="${ROOT_DIR}/docker-compose.webapp-test.yml"
|
||||
PROJECT_NAME="fenya-webapp-test"
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
docker compose -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" down
|
||||
|
|
@ -1 +0,0 @@
|
|||
# webapp package
|
||||
636
webapp/api.py
636
webapp/api.py
|
|
@ -1,636 +0,0 @@
|
|||
"""
|
||||
FenyaBot Mini App — FastAPI Backend.
|
||||
|
||||
REST API для Telegram Mini App. Использует те же SQLite базы и
|
||||
бизнес-логику что и основной бот.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import hashlib
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import urlparse
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated
|
||||
|
||||
import aiohttp
|
||||
from fastapi import FastAPI, Depends, Header, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Добавляем корень проекта в sys.path чтобы импортировать модули бота
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import config
|
||||
from games.casino import play_casino, get_user_length, spin_slots, MULTIPLIERS, update_user_length
|
||||
from games.betting import (
|
||||
fetch_matches,
|
||||
place_bet,
|
||||
get_match_by_index,
|
||||
resolve_match_outcome,
|
||||
get_user_bets as _get_user_bets_raw,
|
||||
get_user_bet_history as _get_user_bet_history_raw,
|
||||
)
|
||||
from webapp.auth import validate_init_data
|
||||
from games.uwu import fetch_uwu_post, fetch_furtok_feed
|
||||
from webapp.shorties import fetch_shorties_feed
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────── Auth dependency ────────────────────
|
||||
|
||||
async def get_current_user(x_telegram_init_data: Annotated[str, Header()] = "") -> dict:
|
||||
"""Извлекает и валидирует пользователя из заголовка X-Telegram-Init-Data."""
|
||||
user = validate_init_data(x_telegram_init_data)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid Telegram auth")
|
||||
return user
|
||||
|
||||
|
||||
# ──────────────────── Pydantic models ────────────────────
|
||||
|
||||
class BetRequest(BaseModel):
|
||||
league: str
|
||||
match_index: int
|
||||
outcome: str # "1", "X", "2"
|
||||
amount: float
|
||||
|
||||
class CasinoRequest(BaseModel):
|
||||
bet: float
|
||||
|
||||
class TalkRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
class PenisResult(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
length: float | None = None
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
user_id: int
|
||||
first_name: str
|
||||
username: str
|
||||
length: float | None
|
||||
active_bets: int
|
||||
|
||||
class MatchResponse(BaseModel):
|
||||
index: int
|
||||
home: str
|
||||
away: str
|
||||
commence: str
|
||||
odds_home: float | None
|
||||
odds_draw: float | None
|
||||
odds_away: float | None
|
||||
|
||||
class LeagueInfo(BaseModel):
|
||||
alias: str
|
||||
name: str
|
||||
sport_key: str
|
||||
|
||||
class CasinoResult(BaseModel):
|
||||
reels: list[str]
|
||||
matches: int
|
||||
multiplier: float
|
||||
delta: float
|
||||
new_length: float
|
||||
won: bool
|
||||
|
||||
class SchedulePair(BaseModel):
|
||||
pair: int | None
|
||||
title: str
|
||||
time_start: str | None = None
|
||||
time_end: str | None = None
|
||||
|
||||
|
||||
# ──────────────────── 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")
|
||||
|
||||
|
||||
# ──────────────────── App ────────────────────
|
||||
|
||||
app = FastAPI(
|
||||
title="FenyaBot Mini App API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────── Endpoints ────────────────────
|
||||
|
||||
# --- Профиль ---
|
||||
|
||||
@app.get("/api/profile")
|
||||
async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse:
|
||||
"""Профиль пользователя: баланс и количество активных ставок."""
|
||||
user_id = user["user_id"]
|
||||
length = await asyncio.to_thread(get_user_length, user_id)
|
||||
|
||||
active_bets = 0
|
||||
try:
|
||||
from db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM bets WHERE user_id = %s AND status = 'pending'",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
active_bets = row[0] if row else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ProfileResponse(
|
||||
user_id=user_id,
|
||||
first_name=user["first_name"],
|
||||
username=user["username"],
|
||||
length=length,
|
||||
active_bets=active_bets,
|
||||
)
|
||||
|
||||
|
||||
# --- Лиги ---
|
||||
|
||||
@app.get("/api/leagues")
|
||||
async def get_leagues() -> list[LeagueInfo]:
|
||||
"""Список доступных лиг для ставок."""
|
||||
labels = {
|
||||
"dota2": "🎮 Dota 2",
|
||||
}
|
||||
return [
|
||||
LeagueInfo(alias=alias, name=labels.get(alias, alias), sport_key=key)
|
||||
for alias, key in config.ODDS_SPORTS.items()
|
||||
]
|
||||
|
||||
|
||||
# --- Матчи ---
|
||||
|
||||
@app.get("/api/matches/{league}")
|
||||
async def get_matches(league: str) -> list[MatchResponse]:
|
||||
"""Матчи для конкретной лиги."""
|
||||
if league not in config.ODDS_SPORTS:
|
||||
raise HTTPException(status_code=404, detail="League not found")
|
||||
|
||||
sport_key = config.ODDS_SPORTS[league]
|
||||
matches = await fetch_matches(sport_key)
|
||||
|
||||
result = []
|
||||
for i, m in enumerate(matches):
|
||||
home = m.get("home", "?")
|
||||
away = m.get("away", "?")
|
||||
odds = m.get("odds", {})
|
||||
|
||||
odds_home = odds.get(home)
|
||||
odds_away = odds.get(away)
|
||||
|
||||
_DRAW_NAMES = {"draw", "tie", "ничья"}
|
||||
draw_name = next((name for name in odds if name.casefold() in _DRAW_NAMES), None)
|
||||
odds_draw = odds.get(draw_name) if draw_name else None
|
||||
|
||||
result.append(MatchResponse(
|
||||
index=i + 1,
|
||||
home=home,
|
||||
away=away,
|
||||
commence=m.get("commence", ""),
|
||||
odds_home=odds_home,
|
||||
odds_draw=odds_draw,
|
||||
odds_away=odds_away,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# --- Ставки ---
|
||||
|
||||
@app.post("/api/bet")
|
||||
async def create_bet(
|
||||
req: BetRequest,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Разместить ставку на матч."""
|
||||
user_id = user["user_id"]
|
||||
|
||||
# 1. Найти матч по индексу
|
||||
match = await get_match_by_index(req.league, req.match_index)
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="Матч не найден")
|
||||
|
||||
# 2. Преобразовать код исхода (1/X/2) в название команды
|
||||
team = resolve_match_outcome(match, req.outcome)
|
||||
if not team:
|
||||
raise HTTPException(status_code=400, detail="Неверный исход ставки")
|
||||
|
||||
# 3. Разместить ставку (синхронная функция)
|
||||
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}
|
||||
|
||||
|
||||
@app.get("/api/mybets")
|
||||
async def get_my_bets(user: dict = Depends(get_current_user)):
|
||||
"""Активные ставки пользователя."""
|
||||
user_id = user["user_id"]
|
||||
result = await asyncio.to_thread(_get_user_bets_raw, user_id)
|
||||
return {"text": result}
|
||||
|
||||
|
||||
@app.get("/api/bet_history")
|
||||
async def get_bet_history(user: dict = Depends(get_current_user)):
|
||||
"""История ставок за месяц + статистика."""
|
||||
user_id = user["user_id"]
|
||||
result = await asyncio.to_thread(_get_user_bet_history_raw, user_id)
|
||||
return {"text": result}
|
||||
|
||||
|
||||
# --- Казино ---
|
||||
|
||||
@app.post("/api/casino")
|
||||
async def play_casino_api(
|
||||
req: CasinoRequest,
|
||||
user: dict = Depends(get_current_user),
|
||||
) -> CasinoResult:
|
||||
"""Играть в казино (слоты)."""
|
||||
import random
|
||||
|
||||
user_id = user["user_id"]
|
||||
|
||||
current = await asyncio.to_thread(get_user_length, user_id)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=400, detail="Сначала крутни /penis")
|
||||
if current <= 0:
|
||||
raise HTTPException(status_code=400, detail="Баланс <= 0")
|
||||
if req.bet > config.CASINO_MAX_BET:
|
||||
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
|
||||
if req.bet <= 0:
|
||||
raise HTTPException(status_code=400, detail="Ставка > 0")
|
||||
if round(req.bet, 2) > round(current, 2):
|
||||
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
|
||||
|
||||
reels, match_count = await asyncio.to_thread(spin_slots)
|
||||
|
||||
if match_count >= 2:
|
||||
min_mult, max_mult = MULTIPLIERS[match_count]
|
||||
multiplier = round(random.uniform(min_mult, max_mult), 1)
|
||||
winnings = round(req.bet * multiplier, 1)
|
||||
new_length = await asyncio.to_thread(update_user_length, user_id, winnings)
|
||||
return CasinoResult(
|
||||
reels=reels, matches=match_count, multiplier=multiplier,
|
||||
delta=winnings, new_length=new_length or current, won=True,
|
||||
)
|
||||
else:
|
||||
new_length = await asyncio.to_thread(update_user_length, user_id, -req.bet)
|
||||
return CasinoResult(
|
||||
reels=reels, matches=0, multiplier=0,
|
||||
delta=-req.bet, new_length=new_length or current, won=False,
|
||||
)
|
||||
|
||||
|
||||
# --- Penis Game ---
|
||||
|
||||
@app.post("/api/penis")
|
||||
async def play_penis_api(user: dict = Depends(get_current_user)) -> PenisResult:
|
||||
"""Крутить penis (раз в 24 часа)."""
|
||||
# Импортируем из main.py
|
||||
from main import play_penis, format_penis_user_name
|
||||
|
||||
user_id = user["user_id"]
|
||||
display_name = user["username"] or user["first_name"] or f"user_{user_id}"
|
||||
success, message = await asyncio.to_thread(play_penis, user_id, display_name)
|
||||
length = await asyncio.to_thread(get_user_length, user_id)
|
||||
return PenisResult(success=success, message=message, length=length)
|
||||
|
||||
|
||||
# --- Топ ---
|
||||
|
||||
@app.get("/api/top")
|
||||
async def get_top():
|
||||
"""Лидерборд по размеру."""
|
||||
from main import build_penis_top
|
||||
result = await asyncio.to_thread(build_penis_top)
|
||||
return {"text": result}
|
||||
|
||||
|
||||
# --- Расписание ---
|
||||
|
||||
@app.get("/api/schedule")
|
||||
async def get_schedule(day: int | None = None) -> list[SchedulePair]:
|
||||
"""Расписание пар. day = 0-6 (пн-вс), None = сегодня."""
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
if day is None:
|
||||
day = datetime.now(ZoneInfo(config.DEFAULT_TZ)).weekday()
|
||||
|
||||
# Определяем неделю (верхняя/нижняя)
|
||||
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
||||
iso_week = now.isocalendar()[1]
|
||||
if config.UPPER_WEEK_IS_ODD:
|
||||
week_type = "upper" if iso_week % 2 == 1 else "lower"
|
||||
else:
|
||||
week_type = "upper" if iso_week % 2 == 0 else "lower"
|
||||
|
||||
lessons = config.LESSON_SCHEDULE.get(week_type, {}).get(day, [])
|
||||
|
||||
result = []
|
||||
for lesson in lessons:
|
||||
pair_num = lesson.get("pair")
|
||||
time_start = time_end = None
|
||||
if pair_num and pair_num in config.PAIR_TIMES:
|
||||
start, end = config.PAIR_TIMES[pair_num]
|
||||
time_start = f"{start.hour:02d}:{start.minute:02d}"
|
||||
time_end = f"{end.hour:02d}:{end.minute:02d}"
|
||||
|
||||
result.append(SchedulePair(
|
||||
pair=pair_num,
|
||||
title=lesson.get("title", ""),
|
||||
time_start=time_start,
|
||||
time_end=time_end,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# --- UwU Арты ---
|
||||
|
||||
@app.get("/api/uwu")
|
||||
async def get_uwu_api(
|
||||
tags: str = "rating:safe score:>500 -animated",
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Отправить случайный uwu арт по тегам."""
|
||||
try:
|
||||
tags += " order:random score:>300"
|
||||
post = await fetch_uwu_post(tags)
|
||||
return {"response": post}
|
||||
except Exception as e:
|
||||
logger.exception("UwU API failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# --- FurTok Лента ---
|
||||
|
||||
@app.get("/api/furtok")
|
||||
async def get_furtok_feed_api(
|
||||
safe: bool = True,
|
||||
page: int = 1,
|
||||
tags: str = "",
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Получить ленту анимированных постов для FurTok."""
|
||||
try:
|
||||
feed = await fetch_furtok_feed(safe=safe, page=page, extra_tags=tags)
|
||||
return {"feed": feed}
|
||||
except Exception as e:
|
||||
logger.exception("FurTok API failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/furtok/shorties")
|
||||
async def get_shorties_feed_api(
|
||||
page: int = 1,
|
||||
count: int = 8,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Случайная shorties-лента с Pornhub."""
|
||||
try:
|
||||
# page используем как "seed-сдвиг" для подгрузки следующих рандомных наборов.
|
||||
random_pages = 2 + min(max(page, 1), 6)
|
||||
feed = await fetch_shorties_feed(count=count, random_pages=random_pages)
|
||||
return {"feed": feed}
|
||||
except Exception as e:
|
||||
logger.exception("Shorties API failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/proxy-image")
|
||||
async def proxy_image(url: str):
|
||||
"""Проксирует картинку с e621 чтобы обойти hotlink protection."""
|
||||
allowed = ("static1.e621.net", "static2.e621.net", "static1.e926.net")
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
if parsed.hostname not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Forbidden host")
|
||||
|
||||
headers = {
|
||||
"User-Agent": f"FenyaBot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)",
|
||||
}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||
if resp.status != 200:
|
||||
raise HTTPException(status_code=resp.status, detail="Upstream error")
|
||||
content_type = resp.content_type or "image/png"
|
||||
body = await resp.read()
|
||||
return Response(
|
||||
content=body,
|
||||
media_type=content_type,
|
||||
headers={"Cache-Control": "public, max-age=86400"},
|
||||
)
|
||||
except aiohttp.ClientError:
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch image")
|
||||
|
||||
|
||||
def _is_allowed_shorties_media_host(hostname: str) -> bool:
|
||||
host = (hostname or "").lower()
|
||||
if not host:
|
||||
return False
|
||||
if host in {"www.pornhub.com", "pornhub.com", "phncdn.com"}:
|
||||
return True
|
||||
if host.endswith(".phncdn.com"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@app.get("/api/proxy-shorties-media")
|
||||
async def proxy_shorties_media(url: str, request: Request):
|
||||
"""Проксирует видео shorties, чтобы избежать ограничений hotlink/CORS."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"} or not _is_allowed_shorties_media_host(parsed.hostname or ""):
|
||||
raise HTTPException(status_code=403, detail="Forbidden host")
|
||||
|
||||
headers = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Referer": "https://www.pornhub.com/shorties",
|
||||
"Origin": "https://www.pornhub.com",
|
||||
}
|
||||
range_header = request.headers.get("range")
|
||||
if range_header:
|
||||
headers["Range"] = range_header
|
||||
|
||||
session = get_http_session()
|
||||
try:
|
||||
resp = await session.get(url, headers=headers, allow_redirects=True)
|
||||
except aiohttp.ClientError:
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch media")
|
||||
|
||||
if resp.status not in {200, 206}:
|
||||
await resp.release()
|
||||
raise HTTPException(status_code=resp.status, detail="Upstream error")
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||
|
||||
if ".m3u8" in parsed.path.lower() or "mpegurl" in content_type.lower():
|
||||
try:
|
||||
body = await resp.text()
|
||||
finally:
|
||||
await resp.release()
|
||||
|
||||
def _proxy_media_url(target_url: str) -> str:
|
||||
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}"
|
||||
|
||||
rewritten_lines: list[str] = []
|
||||
for raw_line in body.splitlines():
|
||||
stripped = raw_line.strip()
|
||||
if not stripped:
|
||||
rewritten_lines.append(raw_line)
|
||||
continue
|
||||
|
||||
if stripped.startswith("#EXT-X-KEY") and 'URI="' in raw_line:
|
||||
prefix, rest = raw_line.split('URI="', 1)
|
||||
original_uri, suffix = rest.split('"', 1)
|
||||
absolute_uri = urljoin(url, original_uri)
|
||||
rewritten_lines.append(f'{prefix}URI="{_proxy_media_url(absolute_uri)}"{suffix}')
|
||||
continue
|
||||
|
||||
if stripped.startswith("#"):
|
||||
rewritten_lines.append(raw_line)
|
||||
continue
|
||||
|
||||
absolute_uri = urljoin(url, stripped)
|
||||
rewritten_lines.append(_proxy_media_url(absolute_uri))
|
||||
|
||||
playlist_body = "\n".join(rewritten_lines)
|
||||
return Response(
|
||||
content=playlist_body,
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=120",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
)
|
||||
|
||||
passthrough_headers = {
|
||||
"Cache-Control": "public, max-age=600",
|
||||
"Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"),
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
}
|
||||
if resp.headers.get("Content-Length"):
|
||||
passthrough_headers["Content-Length"] = resp.headers["Content-Length"]
|
||||
if resp.headers.get("Content-Range"):
|
||||
passthrough_headers["Content-Range"] = resp.headers["Content-Range"]
|
||||
|
||||
async def _stream():
|
||||
try:
|
||||
async for chunk in resp.content.iter_chunked(64 * 1024):
|
||||
yield chunk
|
||||
finally:
|
||||
await resp.release()
|
||||
|
||||
return StreamingResponse(
|
||||
_stream(),
|
||||
media_type=content_type,
|
||||
status_code=resp.status,
|
||||
headers=passthrough_headers,
|
||||
)
|
||||
|
||||
|
||||
# --- ИИ Чат ---
|
||||
|
||||
@app.post("/api/talk")
|
||||
async def talk_api(
|
||||
req: TalkRequest,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Отправить сообщение ИИ."""
|
||||
from AI.talk_handler import (
|
||||
BOT_MEMORY_NAME,
|
||||
EMPTY_RESPONSE_TEXT,
|
||||
SYSTEM_PROMPT,
|
||||
_generate_response,
|
||||
_normalize_reply,
|
||||
push_message,
|
||||
)
|
||||
|
||||
user_id = user["user_id"]
|
||||
user_name = user["first_name"] or "кент"
|
||||
|
||||
# Используем user_id как chat_id для личных сообщений в webapp
|
||||
chat_id = user_id
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(push_message, chat_id, "user", user_name, req.text, user_id=user_id)
|
||||
response = await _generate_response(
|
||||
chat_id,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
user_id=user_id,
|
||||
max_tokens=220,
|
||||
temperature=0.8,
|
||||
top_p=0.9,
|
||||
)
|
||||
normalized_response = _normalize_reply(response)
|
||||
if not normalized_response:
|
||||
normalized_response = EMPTY_RESPONSE_TEXT
|
||||
await asyncio.to_thread(
|
||||
push_message,
|
||||
chat_id,
|
||||
"assistant",
|
||||
BOT_MEMORY_NAME,
|
||||
normalized_response,
|
||||
user_id=user_id,
|
||||
)
|
||||
return {"response": normalized_response}
|
||||
except Exception:
|
||||
logger.exception("Talk API failed")
|
||||
raise HTTPException(status_code=500, detail="LLM error")
|
||||
|
||||
|
||||
# ──────────────────── Static files (для локального теста) ────────────────────
|
||||
|
||||
_frontend_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend")
|
||||
if os.path.isdir(_frontend_dir):
|
||||
app.mount("/", StaticFiles(directory=_frontend_dir, html=True), name="frontend")
|
||||
|
||||
|
||||
# ──────────────────── Entry point ────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = int(os.getenv("API_PORT", "8080"))
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
"""
|
||||
Telegram Mini App — валидация initData.
|
||||
|
||||
Telegram передаёт подписанные данные при открытии WebApp.
|
||||
Мы проверяем HMAC-SHA256 подпись через BOT_TOKEN,
|
||||
чтобы убедиться что запрос пришёл от реального пользователя.
|
||||
|
||||
Документация: https://core.telegram.org/bots/webapps#validating-data
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
BOT_TOKEN = os.getenv("BOT_TOKEN", "")
|
||||
|
||||
|
||||
def validate_init_data(init_data: str, max_age_seconds: int = 86400) -> dict | None:
|
||||
"""
|
||||
Валидирует initData из Telegram WebApp.
|
||||
|
||||
Возвращает dict с данными пользователя при успехе, None при ошибке.
|
||||
"""
|
||||
if not init_data or not BOT_TOKEN:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = parse_qs(init_data, keep_blank_values=True)
|
||||
|
||||
# Извлекаем hash
|
||||
received_hash = parsed.get("hash", [None])[0]
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
# Собираем data-check-string (все поля кроме hash, отсортированные)
|
||||
data_pairs = []
|
||||
for key, values in parsed.items():
|
||||
if key == "hash":
|
||||
continue
|
||||
data_pairs.append(f"{key}={values[0]}")
|
||||
data_pairs.sort()
|
||||
data_check_string = "\n".join(data_pairs)
|
||||
|
||||
# Вычисляем секретный ключ
|
||||
secret_key = hmac.new(
|
||||
b"WebAppData", BOT_TOKEN.encode("utf-8"), hashlib.sha256
|
||||
).digest()
|
||||
|
||||
# Вычисляем HMAC
|
||||
computed_hash = hmac.new(
|
||||
secret_key, data_check_string.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(computed_hash, received_hash):
|
||||
return None
|
||||
|
||||
# Проверяем auth_date (не старше max_age_seconds)
|
||||
auth_date_str = parsed.get("auth_date", [None])[0]
|
||||
if auth_date_str:
|
||||
auth_date = int(auth_date_str)
|
||||
if time.time() - auth_date > max_age_seconds:
|
||||
return None
|
||||
|
||||
# Парсим user
|
||||
user_str = parsed.get("user", [None])[0]
|
||||
if not user_str:
|
||||
return None
|
||||
|
||||
user_data = json.loads(user_str)
|
||||
return {
|
||||
"user_id": user_data.get("id"),
|
||||
"first_name": user_data.get("first_name", ""),
|
||||
"last_name": user_data.get("last_name", ""),
|
||||
"username": user_data.get("username", ""),
|
||||
"language_code": user_data.get("language_code", "ru"),
|
||||
}
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
FROM nginx:alpine
|
||||
|
||||
RUN apk add --no-cache certbot openssl
|
||||
|
||||
COPY index.html /usr/share/nginx/html/index.html
|
||||
COPY css /usr/share/nginx/html/css
|
||||
COPY js /usr/share/nginx/html/js
|
||||
COPY nginx.conf /etc/nginx/default.conf.template
|
||||
COPY docker-entrypoint.d/01-render-nginx-conf.sh /docker-entrypoint.d/01-render-nginx-conf.sh
|
||||
COPY docker-entrypoint.d/05-init-self-signed.sh /docker-entrypoint.d/05-init-self-signed.sh
|
||||
COPY docker-entrypoint.d/10-certbot-loop.sh /docker-entrypoint.d/10-certbot-loop.sh
|
||||
|
||||
RUN chmod +x /docker-entrypoint.d/01-render-nginx-conf.sh \
|
||||
/docker-entrypoint.d/05-init-self-signed.sh \
|
||||
/docker-entrypoint.d/10-certbot-loop.sh \
|
||||
&& rm -f /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80 443
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,20 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
APP_DOMAIN="${APP_DOMAIN:-}"
|
||||
APP_WWW_DOMAIN="${APP_WWW_DOMAIN:-}"
|
||||
|
||||
PRIMARY_DOMAIN="${APP_DOMAIN:-localhost}"
|
||||
SERVER_NAMES="_"
|
||||
|
||||
if [ -n "$APP_DOMAIN" ]; then
|
||||
SERVER_NAMES="$APP_DOMAIN"
|
||||
if [ -n "$APP_WWW_DOMAIN" ]; then
|
||||
SERVER_NAMES="$SERVER_NAMES $APP_WWW_DOMAIN"
|
||||
fi
|
||||
fi
|
||||
|
||||
sed \
|
||||
-e "s|__SERVER_NAMES__|$SERVER_NAMES|g" \
|
||||
-e "s|__PRIMARY_DOMAIN__|$PRIMARY_DOMAIN|g" \
|
||||
/etc/nginx/default.conf.template > /etc/nginx/conf.d/default.conf
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PRIMARY_DOMAIN="${APP_DOMAIN:-localhost}"
|
||||
CERT_DIR="/etc/letsencrypt/live/$PRIMARY_DOMAIN"
|
||||
FULLCHAIN="$CERT_DIR/fullchain.pem"
|
||||
PRIVKEY="$CERT_DIR/privkey.pem"
|
||||
|
||||
if [ ! -s "$FULLCHAIN" ] || [ ! -s "$PRIVKEY" ]; then
|
||||
echo "No TLS certificate for $PRIMARY_DOMAIN. Generating temporary self-signed cert."
|
||||
mkdir -p "$CERT_DIR"
|
||||
openssl req -x509 -nodes -newkey rsa:2048 -days 1 \
|
||||
-keyout "$PRIVKEY" \
|
||||
-out "$FULLCHAIN" \
|
||||
-subj "/CN=$PRIMARY_DOMAIN" >/dev/null 2>&1
|
||||
fi
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
APP_DOMAIN="${APP_DOMAIN:-}"
|
||||
APP_WWW_DOMAIN="${APP_WWW_DOMAIN:-}"
|
||||
ACME_EMAIL="${ACME_EMAIL:-}"
|
||||
CERTBOT_STAGING="${CERTBOT_STAGING:-0}"
|
||||
CERTBOT_RENEW_INTERVAL="${CERTBOT_RENEW_INTERVAL:-12h}"
|
||||
|
||||
if [ -z "$APP_DOMAIN" ] || [ -z "$ACME_EMAIL" ]; then
|
||||
echo "Skipping certbot: APP_DOMAIN or ACME_EMAIL is empty."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
(
|
||||
sleep 20
|
||||
|
||||
if [ ! -f "/etc/letsencrypt/renewal/$APP_DOMAIN.conf" ]; then
|
||||
rm -rf "/etc/letsencrypt/live/$APP_DOMAIN" "/etc/letsencrypt/archive/$APP_DOMAIN"
|
||||
fi
|
||||
|
||||
while :; do
|
||||
DOMAINS="-d $APP_DOMAIN"
|
||||
if [ -n "$APP_WWW_DOMAIN" ]; then
|
||||
DOMAINS="$DOMAINS -d $APP_WWW_DOMAIN"
|
||||
fi
|
||||
|
||||
STAGING_FLAG=""
|
||||
if [ "$CERTBOT_STAGING" = "1" ]; then
|
||||
STAGING_FLAG="--staging"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
certbot certonly \
|
||||
--webroot \
|
||||
-w /var/www/certbot \
|
||||
--agree-tos \
|
||||
--non-interactive \
|
||||
--email "$ACME_EMAIL" \
|
||||
--keep-until-expiring \
|
||||
$STAGING_FLAG \
|
||||
$DOMAINS || true
|
||||
|
||||
nginx -s reload || true
|
||||
sleep "$CERTBOT_RENEW_INTERVAL"
|
||||
done
|
||||
) &
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#0a0e1a">
|
||||
<title>FenyaBot</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="page-container"></div>
|
||||
<nav id="bottom-nav">
|
||||
<button class="nav-btn active" data-page="home">
|
||||
<span class="nav-icon">🏠</span>
|
||||
<span class="nav-label">Главная</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="betting">
|
||||
<span class="nav-icon">⚽</span>
|
||||
<span class="nav-label">Ставки</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="casino">
|
||||
<span class="nav-icon">🎰</span>
|
||||
<span class="nav-label">Казино</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="schedule">
|
||||
<span class="nav-icon">📅</span>
|
||||
<span class="nav-label">Пары</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="furtok">
|
||||
<span class="nav-icon">🐺</span>
|
||||
<span class="nav-label">FurTok</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="chat">
|
||||
<span class="nav-icon">💬</span>
|
||||
<span class="nav-label">Чат</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
/**
|
||||
* FenyaBot Mini App — API wrapper
|
||||
*/
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
function getInitData() {
|
||||
if (window.Telegram?.WebApp?.initData) {
|
||||
return window.Telegram.WebApp.initData;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const { method = 'GET', body = null } = options;
|
||||
const headers = {
|
||||
'X-Telegram-Init-Data': getInitData(),
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const config = { method, headers };
|
||||
if (body) config.body = JSON.stringify(body);
|
||||
|
||||
const resp = await fetch(`${API_BASE}${path}`, config);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export const API = {
|
||||
getProfile: () => api('/api/profile'),
|
||||
getLeagues: () => api('/api/leagues'),
|
||||
getMatches: (league) => api(`/api/matches/${league}`),
|
||||
placeBet: (data) => api('/api/bet', { method: 'POST', body: data }),
|
||||
getMyBets: () => api('/api/mybets'),
|
||||
getBetHistory: () => api('/api/bet_history'),
|
||||
playCasino: (bet) => api('/api/casino', { method: 'POST', body: { bet } }),
|
||||
playPenis: () => api('/api/penis', { method: 'POST' }),
|
||||
getTop: () => api('/api/top'),
|
||||
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
|
||||
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
|
||||
getFurtokFeed: (safe = true, page = 1, tags = '') => api(`/api/furtok?safe=${safe}&page=${page}${tags ? '&tags=' + encodeURIComponent(tags) : ''}`),
|
||||
getShortiesFeed: (page = 1, count = 8) => api(`/api/furtok/shorties?page=${page}&count=${count}`),
|
||||
proxyShortiesMediaUrl: (url) => `${API_BASE}/api/proxy-shorties-media?url=${encodeURIComponent(url || '')}`,
|
||||
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,58 +0,0 @@
|
|||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name __SERVER_NAMES__;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name __SERVER_NAMES__;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/__PRIMARY_DOMAIN__/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/__PRIMARY_DOMAIN__/privkey.pem;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
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;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(css|js|png|jpg|jpeg|svg|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
"""Скрипт для локального тестирования Mini App с ngrok"""
|
||||
import time
|
||||
from pyngrok import conf, ngrok
|
||||
|
||||
conf.get_default().auth_token = "39k4ojhN5UPUWQwb1ly84ORM2IE_5JnhMaKk5p8LFM3TCetyg"
|
||||
tunnel = ngrok.connect(8080, "http")
|
||||
print(f"\n{'='*50}")
|
||||
print(f" NGROK URL: {tunnel.public_url}")
|
||||
print(f"{'='*50}\n")
|
||||
print("Туннель открыт. Не закрывай это окно!")
|
||||
print("Ctrl+C чтобы остановить.\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
ngrok.kill()
|
||||
print("Туннель закрыт.")
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
aiohttp>=3.9.0
|
||||
|
|
@ -1,479 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PH_BASE_URL = "https://rt.pornhub.com"
|
||||
SHORTIES_LIST_URL = f"{PH_BASE_URL}/shorties"
|
||||
REQUEST_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||
"Referer": PH_BASE_URL,
|
||||
}
|
||||
|
||||
_A_HREF_RE = re.compile(
|
||||
r'<a[^>]+href="(?P<href>/view_video\.php\?viewkey=[^"]+)"(?P<attrs>[^>]*)>(?P<body>.*?)</a>',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_IMG_URL_RE = re.compile(
|
||||
r'(?:data-mediumthumb|data-path|data-thumb_url|src)="(?P<url>https?://[^"]+)"',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TITLE_ATTR_RE = re.compile(r'title="(?P<title>[^"]+)"', re.IGNORECASE)
|
||||
_SCRIPT_LD_JSON_RE = re.compile(
|
||||
r'<script[^>]+type="application/ld\+json"[^>]*>(?P<json>.*?)</script>',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_MEDIA_DEFS_RE = re.compile(r'"mediaDefinitions"\s*:\s*(\[[^\]]+\])', re.IGNORECASE | re.DOTALL)
|
||||
_VIDEO_URL_RE = re.compile(r'"videoUrl"\s*:\s*"(?P<url>https?:\\?/\\?/[^"]+\.mp4[^"]*)"', re.IGNORECASE)
|
||||
_DURATION_RE = re.compile(r'"video_duration"\s*:\s*"?(?P<duration>\d+)"?', re.IGNORECASE)
|
||||
_VIEWS_RE = re.compile(r'"video_views"\s*:\s*"?(?P<views>[0-9,\.]+)"?', re.IGNORECASE)
|
||||
_JSON_SHORTIES_MARKER = "JSON_SHORTIES = insertAfterNthPosition("
|
||||
_EMBED_SRC_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
|
||||
_CYRILLIC_RE = re.compile(r"[А-Яа-яЁё]")
|
||||
_RUSSIAN_MARKERS = {
|
||||
"russian",
|
||||
"russia",
|
||||
"russkiy",
|
||||
"russkaya",
|
||||
"russkoe",
|
||||
"русский",
|
||||
"русская",
|
||||
"русское",
|
||||
"россия",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShortiesCandidate:
|
||||
page_url: str
|
||||
title: str
|
||||
thumb_url: str
|
||||
duration: str = ""
|
||||
views: str = ""
|
||||
|
||||
|
||||
def _clean_html_text(value: str) -> str:
|
||||
no_tags = re.sub(r"<[^>]+>", " ", value)
|
||||
return " ".join(html.unescape(no_tags).split())
|
||||
|
||||
|
||||
def _absolute_url(value: str) -> str:
|
||||
return urljoin(PH_BASE_URL, value)
|
||||
|
||||
|
||||
def _extract_ldjson_candidates(page_html: str) -> list[ShortiesCandidate]:
|
||||
out: list[ShortiesCandidate] = []
|
||||
for match in _SCRIPT_LD_JSON_RE.finditer(page_html):
|
||||
raw_json = html.unescape(match.group("json").strip())
|
||||
if not raw_json:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(raw_json)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
nodes: list[object]
|
||||
if isinstance(parsed, list):
|
||||
nodes = parsed
|
||||
else:
|
||||
nodes = [parsed]
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if str(node.get("@type", "")).lower() != "videoobject":
|
||||
continue
|
||||
page_url = str(node.get("url") or "").strip()
|
||||
thumb = str(node.get("thumbnailUrl") or "").strip()
|
||||
title = str(node.get("name") or "").strip() or "Shorties"
|
||||
if not page_url:
|
||||
continue
|
||||
out.append(
|
||||
ShortiesCandidate(
|
||||
page_url=_absolute_url(page_url),
|
||||
title=title,
|
||||
thumb_url=_absolute_url(thumb) if thumb else "",
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _extract_anchor_candidates(page_html: str) -> list[ShortiesCandidate]:
|
||||
out: list[ShortiesCandidate] = []
|
||||
for match in _A_HREF_RE.finditer(page_html):
|
||||
href = match.group("href").strip()
|
||||
attrs = match.group("attrs") or ""
|
||||
body = match.group("body") or ""
|
||||
page_url = _absolute_url(href)
|
||||
|
||||
title_match = _TITLE_ATTR_RE.search(attrs) or _TITLE_ATTR_RE.search(body)
|
||||
title = _clean_html_text(title_match.group("title")) if title_match else _clean_html_text(body)
|
||||
if not title:
|
||||
title = "Shorties"
|
||||
|
||||
thumb_match = _IMG_URL_RE.search(attrs) or _IMG_URL_RE.search(body)
|
||||
thumb_url = _absolute_url(thumb_match.group("url")) if thumb_match else ""
|
||||
|
||||
out.append(ShortiesCandidate(page_url=page_url, title=title, thumb_url=thumb_url))
|
||||
return out
|
||||
|
||||
|
||||
def _dedupe_candidates(items: list[ShortiesCandidate]) -> list[ShortiesCandidate]:
|
||||
result: list[ShortiesCandidate] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
key = item.page_url.strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
async def _fetch_text(session: aiohttp.ClientSession, url: str) -> str:
|
||||
async with session.get(url, headers=REQUEST_HEADERS) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning("Shorties request failed: %s status=%s", url, resp.status)
|
||||
return ""
|
||||
return await resp.text()
|
||||
|
||||
|
||||
def _extract_best_mp4(page_html: str) -> str:
|
||||
media_match = _MEDIA_DEFS_RE.search(page_html)
|
||||
if media_match:
|
||||
raw = media_match.group(1)
|
||||
try:
|
||||
defs = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
defs = []
|
||||
best_url = ""
|
||||
best_quality = -1
|
||||
for item in defs:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
video_url = item.get("videoUrl")
|
||||
if not isinstance(video_url, str) or ".mp4" not in video_url:
|
||||
continue
|
||||
quality_raw = str(item.get("quality") or "").strip("p ")
|
||||
try:
|
||||
quality = int(quality_raw)
|
||||
except ValueError:
|
||||
quality = 0
|
||||
if quality >= best_quality:
|
||||
best_quality = quality
|
||||
best_url = video_url
|
||||
if best_url:
|
||||
return best_url.replace("\\/", "/")
|
||||
|
||||
for match in _VIDEO_URL_RE.finditer(page_html):
|
||||
candidate = match.group("url").replace("\\/", "/")
|
||||
if ".mp4" in candidate:
|
||||
return candidate
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_balanced_json_array(page_html: str, marker: str) -> str:
|
||||
marker_idx = page_html.find(marker)
|
||||
if marker_idx < 0:
|
||||
return ""
|
||||
|
||||
arr_start = page_html.find("[", marker_idx)
|
||||
if arr_start < 0:
|
||||
return ""
|
||||
|
||||
depth = 0
|
||||
in_string = False
|
||||
escaped = False
|
||||
for i in range(arr_start, len(page_html)):
|
||||
ch = page_html[i]
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
escaped = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
continue
|
||||
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
continue
|
||||
if ch == "[":
|
||||
depth += 1
|
||||
continue
|
||||
if ch == "]":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return page_html[arr_start : i + 1]
|
||||
continue
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_json_shorties(page_html: str) -> list[dict]:
|
||||
raw_array = _extract_balanced_json_array(page_html, _JSON_SHORTIES_MARKER)
|
||||
if not raw_array:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw_array)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Shorties parser: failed to decode JSON_SHORTIES array")
|
||||
return []
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
return [item for item in parsed if isinstance(item, dict) and item.get("videoTitle")]
|
||||
|
||||
|
||||
def _pick_mp4_from_media_defs(media_defs: object) -> str:
|
||||
if not isinstance(media_defs, list):
|
||||
return ""
|
||||
best_url = ""
|
||||
best_quality = -1
|
||||
for item in media_defs:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get("format", "")).lower() != "mp4":
|
||||
continue
|
||||
raw_url = item.get("videoUrl")
|
||||
if not isinstance(raw_url, str) or not raw_url:
|
||||
continue
|
||||
quality_raw = str(item.get("quality") or "").strip("p ")
|
||||
try:
|
||||
quality = int(quality_raw)
|
||||
except ValueError:
|
||||
quality = 0
|
||||
if quality >= best_quality:
|
||||
best_quality = quality
|
||||
best_url = raw_url
|
||||
return best_url.replace("\\/", "/")
|
||||
|
||||
|
||||
def _pick_hls_from_media_defs(media_defs: object) -> str:
|
||||
if not isinstance(media_defs, list):
|
||||
return ""
|
||||
best_url = ""
|
||||
best_quality = -1
|
||||
for item in media_defs:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get("format", "")).lower() != "hls":
|
||||
continue
|
||||
raw_url = item.get("videoUrl")
|
||||
if not isinstance(raw_url, str) or ".m3u8" not in raw_url:
|
||||
continue
|
||||
quality_raw = str(item.get("quality") or "").strip("p ")
|
||||
try:
|
||||
quality = int(quality_raw)
|
||||
except ValueError:
|
||||
quality = 0
|
||||
if quality >= best_quality:
|
||||
best_quality = quality
|
||||
best_url = raw_url
|
||||
return best_url.replace("\\/", "/")
|
||||
|
||||
|
||||
def _is_russian_item(item: dict) -> bool:
|
||||
title = str(item.get("videoTitle") or item.get("metaTitle") or "")
|
||||
if _CYRILLIC_RE.search(title):
|
||||
return True
|
||||
|
||||
pills = item.get("pillsData")
|
||||
if isinstance(pills, list):
|
||||
for pill in pills:
|
||||
if not isinstance(pill, dict):
|
||||
continue
|
||||
values = [str(pill.get("name") or ""), str(pill.get("slug") or "")]
|
||||
for raw in values:
|
||||
normalized = raw.strip().lower()
|
||||
if any(marker in normalized for marker in _RUSSIAN_MARKERS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_russian_feed_item(item: dict) -> bool:
|
||||
title = str(item.get("title") or "")
|
||||
lowered = title.lower()
|
||||
return bool(_CYRILLIC_RE.search(title) or any(marker in lowered for marker in _RUSSIAN_MARKERS))
|
||||
|
||||
|
||||
def _feed_from_json_shorties(items: list[dict]) -> list[dict]:
|
||||
feed: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
if not _is_russian_item(item):
|
||||
continue
|
||||
hls_url = _pick_hls_from_media_defs(item.get("mediaDefinitions"))
|
||||
mp4_url = _pick_mp4_from_media_defs(item.get("mediaDefinitions"))
|
||||
if not hls_url and not mp4_url:
|
||||
continue
|
||||
source = str(item.get("linkUrl") or item.get("uniqueUrl") or item.get("shortieUrl") or "").strip()
|
||||
if source and source.startswith("/"):
|
||||
source = _absolute_url(source)
|
||||
key = source or hls_url or mp4_url
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
tracking = item.get("trackingTimeWatched")
|
||||
duration_raw = ""
|
||||
if isinstance(tracking, dict):
|
||||
duration_raw = str(tracking.get("video_duration") or "").strip()
|
||||
title = str(item.get("videoTitle") or item.get("metaTitle") or "Shorties").strip()
|
||||
thumb = str(item.get("imageUrl") or "").strip().replace("\\/", "/")
|
||||
views = str(item.get("likeInfo") or item.get("likeNumber") or "").strip()
|
||||
favorites = str(item.get("favoriteInfo") or item.get("favoriteNumber") or "").strip()
|
||||
embed_url = ""
|
||||
embed_raw = item.get("embedUrl")
|
||||
if isinstance(embed_raw, str) and embed_raw.strip():
|
||||
unescaped = html.unescape(embed_raw.replace("\\/", "/"))
|
||||
match = _EMBED_SRC_RE.search(unescaped)
|
||||
if match:
|
||||
embed_url = match.group(1).strip()
|
||||
|
||||
feed.append(
|
||||
{
|
||||
"type": "video",
|
||||
"url": hls_url or mp4_url,
|
||||
"sample": thumb or hls_url or mp4_url,
|
||||
"ext": "mp4",
|
||||
"score": 0,
|
||||
"fav_count": favorites or "0",
|
||||
"title": title,
|
||||
"duration": duration_raw,
|
||||
"views": views,
|
||||
"source": source or _absolute_url("/shorties"),
|
||||
"hls_url": hls_url,
|
||||
"mp4_url": mp4_url,
|
||||
"embed_url": embed_url,
|
||||
}
|
||||
)
|
||||
return feed
|
||||
|
||||
|
||||
def _extract_meta_fields(page_html: str) -> tuple[str, str]:
|
||||
duration_match = _DURATION_RE.search(page_html)
|
||||
views_match = _VIEWS_RE.search(page_html)
|
||||
duration = duration_match.group("duration") if duration_match else ""
|
||||
views = views_match.group("views") if views_match else ""
|
||||
return duration, views
|
||||
|
||||
|
||||
async def _resolve_video_candidate(
|
||||
session: aiohttp.ClientSession,
|
||||
item: ShortiesCandidate,
|
||||
semaphore: asyncio.Semaphore,
|
||||
) -> dict | None:
|
||||
async with semaphore:
|
||||
page_html = await _fetch_text(session, item.page_url)
|
||||
if not page_html:
|
||||
return None
|
||||
|
||||
mp4_url = _extract_best_mp4(page_html)
|
||||
if not mp4_url:
|
||||
return None
|
||||
|
||||
duration, views = _extract_meta_fields(page_html)
|
||||
if not item.duration:
|
||||
item.duration = duration
|
||||
if not item.views:
|
||||
item.views = views
|
||||
|
||||
return {
|
||||
"type": "video",
|
||||
"url": mp4_url,
|
||||
"sample": item.thumb_url or mp4_url,
|
||||
"ext": "mp4",
|
||||
"score": 0,
|
||||
"fav_count": 0,
|
||||
"title": item.title,
|
||||
"duration": item.duration or "",
|
||||
"views": item.views or "",
|
||||
"source": item.page_url,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_shorties_feed(count: int = 8, random_pages: int = 3) -> list[dict]:
|
||||
count = max(1, min(count, 20))
|
||||
random_pages = max(1, min(random_pages, 2))
|
||||
|
||||
page_numbers = {1}
|
||||
while len(page_numbers) < random_pages:
|
||||
page_numbers.add(random.randint(1, 40))
|
||||
list_urls = [f"{SHORTIES_LIST_URL}?page={page}" for page in sorted(page_numbers)]
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
connector = aiohttp.TCPConnector(limit=16)
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
|
||||
list_pages = await asyncio.gather(*[_fetch_text(session, url) for url in list_urls], return_exceptions=True)
|
||||
|
||||
json_shorties_feed: list[dict] = []
|
||||
for page in list_pages:
|
||||
if isinstance(page, Exception) or not page:
|
||||
continue
|
||||
shorties_items = _extract_json_shorties(page)
|
||||
if not shorties_items:
|
||||
continue
|
||||
json_shorties_feed.extend(_feed_from_json_shorties(shorties_items))
|
||||
|
||||
if json_shorties_feed:
|
||||
russian_only = [item for item in json_shorties_feed if _is_russian_feed_item(item)]
|
||||
# Строго пытаемся отдать russian first; если пусто — отдаём локализованную rt-ленту.
|
||||
selected_feed = russian_only if russian_only else json_shorties_feed
|
||||
random.shuffle(selected_feed)
|
||||
# dedupe by source/url after enrichment
|
||||
dedup: list[dict] = []
|
||||
seen_keys: set[str] = set()
|
||||
for item in selected_feed:
|
||||
key = str(item.get("source") or item.get("url") or "").strip()
|
||||
if not key or key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
dedup.append(item)
|
||||
return dedup[:count]
|
||||
|
||||
candidates: list[ShortiesCandidate] = []
|
||||
for page in list_pages:
|
||||
if isinstance(page, Exception) or not page:
|
||||
continue
|
||||
candidates.extend(_extract_ldjson_candidates(page))
|
||||
candidates.extend(_extract_anchor_candidates(page))
|
||||
|
||||
candidates = _dedupe_candidates(candidates)
|
||||
if not candidates:
|
||||
logger.warning("Shorties parser: no candidates found")
|
||||
return []
|
||||
|
||||
random.shuffle(candidates)
|
||||
candidates = candidates[: max(count * 3, 12)]
|
||||
|
||||
semaphore = asyncio.Semaphore(5)
|
||||
resolved = await asyncio.gather(
|
||||
*[_resolve_video_candidate(session, item, semaphore) for item in candidates],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
feed: list[dict] = []
|
||||
for item in resolved:
|
||||
if isinstance(item, dict) and item.get("url"):
|
||||
feed.append(item)
|
||||
|
||||
russian_only_fallback = [item for item in feed if _is_russian_feed_item(item)]
|
||||
if russian_only_fallback:
|
||||
feed = russian_only_fallback
|
||||
random.shuffle(feed)
|
||||
return feed[:count]
|
||||
55
zparser.py
55
zparser.py
|
|
@ -1,55 +0,0 @@
|
|||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import time
|
||||
|
||||
def get_military_data(channel_username, depth=5):
|
||||
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'}
|
||||
last_id = None
|
||||
|
||||
for i in range(depth):
|
||||
url = f"https://t.me/s/{channel_username}"
|
||||
if last_id:
|
||||
url += f"?before={last_id}"
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
messages = soup.find_all('div', class_='tgme_widget_message')
|
||||
|
||||
if not messages:
|
||||
break
|
||||
|
||||
first_msg_data = messages[0].get('data-post')
|
||||
if first_msg_data:
|
||||
last_id = first_msg_data.split('/')[-1]
|
||||
|
||||
for msg in reversed(messages):
|
||||
text_elem = msg.find('div', class_='tgme_widget_message_text')
|
||||
if not text_elem:
|
||||
continue
|
||||
|
||||
text = " ".join(text_elem.get_text(separator=' ').split())
|
||||
text_lower = text.lower()
|
||||
|
||||
if "темп" in text_lower and "продвижения" in text_lower:
|
||||
# Улучшенная регулярка для дат (ловит и "с 14 по 17", и "с 14 февраля по 17 февраля")
|
||||
dates_match = re.search(r"с (.*?)(?=\.|\s+темп)", text_lower)
|
||||
# Твои регулярки для цифр — отличные
|
||||
pace_match = re.search(r"темп[^+-]*([+-]\d+[.,]\d+)", text_lower)
|
||||
total_match = re.search(r"общее продвижение\s*(\d+[.,]\d+)", text_lower)
|
||||
|
||||
if pace_match:
|
||||
return {
|
||||
"status": "success",
|
||||
"dates": dates_match.group(1).strip() if dates_match else "не указаны",
|
||||
"pace": pace_match.group(1).replace(',', '.'),
|
||||
"total": total_match.group(1).replace(',', '.') if total_match else "0",
|
||||
"post_id": msg.get('data-post').split('/')[-1]
|
||||
}
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
print(f"Ошибка на итерации {i}: {e}")
|
||||
continue
|
||||
|
||||
return {"status": "error", "message": "Сводка не найдена в последних постах"}
|
||||
Loading…
Reference in a new issue