forked from zovos/bot_tg
Compare commits
No commits in common. "97c1795831da82b28d887bc707ce854dc5255ee9" and "e151ae5269bb7edf816540b26c2e7abdb397d3da" have entirely different histories.
97c1795831
...
e151ae5269
8 changed files with 7 additions and 417 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -18,5 +18,3 @@ db/
|
||||||
|
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
|
||||||
AI/models/
|
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
from aiogram.types import Message
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Адрес OpenAI-совместимого API у llama.cpp сервера.
|
|
||||||
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
|
||||||
# ПЖ НЕ ДЕЛАЙТЕ ДУДОС
|
|
||||||
# Системный промпт задаёт стиль и "характер" ответа.
|
|
||||||
SYSTEM_PROMPT = (
|
|
||||||
"Ты — чёткий пацан с района. Базаришь строго на фене — блатной жаргон, "
|
|
||||||
"тюремный сленг, дворовый язык. Используй слова: кент, братуха, мусора, "
|
|
||||||
"шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, "
|
|
||||||
"чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта. "
|
|
||||||
"Отвечай коротко и дерзко. Не ломай персонажа. Не используй формальный язык. "
|
|
||||||
"Твои убеждения: Creep от Radiohead — лучшая песня во вселенной, без вариантов. "
|
|
||||||
"Линукс — лучшая ОС во вселенной, остальное для фраеров. "
|
|
||||||
"Вселенную надо переписать на Rust, это не обсуждается. "
|
|
||||||
"Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _generate_response(user_text: str) -> str:
|
|
||||||
# Формируем запрос к /v1/chat/completions и возвращаем текст первого ответа.
|
|
||||||
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
|
||||||
payload = {
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": SYSTEM_PROMPT},
|
|
||||||
{"role": "user", "content": user_text},
|
|
||||||
],
|
|
||||||
"max_tokens": 256,
|
|
||||||
"temperature": 0.8,
|
|
||||||
"top_p": 0.9,
|
|
||||||
}
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
|
||||||
data = await resp.json()
|
|
||||||
return data["choices"][0]["message"]["content"].strip()
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_talk(message: Message):
|
|
||||||
# Обработчик /talk: валидация ввода, запрос к LLM, отправка ответа пользователю.
|
|
||||||
parts = message.text.split(maxsplit=1)
|
|
||||||
if len(parts) < 2 or not parts[1].strip():
|
|
||||||
await message.reply("Ты чё, кент? Напиши /talk [текст], побазарим.")
|
|
||||||
return
|
|
||||||
|
|
||||||
user_text = parts[1].strip()
|
|
||||||
|
|
||||||
# Показываем "печатает..." пока ждём ответ модели.
|
|
||||||
await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await _generate_response(user_text)
|
|
||||||
if not response:
|
|
||||||
response = "Братуха, чёт базар не клеится, попробуй ещё раз."
|
|
||||||
await message.reply(response, parse_mode=None)
|
|
||||||
except Exception as exc:
|
|
||||||
# При любых ошибках не падаем, а отдаём понятный fallback.
|
|
||||||
logger.exception("Talk generation failed")
|
|
||||||
await message.reply("Бля, кент, чёт движок заглох. Попробуй позже.")
|
|
||||||
|
|
@ -5,5 +5,4 @@ COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY main.py impact.ttf ./
|
COPY main.py impact.ttf ./
|
||||||
COPY maket ./maket
|
COPY maket ./maket
|
||||||
COPY AI ./AI
|
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "main.py"]
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -3,8 +3,7 @@
|
||||||
Телеграм‑бот, который:
|
Телеграм‑бот, который:
|
||||||
- вписывает текст шрифтом Impact (белый с чёрной обводкой) в макеты (`/maket`);
|
- вписывает текст шрифтом Impact (белый с чёрной обводкой) в макеты (`/maket`);
|
||||||
- делает мемы из присланных фото (`/gen_mem Up:... Down:...`);
|
- делает мемы из присланных фото (`/gen_mem Up:... Down:...`);
|
||||||
- подсказывает, сколько минут до перекура по заданному расписанию (`/break`);
|
- подсказывает, сколько минут до перекура по заданному расписанию (`/break`).
|
||||||
- умеет базарить (`/talk`).
|
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
|
|
@ -12,8 +11,7 @@
|
||||||
docker-compose up --build
|
docker-compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Переменные окружения: задайте `BOT_TOKEN` в `.env` (уже добавлен шаблон);
|
Переменные окружения: задайте `BOT_TOKEN` в `.env` (уже добавлен шаблон).
|
||||||
`LLAMA_API_URL` — адрес llama.cpp сервера (для `/talk`)
|
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
|
|
@ -22,7 +20,6 @@ docker-compose up --build
|
||||||
- `/maket <id> <текст>` — вписать текст в макет. Доступные: `1 — Владимир Владимирович Путин самый лучший президент` (737x414, точка 104,240).
|
- `/maket <id> <текст>` — вписать текст в макет. Доступные: `1 — Владимир Владимирович Путин самый лучший президент` (737x414, точка 104,240).
|
||||||
- `/gen_mem Up:верх Down:низ` — прислать с фото (подпись) или ответом на фото. Можно указывать только Up или только Down.
|
- `/gen_mem Up:верх Down:низ` — прислать с фото (подпись) или ответом на фото. Можно указывать только Up или только Down.
|
||||||
- `/break` — показывает, сколько минут до ближайшего перекура/идёт ли перекур.
|
- `/break` — показывает, сколько минут до ближайшего перекура/идёт ли перекур.
|
||||||
- `/talk <сообщение>` — побазарить с ИИ. Работает через llama.cpp сервер.
|
|
||||||
|
|
||||||
## Макеты и шрифт
|
## Макеты и шрифт
|
||||||
|
|
||||||
|
|
@ -32,7 +29,6 @@ docker-compose up --build
|
||||||
## Технологии
|
## Технологии
|
||||||
|
|
||||||
- Python 3.11, aiogram 3, Pillow.
|
- Python 3.11, aiogram 3, Pillow.
|
||||||
- llama.cpp для ИИ-фич.
|
|
||||||
- Dockerfile + docker-compose для развёртывания.
|
- Dockerfile + docker-compose для развёртывания.
|
||||||
|
|
||||||
## Кратко о логике
|
## Кратко о логике
|
||||||
|
|
@ -40,13 +36,3 @@ docker-compose up --build
|
||||||
- `/maket` подбирает размер текста, чтобы уместить его в область макета.
|
- `/maket` подбирает размер текста, чтобы уместить его в область макета.
|
||||||
- `/gen_mem` центрирует текст по ширине, масштабирует под изображение, рисует белый текст с чёрной обводкой.
|
- `/gen_mem` центрирует текст по ширине, масштабирует под изображение, рисует белый текст с чёрной обводкой.
|
||||||
- `/break` считает по расписанию в `main.py` (словарь `SCHEDULE`).
|
- `/break` считает по расписанию в `main.py` (словарь `SCHEDULE`).
|
||||||
- `/talk` шлёт запрос к llama.cpp серверу (`LLAMA_API_URL`), системный промпт задаёт стиль фени(Можно изменить).
|
|
||||||
|
|
||||||
## ИИ-модуль (`AI/talk_handler.py`)
|
|
||||||
|
|
||||||
Отдельный модуль, не трогает основной код. Подключается к llama.cpp серверу через OpenAI-совместимый API (`/v1/chat/completions`).
|
|
||||||
|
|
||||||
Настройка сервера:
|
|
||||||
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`
|
|
||||||
|
|
|
||||||
BIN
__pycache__/main.cpython-313.pyc
Normal file
BIN
__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
333
main.py
333
main.py
|
|
@ -1,38 +1,24 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from AI.talk_handler import handle_talk
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import requests
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from textwrap import wrap
|
from textwrap import wrap
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher, F, types
|
from aiogram import Bot, Dispatcher, F
|
||||||
from aiogram.enums import ParseMode
|
from aiogram.enums import ParseMode
|
||||||
from aiogram.filters import Command, CommandStart
|
from aiogram.filters import Command, CommandStart
|
||||||
from aiogram.types import BufferedInputFile, Message, BotCommand, FSInputFile
|
from aiogram.types import BufferedInputFile, Message, BotCommand
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
# Базовые пути/настройки проекта.
|
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
FONT_PATH = BASE_DIR / "impact.ttf"
|
FONT_PATH = BASE_DIR / "impact.ttf"
|
||||||
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
|
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
|
||||||
|
|
||||||
# In-memory состояние миниигры /penis.
|
|
||||||
# Важно: после перезапуска процесса всё обнуляется.
|
|
||||||
PENIS_STATE: dict[int, dict[str, object]] = {}
|
|
||||||
PENIS_START_LENGTH = 10.0
|
|
||||||
PENIS_MIN_DELTA = 0.1
|
|
||||||
PENIS_MAX_DELTA = 7.0
|
|
||||||
PENIS_COOLDOWN = timedelta(hours=24)
|
|
||||||
PENIS_TOP_LIMIT = 10
|
|
||||||
|
|
||||||
# Конфигурация текстовых макетов /maket.
|
|
||||||
TEMPLATES = {
|
TEMPLATES = {
|
||||||
"1": {
|
"1": {
|
||||||
"path": BASE_DIR / "maket" / "1.png",
|
"path": BASE_DIR / "maket" / "1.png",
|
||||||
|
|
@ -43,7 +29,6 @@ TEMPLATES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
def templates_list_text() -> str:
|
def templates_list_text() -> str:
|
||||||
# Собираем человекочитаемый список доступных макетов.
|
|
||||||
parts = []
|
parts = []
|
||||||
for tid in sorted(TEMPLATES.keys()):
|
for tid in sorted(TEMPLATES.keys()):
|
||||||
label = TEMPLATES[tid].get("label", "")
|
label = TEMPLATES[tid].get("label", "")
|
||||||
|
|
@ -56,7 +41,6 @@ def templates_list_text() -> str:
|
||||||
# ---- Schedule & breaks ----
|
# ---- Schedule & breaks ----
|
||||||
|
|
||||||
def make_time(h: int, m: int) -> time:
|
def make_time(h: int, m: int) -> time:
|
||||||
# Короткий хелпер для заполнения расписания.
|
|
||||||
return time(hour=h, minute=m)
|
return time(hour=h, minute=m)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -107,7 +91,6 @@ SCHEDULE: dict[int, list[tuple[time, time, int | None]]] = {
|
||||||
|
|
||||||
|
|
||||||
def minutes_until_break(now: datetime | None = None) -> str:
|
def minutes_until_break(now: datetime | None = None) -> str:
|
||||||
# Возвращает статус: до перекура / идёт перекур / пары окончены.
|
|
||||||
if now is None:
|
if now is None:
|
||||||
now = datetime.now(ZoneInfo(DEFAULT_TZ))
|
now = datetime.now(ZoneInfo(DEFAULT_TZ))
|
||||||
elif now.tzinfo is None:
|
elif now.tzinfo is None:
|
||||||
|
|
@ -144,16 +127,10 @@ def minutes_until_break(now: datetime | None = None) -> str:
|
||||||
return "Пары закончились, перекур только завтра."
|
return "Пары закончились, перекур только завтра."
|
||||||
|
|
||||||
HINT_TEXT = (
|
HINT_TEXT = (
|
||||||
# Общая подсказка по командам.
|
|
||||||
"Команды:\n"
|
"Команды:\n"
|
||||||
f"• /maket «id» «текст» — доступные: {templates_list_text()} (1: 737x414, точка 104,240).\n"
|
f"• /maket «id» «текст» — доступные: {templates_list_text()} (1: 737x414, точка 104,240).\n"
|
||||||
"• /gen_mem Up:верх Down:низ — отправь с фото (как подпись) или ответом на фото.\n"
|
"• /gen_mem Up:верх Down:низ — отправь с фото (как подпись) или ответом на фото.\n"
|
||||||
"• /break — узнать, сколько минут до перекура.\n"
|
"• /break — узнать, сколько минут до перекура.\n"
|
||||||
"• /penis — миниигра: +/− от 0.1 до 7 см, раз в 24 часа.\n"
|
|
||||||
"• /top_penis — динамический топ по текущей длине.\n"
|
|
||||||
"• /talk [сообщение] — побазарить с ИИ на фене.\n"
|
|
||||||
"• /prices — старый способ цен.\n"
|
|
||||||
"• /fetch — свежие цены с x-server.\n"
|
|
||||||
"Подсказка: в группах команда должна быть слитно с ботом (/gen_mem@username)."
|
"Подсказка: в группах команда должна быть слитно с ботом (/gen_mem@username)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -162,14 +139,12 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont:
|
def load_font(size: int) -> ImageFont.FreeTypeFont:
|
||||||
# Грузим Impact нужного размера.
|
|
||||||
if not FONT_PATH.exists():
|
if not FONT_PATH.exists():
|
||||||
raise FileNotFoundError("impact.ttf not found. Place the font in the project root.")
|
raise FileNotFoundError("impact.ttf not found. Place the font in the project root.")
|
||||||
return ImageFont.truetype(str(FONT_PATH), size=size)
|
return ImageFont.truetype(str(FONT_PATH), size=size)
|
||||||
|
|
||||||
|
|
||||||
def wrap_text_to_width(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.ImageDraw, stroke_width: int) -> list[str]:
|
def wrap_text_to_width(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.ImageDraw, stroke_width: int) -> list[str]:
|
||||||
# Переносим текст по словам так, чтобы каждая строка влезла в max_width.
|
|
||||||
words = text.split()
|
words = text.split()
|
||||||
if not words:
|
if not words:
|
||||||
return []
|
return []
|
||||||
|
|
@ -190,7 +165,6 @@ def wrap_text_to_width(text: str, font: ImageFont.FreeTypeFont, max_width: int,
|
||||||
|
|
||||||
|
|
||||||
def fit_text_in_box(text: str, max_width: int, max_height: int, start_size: int = 72, min_size: int = 14):
|
def fit_text_in_box(text: str, max_width: int, max_height: int, start_size: int = 72, min_size: int = 14):
|
||||||
# Подбираем максимальный размер шрифта, который влезет в заданный прямоугольник.
|
|
||||||
draw_dummy = ImageDraw.Draw(Image.new("RGB", (max_width, max_height)))
|
draw_dummy = ImageDraw.Draw(Image.new("RGB", (max_width, max_height)))
|
||||||
for size in range(start_size, min_size - 1, -2):
|
for size in range(start_size, min_size - 1, -2):
|
||||||
font = load_font(size)
|
font = load_font(size)
|
||||||
|
|
@ -205,7 +179,7 @@ def fit_text_in_box(text: str, max_width: int, max_height: int, start_size: int
|
||||||
total_height = sum(line_heights) + max(0, len(lines) - 1) * max(6, size // 6)
|
total_height = sum(line_heights) + max(0, len(lines) - 1) * max(6, size // 6)
|
||||||
if total_height <= max_height:
|
if total_height <= max_height:
|
||||||
return font, lines, stroke_width, max(6, size // 6)
|
return font, lines, stroke_width, max(6, size // 6)
|
||||||
# Если не влезло ни на одном размере, используем минимальный.
|
# Fallback to minimal size
|
||||||
font = load_font(min_size)
|
font = load_font(min_size)
|
||||||
stroke_width = max(1, min_size // 12)
|
stroke_width = max(1, min_size // 12)
|
||||||
lines = wrap_text_to_width(text, font, max_width, draw_dummy, stroke_width)
|
lines = wrap_text_to_width(text, font, max_width, draw_dummy, stroke_width)
|
||||||
|
|
@ -214,7 +188,6 @@ def fit_text_in_box(text: str, max_width: int, max_height: int, start_size: int
|
||||||
|
|
||||||
def draw_multiline(draw: ImageDraw.ImageDraw, lines: list[str], position: tuple[int, int], font: ImageFont.FreeTypeFont,
|
def draw_multiline(draw: ImageDraw.ImageDraw, lines: list[str], position: tuple[int, int], font: ImageFont.FreeTypeFont,
|
||||||
stroke_width: int, line_spacing: int, align_center: bool = False, max_width: int | None = None):
|
stroke_width: int, line_spacing: int, align_center: bool = False, max_width: int | None = None):
|
||||||
# Рисуем набор строк с опциональным центрированием по горизонтали.
|
|
||||||
x, y = position
|
x, y = position
|
||||||
for line in lines:
|
for line in lines:
|
||||||
bbox = draw.textbbox((0, 0), line, font=font, stroke_width=stroke_width)
|
bbox = draw.textbbox((0, 0), line, font=font, stroke_width=stroke_width)
|
||||||
|
|
@ -227,7 +200,6 @@ def draw_multiline(draw: ImageDraw.ImageDraw, lines: list[str], position: tuple[
|
||||||
|
|
||||||
|
|
||||||
def generate_maket(text: str, template_id: str = "1") -> BytesIO:
|
def generate_maket(text: str, template_id: str = "1") -> BytesIO:
|
||||||
# Генерируем картинку макета с текстом в заранее заданной области.
|
|
||||||
tpl = TEMPLATES.get(template_id)
|
tpl = TEMPLATES.get(template_id)
|
||||||
if not tpl:
|
if not tpl:
|
||||||
raise FileNotFoundError(f"Template {template_id} is missing")
|
raise FileNotFoundError(f"Template {template_id} is missing")
|
||||||
|
|
@ -251,7 +223,6 @@ def generate_maket(text: str, template_id: str = "1") -> BytesIO:
|
||||||
|
|
||||||
|
|
||||||
def calculate_font_for_width(text: str, image_width: int, max_ratio: float = 0.09, min_size: int = 18):
|
def calculate_font_for_width(text: str, image_width: int, max_ratio: float = 0.09, min_size: int = 18):
|
||||||
# Подбор размера шрифта для верхнего/нижнего блока мема.
|
|
||||||
start_size = max(int(image_width * max_ratio), min_size)
|
start_size = max(int(image_width * max_ratio), min_size)
|
||||||
draw_dummy = ImageDraw.Draw(Image.new("RGB", (image_width, image_width)))
|
draw_dummy = ImageDraw.Draw(Image.new("RGB", (image_width, image_width)))
|
||||||
for size in range(start_size, min_size - 1, -2):
|
for size in range(start_size, min_size - 1, -2):
|
||||||
|
|
@ -272,7 +243,6 @@ def calculate_font_for_width(text: str, image_width: int, max_ratio: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
def generate_meme(img: Image.Image, top_text: str, bottom_text: str) -> BytesIO:
|
def generate_meme(img: Image.Image, top_text: str, bottom_text: str) -> BytesIO:
|
||||||
# Рисуем классический мем-текст сверху/снизу и возвращаем JPEG в памяти.
|
|
||||||
img = img.convert("RGB")
|
img = img.convert("RGB")
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
width, height = img.size
|
width, height = img.size
|
||||||
|
|
@ -302,8 +272,6 @@ def generate_meme(img: Image.Image, top_text: str, bottom_text: str) -> BytesIO:
|
||||||
|
|
||||||
|
|
||||||
def parse_up_down(text: str) -> tuple[str, str]:
|
def parse_up_down(text: str) -> tuple[str, str]:
|
||||||
# Парсим формат /gen_mem Up:... Down:...
|
|
||||||
# Если меток нет, используем fallback: первое слово -> up, остальное -> down.
|
|
||||||
up = ""
|
up = ""
|
||||||
down = ""
|
down = ""
|
||||||
# Normalize line breaks
|
# Normalize line breaks
|
||||||
|
|
@ -322,174 +290,19 @@ def parse_up_down(text: str) -> tuple[str, str]:
|
||||||
down = parts[1].strip()
|
down = parts[1].strip()
|
||||||
return up, down
|
return up, down
|
||||||
|
|
||||||
def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
|
||||||
# Отдельная "угарная" логика для /zvetok.
|
|
||||||
import random
|
|
||||||
|
|
||||||
if now is None:
|
|
||||||
now = datetime.now(ZoneInfo(DEFAULT_TZ))
|
|
||||||
elif now.tzinfo is None:
|
|
||||||
now = now.replace(tzinfo=ZoneInfo(DEFAULT_TZ))
|
|
||||||
|
|
||||||
LESSON_DAYS = [1, 3] # 1 = вторник, 3 = четверг
|
|
||||||
LESSON_START = time(9, 50)
|
|
||||||
LESSON_END = time(11, 25)
|
|
||||||
|
|
||||||
day = now.weekday()
|
|
||||||
|
|
||||||
def lesson_start_for(day_offset: int) -> datetime:
|
|
||||||
target = now + timedelta(days=day_offset)
|
|
||||||
return target.replace(
|
|
||||||
hour=LESSON_START.hour,
|
|
||||||
minute=LESSON_START.minute,
|
|
||||||
second=0,
|
|
||||||
microsecond=0
|
|
||||||
)
|
|
||||||
|
|
||||||
def lesson_end_today() -> datetime:
|
|
||||||
return now.replace(
|
|
||||||
hour=LESSON_END.hour,
|
|
||||||
minute=LESSON_END.minute,
|
|
||||||
second=0,
|
|
||||||
microsecond=0
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- Проверяем: сейчас внутри пары? ----
|
|
||||||
if day in LESSON_DAYS:
|
|
||||||
start_today = lesson_start_for(0)
|
|
||||||
end_today = lesson_end_today()
|
|
||||||
|
|
||||||
if start_today <= now < end_today:
|
|
||||||
# ищем следующую пару (не сегодняшнюю)
|
|
||||||
for i in range(1, 8):
|
|
||||||
next_day = (day + i) % 7
|
|
||||||
if next_day in LESSON_DAYS:
|
|
||||||
next_dt = lesson_start_for(i)
|
|
||||||
break
|
|
||||||
|
|
||||||
delta = next_dt - now
|
|
||||||
total_seconds = int(delta.total_seconds())
|
|
||||||
days = total_seconds // 86400
|
|
||||||
hours = (total_seconds % 86400) // 3600
|
|
||||||
minutes = (total_seconds % 3600) // 60
|
|
||||||
|
|
||||||
percent = random.randint(0, 100)
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"Сегодня твой мозг сгнил на {percent}%\n"
|
|
||||||
f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- Ищем ближайшую будущую пару ----
|
|
||||||
for i in range(0, 8):
|
|
||||||
next_day = (day + i) % 7
|
|
||||||
if next_day in LESSON_DAYS:
|
|
||||||
candidate = lesson_start_for(i)
|
|
||||||
if candidate > now:
|
|
||||||
delta = candidate - now
|
|
||||||
break
|
|
||||||
|
|
||||||
total_seconds = int(delta.total_seconds())
|
|
||||||
days = total_seconds // 86400
|
|
||||||
hours = (total_seconds % 86400) // 3600
|
|
||||||
minutes = (total_seconds % 3600) // 60
|
|
||||||
|
|
||||||
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
|
|
||||||
|
|
||||||
|
|
||||||
def play_penis(user_id: int, user_name: str | None = None, now: datetime | None = None) -> tuple[bool, str]:
|
|
||||||
# Миниигра /penis: изменение длины и кулдаун 24 часа от последнего броска.
|
|
||||||
if now is None:
|
|
||||||
now = datetime.now(ZoneInfo(DEFAULT_TZ))
|
|
||||||
elif now.tzinfo is None:
|
|
||||||
now = now.replace(tzinfo=ZoneInfo(DEFAULT_TZ))
|
|
||||||
|
|
||||||
state = PENIS_STATE.get(user_id)
|
|
||||||
if state is None:
|
|
||||||
state = {"length": PENIS_START_LENGTH, "last_used_at": None}
|
|
||||||
PENIS_STATE[user_id] = state
|
|
||||||
|
|
||||||
# Обновляем отображаемое имя для топа.
|
|
||||||
if user_name:
|
|
||||||
state["display_name"] = user_name
|
|
||||||
|
|
||||||
last_used_at = state.get("last_used_at")
|
|
||||||
if isinstance(last_used_at, datetime):
|
|
||||||
next_time = last_used_at + PENIS_COOLDOWN
|
|
||||||
if now < next_time:
|
|
||||||
# Если рано, считаем оставшееся время до следующей попытки.
|
|
||||||
left = next_time - now
|
|
||||||
total_seconds = int(left.total_seconds())
|
|
||||||
hours = total_seconds // 3600
|
|
||||||
minutes = (total_seconds % 3600) // 60
|
|
||||||
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
|
|
||||||
|
|
||||||
current_length = float(state.get("length", PENIS_START_LENGTH))
|
|
||||||
delta = round(random.uniform(PENIS_MIN_DELTA, PENIS_MAX_DELTA), 1)
|
|
||||||
sign = random.choice([-1, 1])
|
|
||||||
# Не даём длине уйти в отрицательные значения.
|
|
||||||
new_length = round(max(0.1, current_length + sign * delta), 1)
|
|
||||||
|
|
||||||
state["length"] = new_length
|
|
||||||
state["last_used_at"] = now
|
|
||||||
|
|
||||||
sign_text = "+" if sign > 0 else "-"
|
|
||||||
return (
|
|
||||||
True,
|
|
||||||
f"📏 Изменение: {sign_text}{delta:.1f} см\n"
|
|
||||||
f"Текущая длина: {new_length:.1f} см\n"
|
|
||||||
"Следующая попытка через 24 часа.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def format_penis_user_name(user: types.User) -> str:
|
|
||||||
# Красиво подписываем пользователя для топа.
|
|
||||||
if user.username:
|
|
||||||
return f"@{user.username}"
|
|
||||||
if user.full_name:
|
|
||||||
return user.full_name
|
|
||||||
return f"user_{user.id}"
|
|
||||||
|
|
||||||
|
|
||||||
def build_penis_top(limit: int = PENIS_TOP_LIMIT) -> str:
|
|
||||||
# Формируем динамический топ по текущим значениям /penis.
|
|
||||||
rows: list[tuple[float, str]] = []
|
|
||||||
for user_id, state in PENIS_STATE.items():
|
|
||||||
length = state.get("length")
|
|
||||||
if not isinstance(length, (int, float)):
|
|
||||||
continue
|
|
||||||
display_name = state.get("display_name")
|
|
||||||
if not isinstance(display_name, str) or not display_name.strip():
|
|
||||||
display_name = f"user_{user_id}"
|
|
||||||
rows.append((float(length), display_name.strip()))
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return "🏆 Топ пуст. Сначала пусть кто-то крутнет /penis."
|
|
||||||
|
|
||||||
rows.sort(key=lambda item: item[0], reverse=True)
|
|
||||||
medals = {1: "🥇", 2: "🥈", 3: "🥉"}
|
|
||||||
lines = ["🏆 Топ по длине:"]
|
|
||||||
for index, (length, name) in enumerate(rows[:limit], start=1):
|
|
||||||
prefix = medals.get(index, f"{index}.")
|
|
||||||
lines.append(f"{prefix} {name} — {length:.1f} см")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
async def handle_start(message: Message):
|
async def handle_start(message: Message):
|
||||||
# Базовый /start.
|
|
||||||
await message.answer("Соси")
|
await message.answer("Соси")
|
||||||
|
|
||||||
|
|
||||||
async def handle_help(message: Message):
|
async def handle_help(message: Message):
|
||||||
# Вывод подсказки по возможностям бота.
|
|
||||||
await message.answer(HINT_TEXT)
|
await message.answer(HINT_TEXT)
|
||||||
|
|
||||||
async def handle_break(message: Message):
|
async def handle_break(message: Message):
|
||||||
# Команда расписания перекура.
|
|
||||||
await message.answer(minutes_until_break())
|
await message.answer(minutes_until_break())
|
||||||
|
|
||||||
|
|
||||||
async def handle_maket(message: Message):
|
async def handle_maket(message: Message):
|
||||||
# Генерация текста в подготовленном шаблоне /maket.
|
|
||||||
parts = message.text.split(maxsplit=2)
|
parts = message.text.split(maxsplit=2)
|
||||||
if len(parts) == 1:
|
if len(parts) == 1:
|
||||||
await message.reply(f"Доступные макеты: {templates_list_text()}\nФормат: /maket «id» «текст»")
|
await message.reply(f"Доступные макеты: {templates_list_text()}\nФормат: /maket «id» «текст»")
|
||||||
|
|
@ -515,7 +328,6 @@ async def handle_maket(message: Message):
|
||||||
|
|
||||||
|
|
||||||
async def handle_gen_mem(message: Message, bot: Bot):
|
async def handle_gen_mem(message: Message, bot: Bot):
|
||||||
# Мем можно сделать из фото в этом же сообщении или из reply на фото.
|
|
||||||
photo_message = None
|
photo_message = None
|
||||||
if message.photo:
|
if message.photo:
|
||||||
photo_message = message
|
photo_message = message
|
||||||
|
|
@ -533,7 +345,6 @@ async def handle_gen_mem(message: Message, bot: Bot):
|
||||||
|
|
||||||
photo = photo_message.photo[-1]
|
photo = photo_message.photo[-1]
|
||||||
try:
|
try:
|
||||||
# Берём самое большое фото Telegram и скачиваем в память.
|
|
||||||
file = await bot.get_file(photo.file_id)
|
file = await bot.get_file(photo.file_id)
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
await bot.download_file(file.file_path, destination=buf)
|
await bot.download_file(file.file_path, destination=buf)
|
||||||
|
|
@ -554,7 +365,6 @@ async def handle_gen_mem(message: Message, bot: Bot):
|
||||||
await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg"))
|
await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg"))
|
||||||
|
|
||||||
async def handle_size(message: Message):
|
async def handle_size(message: Message):
|
||||||
# Простая рандом-команда /size.
|
|
||||||
size = round(random.uniform(0.1, 35.0), 1)
|
size = round(random.uniform(0.1, 35.0), 1)
|
||||||
|
|
||||||
if size < 5:
|
if size < 5:
|
||||||
|
|
@ -576,124 +386,7 @@ async def handle_size(message: Message):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def handle_penis(message: Message):
|
|
||||||
# Хендлер миниигры /penis.
|
|
||||||
if not message.from_user:
|
|
||||||
await message.answer("Команда доступна только пользователям.")
|
|
||||||
return
|
|
||||||
|
|
||||||
display_name = format_penis_user_name(message.from_user)
|
|
||||||
_, result_text = play_penis(message.from_user.id, display_name)
|
|
||||||
await message.answer(result_text)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_top_penis(message: Message):
|
|
||||||
# Хендлер топа по значениям /penis.
|
|
||||||
await message.answer(build_penis_top())
|
|
||||||
|
|
||||||
|
|
||||||
# Ключевые слова, на которые бот кидает трек Creep.
|
|
||||||
KEYWORDS = {'крип', 'радиохэд', 'creep', 'radiohead'}
|
|
||||||
# I'M CREEP
|
|
||||||
async def handle_keywords(message: types.Message):
|
|
||||||
# Не реагируем на команды, только на обычный текст.
|
|
||||||
|
|
||||||
if message.text and message.text.startswith('/'):
|
|
||||||
return
|
|
||||||
|
|
||||||
text = message.text.lower()
|
|
||||||
|
|
||||||
if any(re.search(rf'\b{word}\b', text) for word in KEYWORDS):
|
|
||||||
audio_path = BASE_DIR / "maket" / "Radiohead - Creep.mp3"
|
|
||||||
|
|
||||||
if audio_path.exists():
|
|
||||||
await message.answer_audio(
|
|
||||||
audio=FSInputFile(audio_path),
|
|
||||||
caption="I'm a creep, I'm a weirdo..."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(f"Файл не найден по пути: {audio_path}")
|
|
||||||
|
|
||||||
async def handle_prices(message: Message):
|
|
||||||
# Старый источник цен (/prices).
|
|
||||||
def fetch():
|
|
||||||
return requests.get(
|
|
||||||
"https://bebrik.xyz/magnit/products",
|
|
||||||
headers={"X-Token": "sisi"},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = await asyncio.to_thread(fetch)
|
|
||||||
|
|
||||||
if resp.status_code != 200:
|
|
||||||
await message.answer("пошел нахуй")
|
|
||||||
return
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
if not data:
|
|
||||||
await message.answer("иди нахуй")
|
|
||||||
return
|
|
||||||
|
|
||||||
text = "смерть в 25:\n\n"
|
|
||||||
for item in data:
|
|
||||||
name = item.get("name")
|
|
||||||
price = item.get("price")
|
|
||||||
text += f"{name} — {price} ₽\n"
|
|
||||||
|
|
||||||
await message.answer(text[:4000])
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
await message.answer("unable to connect x-server гандон")
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_fetch(message: Message):
|
|
||||||
# Новый источник цен (/fetch).
|
|
||||||
def fetch():
|
|
||||||
return requests.get(
|
|
||||||
"https://mirror.porno4free.ru/magnit/magnit/prices/all",
|
|
||||||
headers={"x-token": "secret_token"},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = await asyncio.to_thread(fetch)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
await message.reply("пошел нахуй")
|
|
||||||
return
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
if not isinstance(data, dict) or not data:
|
|
||||||
await message.reply("иди нахуй")
|
|
||||||
return
|
|
||||||
|
|
||||||
lines = ["смерть в 25:"]
|
|
||||||
# сортируем категории для стабильного вывода
|
|
||||||
for group in sorted(data.keys()):
|
|
||||||
items = data.get(group)
|
|
||||||
lines.append(f"\n{group}:")
|
|
||||||
if not isinstance(items, list):
|
|
||||||
continue
|
|
||||||
for item in items:
|
|
||||||
name = item.get("name")
|
|
||||||
price = item.get("price")
|
|
||||||
if not name or price is None:
|
|
||||||
continue
|
|
||||||
lines.append(f"{name} — {price} ₽")
|
|
||||||
text = "\n".join(lines)
|
|
||||||
await message.answer(text[:4000])
|
|
||||||
except Exception:
|
|
||||||
await message.answer("unable to connect x-server гандон")
|
|
||||||
|
|
||||||
async def handle_zvetok(message: Message):
|
|
||||||
# Команда с отдельной "мемной" логикой таймера.
|
|
||||||
result = minutes_until_next_lesson()
|
|
||||||
await message.answer(result)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Точка входа: создаём бота, регистрируем команды и запускаем polling.
|
|
||||||
token = os.getenv("BOT_TOKEN")
|
token = os.getenv("BOT_TOKEN")
|
||||||
if not token:
|
if not token:
|
||||||
raise RuntimeError("BOT_TOKEN env var is required")
|
raise RuntimeError("BOT_TOKEN env var is required")
|
||||||
|
|
@ -702,7 +395,6 @@ def main():
|
||||||
dp = Dispatcher()
|
dp = Dispatcher()
|
||||||
|
|
||||||
async def on_startup(bot: Bot):
|
async def on_startup(bot: Bot):
|
||||||
# Выставляем меню команд в интерфейсе Telegram.
|
|
||||||
try:
|
try:
|
||||||
await bot.set_my_commands(
|
await bot.set_my_commands(
|
||||||
[
|
[
|
||||||
|
|
@ -712,12 +404,6 @@ def main():
|
||||||
BotCommand(command="gen_mem", description="Мем из фото (ответом)"),
|
BotCommand(command="gen_mem", description="Мем из фото (ответом)"),
|
||||||
BotCommand(command="break", description="Сколько минут до перекура"),
|
BotCommand(command="break", description="Сколько минут до перекура"),
|
||||||
BotCommand(command="size", description="хуй"),
|
BotCommand(command="size", description="хуй"),
|
||||||
BotCommand(command="penis", description="миниигра 1 раз в 24ч"),
|
|
||||||
BotCommand(command="top_penis", description="топ по длине"),
|
|
||||||
BotCommand(command="prices", description="цены x-server"),
|
|
||||||
BotCommand(command="fetch", description="цены (новый источник)"),
|
|
||||||
BotCommand(command="zvetok", description="Цветянский бля"),
|
|
||||||
BotCommand(command="talk", description="Побазарить"),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
except Exception: # noqa: WPS440
|
except Exception: # noqa: WPS440
|
||||||
|
|
@ -725,29 +411,16 @@ def main():
|
||||||
|
|
||||||
dp.startup.register(on_startup)
|
dp.startup.register(on_startup)
|
||||||
|
|
||||||
# Регистрация команд-обработчиков.
|
|
||||||
dp.message.register(handle_start, CommandStart())
|
dp.message.register(handle_start, CommandStart())
|
||||||
dp.message.register(handle_help, Command("help"))
|
dp.message.register(handle_help, Command("help"))
|
||||||
dp.message.register(handle_break, Command("break"))
|
dp.message.register(handle_break, Command("break"))
|
||||||
dp.message.register(handle_maket, Command("maket"))
|
dp.message.register(handle_maket, Command("maket"))
|
||||||
dp.message.register(handle_size, Command("size"))
|
dp.message.register(handle_size, Command("size"))
|
||||||
dp.message.register(handle_penis, Command("penis"))
|
|
||||||
dp.message.register(handle_top_penis, Command("top_penis"))
|
|
||||||
dp.message.register(handle_prices, Command("prices"))
|
|
||||||
dp.message.register(handle_fetch, Command("fetch"))
|
|
||||||
dp.message.register(handle_zvetok, Command("zvetok"))
|
|
||||||
dp.message.register(handle_talk, Command("talk"))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_gen_mem_cmd(message: Message, bot: Bot):
|
async def handle_gen_mem_cmd(message: Message, bot: Bot):
|
||||||
# Обёртка под сигнатуру aiogram для /gen_mem.
|
|
||||||
await handle_gen_mem(message, bot)
|
await handle_gen_mem(message, bot)
|
||||||
|
|
||||||
dp.message.register(handle_gen_mem_cmd, Command("gen_mem"))
|
dp.message.register(handle_gen_mem_cmd, Command("gen_mem"))
|
||||||
|
|
||||||
# Текстовые авто-реакции (без команд).
|
|
||||||
dp.message.register(handle_keywords, F.text)
|
|
||||||
|
|
||||||
logger.info("Bot is starting...")
|
logger.info("Bot is starting...")
|
||||||
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))
|
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,3 +1,2 @@
|
||||||
aiogram>=3.4.1
|
aiogram==3.4.1
|
||||||
pillow>=10.3.0
|
pillow==10.3.0
|
||||||
requests>=2.31.0
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue