1
0
Fork 0
forked from zovos/bot_tg

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 .idea
.vscode .vscode
.DS_Store .DS_Store
db

View file

@ -1,10 +1,15 @@
FROM python:3.11-slim FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app WORKDIR /app
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN python -m pip install --upgrade pip \
COPY main.py config.py impact.ttf zparser.py ./ && python -m pip install -r requirements.txt
COPY maket ./maket
COPY AI ./AI COPY . .
COPY games ./games
RUN mkdir -p /db
CMD ["python", "main.py"] CMD ["python", "main.py"]

View file

@ -13,11 +13,28 @@
## Запуск ## Запуск
```bash ```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 сообщений). - `/talk <сообщение>` — побазарить с ИИ (контекст 100 сообщений).
- `/penis` — миниигра, 1 раз в 24 часа. - `/penis` — миниигра, 1 раз в 24 часа.
- `/top_penis` — топ по длине. - `/top_penis` — топ по длине.
- `/polychaetsi` — топ по слову «получается».
- `/penis_casino <ставка>` — казино на размер (макс 1 см, казино всегда в выигрыше). - `/penis_casino <ставка>` — казино на размер (макс 1 см, казино всегда в выигрыше).
- `/gadanie <тема>` — ИИ-гадание на фене + авто-мем. - `/gadanie <тема>` — ИИ-гадание на фене + авто-мем.
- `/ebalnik` — вкл/выкл авто-ответы бота в чате. - `/ebalnik` — вкл/выкл авто-ответы бота в чате.
@ -45,6 +63,12 @@ docker-compose up --build
- llama.cpp для ИИ-фич. - llama.cpp для ИИ-фич.
- Dockerfile + docker-compose для развёртывания. - Dockerfile + docker-compose для развёртывания.
## Docker Notes
- Образ ставит `tzdata`, поэтому `ZoneInfo("Europe/Moscow")` нормально работает в контейнере.
- В образ копируется весь проект, а runtime-данные исключены через `.dockerignore`.
- Если меняешь код или картинки, достаточно пересобрать: `docker compose up --build -d`.
## Кратко о логике ## Кратко о логике
- `/maket` подбирает размер текста, чтобы уместить его в область макета. - `/maket` подбирает размер текста, чтобы уместить его в область макета.

View file

@ -18,6 +18,12 @@ PENIS_COOLDOWN = timedelta(hours=24)
PENIS_COOLDOWN_SECONDS = int(PENIS_COOLDOWN.total_seconds()) PENIS_COOLDOWN_SECONDS = int(PENIS_COOLDOWN.total_seconds())
PENIS_TOP_LIMIT = 10 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 CASINO_MAX_BET = 1.0
@ -112,6 +118,7 @@ def get_hint_text(templates_str: str) -> str:
"• /penis_casino [ставка] — казино на размер.\n" "• /penis_casino [ставка] — казино на размер.\n"
"• /gadanie [тема] — гадание на фене с мемом.\n" "• /gadanie [тема] — гадание на фене с мемом.\n"
"• /ebalnik — включить/выключить автоответы в чате.\n" "• /ebalnik — включить/выключить автоответы в чате.\n"
"• /polychaetsi — топ по слову «получается».\n"
"• /prices /fetch — цены и остатки Магнита.\n" "• /prices /fetch — цены и остатки Магнита.\n"
"Подсказка: в группах пиши /команда@username_бота." "Подсказка: в группах пиши /команда@username_бота."
) )

View file

@ -1,10 +1,15 @@
version: "3.9"
services: services:
bot: bot:
build: . build:
context: .
volumes: volumes:
- ./db:/db - ./db:/db
env_file: env_file:
- .env - .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 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 datetime import datetime, time, timedelta
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
@ -26,6 +26,8 @@ import config
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) 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.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)")
conn.commit() 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: def templates_list_text() -> str:
"""Список макетов для подсказки.""" """Список макетов для подсказки."""
parts = [] parts = []
@ -572,6 +581,161 @@ def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
lines.append(f"{prefix} {name}{length:.1f} см") lines.append(f"{prefix} {name}{length:.1f} см")
return "\n".join(lines) 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 --- # --- Хендлеры aiogram ---
async def handle_help(message: Message): 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): async def handle_top_penis_cmd(message: Message):
await message.answer(build_penis_top()) 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): async def handle_size_cmd(message: Message):
size = round(random.uniform(0.1, 35.0), 1) size = round(random.uniform(0.1, 35.0), 1)
if size < 5: if size < 5:
@ -765,6 +932,20 @@ async def handle_keywords(message: Message):
) )
return 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) 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 не задан!") if not token: raise RuntimeError("BOT_TOKEN не задан!")
init_penis_db() init_penis_db()
init_polychaetsi_storage()
bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher() dp = Dispatcher()
@ -846,6 +1028,7 @@ def main():
dp.message.register(handle_size_cmd, Command("size")) dp.message.register(handle_size_cmd, Command("size"))
dp.message.register(handle_penis_cmd, Command("penis")) dp.message.register(handle_penis_cmd, Command("penis"))
dp.message.register(handle_top_penis_cmd, Command("top_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_prices_cmd, Command("prices"))
dp.message.register(handle_fetch_cmd, Command("fetch")) dp.message.register(handle_fetch_cmd, Command("fetch"))
dp.message.register(handle_zvetok_cmd, Command("zvetok")) dp.message.register(handle_zvetok_cmd, Command("zvetok"))
@ -868,6 +1051,7 @@ def main():
BotCommand(command="size", description="хуй"), BotCommand(command="size", description="хуй"),
BotCommand(command="penis", description="миниигра 1 раз в 24ч"), BotCommand(command="penis", description="миниигра 1 раз в 24ч"),
BotCommand(command="top_penis", description="топ по длине"), BotCommand(command="top_penis", description="топ по длине"),
BotCommand(command="polychaetsi", description="топ по получается"),
BotCommand(command="prices", description="цены и остатки Магнита"), BotCommand(command="prices", description="цены и остатки Магнита"),
BotCommand(command="fetch", description="alias для /prices"), BotCommand(command="fetch", description="alias для /prices"),
BotCommand(command="zvetok", description="Цветянский бля"), 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 aiogram>=3.4.1
pillow>=10.3.0 pillow>=10.3.0
requests>=2.31.0 requests>=2.31.0
aiohttp>=3.9.5
tzdata>=2024.1
beautifulsoup4 beautifulsoup4
lxml lxml
pysqlite3-binary>=0.5.3 pysqlite3-binary>=0.5.3