Add polychaetsi tracker and improve Docker setup
This commit is contained in:
parent
43503dc778
commit
5b294415d0
8 changed files with 241 additions and 13 deletions
|
|
@ -5,3 +5,4 @@ __pycache__
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
db
|
||||||
|
|
|
||||||
17
Dockerfile
17
Dockerfile
|
|
@ -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"]
|
||||||
|
|
|
||||||
30
README.md
30
README.md
|
|
@ -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` подбирает размер текста, чтобы уместить его в область макета.
|
||||||
|
|
|
||||||
|
|
@ -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_бота."
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
186
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 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
BIN
maket/cvetok.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue