Add polychaetsi tracker and improve Docker setup

This commit is contained in:
q 2026-03-10 13:08:04 +03:00
parent 43503dc778
commit 5b294415d0
8 changed files with 241 additions and 13 deletions

View file

@ -5,3 +5,4 @@ __pycache__
.idea
.vscode
.DS_Store
db

View file

@ -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"]

View file

@ -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` подбирает размер текста, чтобы уместить его в область макета.

View file

@ -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_бота."
)

View file

@ -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

186
main.py
View file

@ -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="Цветянский бля"),

BIN
maket/cvetok.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -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