diff --git a/.dockerignore b/.dockerignore index c024517..07421c8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,4 @@ __pycache__ .idea .vscode .DS_Store +db diff --git a/Dockerfile b/Dockerfile index d84e227..2b3c823 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,15 @@ FROM python:3.11-slim -ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 WORKDIR /app + COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt -COPY main.py config.py impact.ttf zparser.py ./ -COPY maket ./maket -COPY AI ./AI -COPY games ./games +RUN python -m pip install --upgrade pip \ + && python -m pip install -r requirements.txt + +COPY . . + +RUN mkdir -p /db + CMD ["python", "main.py"] diff --git a/README.md b/README.md index 4528060..32e9f72 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,28 @@ ## Запуск ```bash -docker-compose up --build +docker compose up --build -d ``` -Переменные окружения: задайте `BOT_TOKEN` в `.env` (уже добавлен шаблон); -`LLAMA_API_URL` — адрес llama.cpp сервера (для `/talk`) +Логи: + +```bash +docker compose logs -f bot +``` + +Остановка: + +```bash +docker compose down +``` + +Переменные окружения: задайте `BOT_TOKEN` в `.env`. +Для ИИ-фич нужен `LLAMA_API_URL`. + +Контейнер хранит состояние в `./db`: +- `penis_stats.sqlite3` +- `chat_history.sqlite3` +- `polychaetsi_stats.json` ## Команды @@ -29,6 +46,7 @@ docker-compose up --build - `/talk <сообщение>` — побазарить с ИИ (контекст 100 сообщений). - `/penis` — миниигра, 1 раз в 24 часа. - `/top_penis` — топ по длине. +- `/polychaetsi` — топ по слову «получается». - `/penis_casino <ставка>` — казино на размер (макс 1 см, казино всегда в выигрыше). - `/gadanie <тема>` — ИИ-гадание на фене + авто-мем. - `/ebalnik` — вкл/выкл авто-ответы бота в чате. @@ -45,6 +63,12 @@ docker-compose up --build - llama.cpp для ИИ-фич. - Dockerfile + docker-compose для развёртывания. +## Docker Notes + +- Образ ставит `tzdata`, поэтому `ZoneInfo("Europe/Moscow")` нормально работает в контейнере. +- В образ копируется весь проект, а runtime-данные исключены через `.dockerignore`. +- Если меняешь код или картинки, достаточно пересобрать: `docker compose up --build -d`. + ## Кратко о логике - `/maket` подбирает размер текста, чтобы уместить его в область макета. diff --git a/config.py b/config.py index 5e191aa..9c7aaae 100644 --- a/config.py +++ b/config.py @@ -18,6 +18,12 @@ 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 @@ -112,6 +118,7 @@ def get_hint_text(templates_str: str) -> str: "• /penis_casino [ставка] — казино на размер.\n" "• /gadanie [тема] — гадание на фене с мемом.\n" "• /ebalnik — включить/выключить автоответы в чате.\n" + "• /polychaetsi — топ по слову «получается».\n" "• /prices /fetch — цены и остатки Магнита.\n" "Подсказка: в группах пиши /команда@username_бота." ) diff --git a/docker-compose.yaml b/docker-compose.yaml index 19f22e3..7432aa9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,10 +1,15 @@ -version: "3.9" - services: bot: - build: . + build: + context: . 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 + init: true restart: unless-stopped diff --git a/main.py b/main.py index 737ed36..7a623d7 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ # Системные импорты -import asyncio, logging, os, re, random, sqlite3 +import asyncio, json, logging, os, re, random, sqlite3, threading from datetime import datetime, time, timedelta from io import BytesIO from pathlib import Path @@ -26,6 +26,8 @@ import config logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +_polychaetsi_lock = threading.Lock() +_polychaetsi_word_re = re.compile(r"[а-яё]+", re.IGNORECASE) # --- Инициализация и вспомогательные функции --- @@ -46,6 +48,13 @@ def init_penis_db() -> None: conn.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)") conn.commit() +def init_polychaetsi_storage() -> None: + """Инициализация json-хранилища статистики слова 'получается'.""" + path = config.POLYCHAETSI_STATS_PATH + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + path.write_text('{"users": {}}', encoding="utf-8") + def templates_list_text() -> str: """Список макетов для подсказки.""" parts = [] @@ -572,6 +581,161 @@ def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str: lines.append(f"{prefix} {name} — {length:.1f} см") return "\n".join(lines) +def _load_polychaetsi_stats() -> dict: + path = config.POLYCHAETSI_STATS_PATH + if not path.exists(): + return {"users": {}} + + try: + with path.open("r", encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, json.JSONDecodeError): + logger.exception("Failed to load polychaetsi stats") + return {"users": {}} + + if not isinstance(data, dict): + return {"users": {}} + + users = data.get("users") + if not isinstance(users, dict): + data["users"] = {} + return data + +def _save_polychaetsi_stats(data: dict) -> None: + path = config.POLYCHAETSI_STATS_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 _damerau_levenshtein_distance(source: str, target: str, max_distance: int = 2) -> int: + if source == target: + return 0 + if abs(len(source) - len(target)) > max_distance: + return max_distance + 1 + + previous_previous: list[int] | None = None + previous = list(range(len(target) + 1)) + + for i, source_char in enumerate(source, start=1): + current = [i] + [0] * len(target) + row_min = current[0] + + for j, target_char in enumerate(target, start=1): + cost = 0 if source_char == target_char else 1 + current[j] = min( + previous[j] + 1, + current[j - 1] + 1, + previous[j - 1] + cost, + ) + if ( + previous_previous is not None + and i > 1 + and j > 1 + and source_char == target[j - 2] + and source[i - 2] == target_char + ): + current[j] = min(current[j], previous_previous[j - 2] + 1) + + row_min = min(row_min, current[j]) + + if row_min > max_distance: + return max_distance + 1 + + previous_previous, previous = previous, current + + return previous[-1] + +def _normalize_polychaetsi_word(word: str) -> str: + normalized = word.lower().replace("ё", "е") + # Подтягиваем самые частые ошибки в возвратном окончании. + return re.sub(r"(ть?ся|ть?са|цца|ца)$", "тся", normalized) + +def is_polychaetsi_variant(word: str) -> bool: + normalized = _normalize_polychaetsi_word(word) + target = config.POLYCHAETSI_TARGET + + if normalized == target: + return True + if not normalized or normalized[0] != "п": + return False + if len(normalized) < len(target) - 1 or len(normalized) > len(target) + 1: + return False + if "ч" not in normalized or "а" not in normalized or "е" not in normalized: + return False + if not normalized.endswith(("я", "а")): + return False + + return _damerau_levenshtein_distance(normalized, target, max_distance=2) <= 2 + +def count_polychaetsi_mentions(text: str) -> int: + return sum(1 for word in _polychaetsi_word_re.findall(text) if is_polychaetsi_variant(word)) + +def register_polychaetsi_hits(user: types.User, hits: int) -> int: + display_name = format_penis_user_name(user) + with _polychaetsi_lock: + data = _load_polychaetsi_stats() + users = data.setdefault("users", {}) + + user_key = str(user.id) + entry = users.get(user_key) + if not isinstance(entry, dict): + entry = {"display_name": display_name, "count": 0} + + stored_count = entry.get("count", 0) + try: + count = int(stored_count) + except (TypeError, ValueError): + count = 0 + + count += hits + entry["display_name"] = display_name + entry["count"] = count + users[user_key] = entry + _save_polychaetsi_stats(data) + return count + +def build_polychaetsi_top(limit: int = config.POLYCHAETSI_TOP_LIMIT) -> str: + with _polychaetsi_lock: + data = _load_polychaetsi_stats() + + users = data.get("users", {}) + if not isinstance(users, dict): + return "ТОП получается:\nпока пусто." + + entries: list[tuple[int, str, int]] = [] + for user_key, raw_entry in users.items(): + if not isinstance(raw_entry, dict): + continue + + try: + count = int(raw_entry.get("count", 0)) + except (TypeError, ValueError): + continue + + if count <= 0: + continue + + try: + numeric_user_id = int(user_key) + except (TypeError, ValueError): + numeric_user_id = 0 + + name = str(raw_entry.get("display_name") or f"user_{user_key}").strip() or f"user_{user_key}" + entries.append((count, name, numeric_user_id)) + + if not entries: + return "ТОП получается:\nпока пусто." + + entries.sort(key=lambda item: (-item[0], item[1].lower(), item[2])) + medals = {1: "🥇", 2: "🥈", 3: "🥉"} + lines = ["ТОП получается:"] + for index, (count, name, _) in enumerate(entries[:limit], start=1): + prefix = medals.get(index, f"{index}.") + lines.append(f"{prefix} {name} — {count}") + return "\n".join(lines) + # --- Хендлеры aiogram --- async def handle_help(message: Message): @@ -599,6 +763,9 @@ async def handle_penis_cmd(message: Message): async def handle_top_penis_cmd(message: Message): await message.answer(build_penis_top()) +async def handle_polychaetsi_cmd(message: Message): + await message.answer(build_polychaetsi_top()) + async def handle_size_cmd(message: Message): size = round(random.uniform(0.1, 35.0), 1) if size < 5: @@ -765,6 +932,20 @@ async def handle_keywords(message: Message): ) return + polychaetsi_hits = count_polychaetsi_mentions(text_lower) + if polychaetsi_hits > 0 and message.from_user: + total_hits = await asyncio.to_thread(register_polychaetsi_hits, message.from_user, polychaetsi_hits) + photo_path = config.POLYCHAETSI_IMAGE_PATH + if photo_path.exists(): + await message.answer_photo( + photo=FSInputFile(photo_path), + caption=f"x{total_hits}", + ) + else: + logger.warning("cvetok.jpg not found, fallback to text response") + await message.answer(f"x{total_hits}") + return + # Записываем каждое обычное сообщение в историю чата await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text) @@ -835,6 +1016,7 @@ def main(): if not token: raise RuntimeError("BOT_TOKEN не задан!") init_penis_db() + init_polychaetsi_storage() bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) dp = Dispatcher() @@ -846,6 +1028,7 @@ def main(): dp.message.register(handle_size_cmd, Command("size")) dp.message.register(handle_penis_cmd, Command("penis")) dp.message.register(handle_top_penis_cmd, Command("top_penis")) + dp.message.register(handle_polychaetsi_cmd, Command("polychaetsi")) dp.message.register(handle_prices_cmd, Command("prices")) dp.message.register(handle_fetch_cmd, Command("fetch")) dp.message.register(handle_zvetok_cmd, Command("zvetok")) @@ -868,6 +1051,7 @@ def main(): BotCommand(command="size", description="хуй"), BotCommand(command="penis", description="миниигра 1 раз в 24ч"), BotCommand(command="top_penis", description="топ по длине"), + BotCommand(command="polychaetsi", description="топ по получается"), BotCommand(command="prices", description="цены и остатки Магнита"), BotCommand(command="fetch", description="alias для /prices"), BotCommand(command="zvetok", description="Цветянский бля"), diff --git a/maket/cvetok.jpg b/maket/cvetok.jpg new file mode 100644 index 0000000..dc4e6a6 Binary files /dev/null and b/maket/cvetok.jpg differ diff --git a/requirements.txt b/requirements.txt index 816b45e..8a88b7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ aiogram>=3.4.1 pillow>=10.3.0 requests>=2.31.0 +aiohttp>=3.9.5 +tzdata>=2024.1 beautifulsoup4 lxml pysqlite3-binary>=0.5.3