Compare commits

...

2 commits

Author SHA1 Message Date
q
185ef45621 Merge pull request 'Изменения: что изменилось' (#5) from Zhirik1337/bot_tg:wpng into main
Reviewed-on: #5
2026-02-22 17:04:43 +00:00
ac491e6433 Изменения: что изменилось
Добавлена миниигра /penis с хранением состояния по пользователю (в памяти процесса).
main.py (line 28)
main.py (line 29)
main.py (line 32)
main.py (line 400)
Что делает:
хранит length и last_used_at на user_id;
даёт изменение длины случайно +/- в диапазоне 0.1..7.0;
ограничивает минимум до 0.1;
вводит кулдаун 24 часа от последнего вызова.
Реализован динамический топ /top_penis.
main.py (line 33)
main.py (line 454)
main.py (line 590)
Что делает:
собирает текущие значения из PENIS_STATE;
сортирует по убыванию см;
показывает медали 🥇🥈🥉 и далее нумерацию;
лимит топа по умолчанию 10 участников;
при пустом состоянии пишет, что топ пуст.
Добавлена нормализация имени пользователя для топа.
main.py (line 445)
main.py (line 585)
Как работает:
приоритет @username;
если нет username, берёт full_name;
fallback: user_<id>.
Обновлены подсказки и меню Telegram-команд.
main.py (line 153)
main.py (line 716)
main.py (line 735)
Что добавлено:
строка в /help про /top_penis;
BotCommand("top_penis") в меню;
регистрация обработчика Command("top_penis").
Добавлены подробные комментарии по коду.
main.py (line 21)
main.py (line 146)
main.py (line 400)
talk_handler.py (line 10)
talk_handler.py (line 45)
Что покрыто:
константы/секции;
игровые функции;
хендлеры;
старт и регистрация роутов;
пояснения к LLM-модулю /talk.
Что протестировано локально.
compileall для main.py и talk_handler.py — успешно.
Смоук-тесты функций:
parse_up_down, minutes_until_break, minutes_until_next_lesson;
generate_maket, generate_meme;
play_penis (включая cooldown);
build_penis_top (сортировка/пустой топ).
Итог: основные проверки прошли.
Текущее ограничение реализации.
Состояние /penis и /top_penis хранится только в RAM, после перезапуска бота сбрасывается.
main.py (line 28)
2026-02-22 19:40:57 +03:00
2 changed files with 151 additions and 2 deletions

View file

@ -7,8 +7,10 @@ from aiogram.types import Message
logger = logging.getLogger(__name__)
# Адрес OpenAI-совместимого API у llama.cpp сервера.
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "http://localhost:8080")
# Системный промпт задаёт стиль и "характер" ответа.
SYSTEM_PROMPT = (
"Ты — чёткий пацан с района. Базаришь строго на фене — блатной жаргон, "
"тюремный сленг, дворовый язык. Используй слова: кент, братуха, мусора, "
@ -23,6 +25,7 @@ SYSTEM_PROMPT = (
async def _generate_response(user_text: str) -> str:
# Формируем запрос к /v1/chat/completions и возвращаем текст первого ответа.
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
payload = {
"messages": [
@ -40,6 +43,7 @@ async def _generate_response(user_text: str) -> str:
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 [текст], побазарим.")
@ -47,6 +51,7 @@ async def handle_talk(message: Message):
user_text = parts[1].strip()
# Показываем "печатает..." пока ждём ответ модели.
await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
try:
@ -55,5 +60,6 @@ async def handle_talk(message: Message):
response = "Братуха, чёт базар не клеится, попробуй ещё раз."
await message.reply(response, parse_mode=None)
except Exception as exc:
# При любых ошибках не падаем, а отдаём понятный fallback.
logger.exception("Talk generation failed")
await message.reply("Бля, кент, чёт движок заглох. Попробуй позже.")

147
main.py
View file

@ -18,9 +18,21 @@ from PIL import Image, ImageDraw, ImageFont
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
# Базовые пути/настройки проекта.
BASE_DIR = Path(__file__).parent
FONT_PATH = BASE_DIR / "impact.ttf"
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 = {
"1": {
"path": BASE_DIR / "maket" / "1.png",
@ -31,6 +43,7 @@ TEMPLATES = {
}
def templates_list_text() -> str:
# Собираем человекочитаемый список доступных макетов.
parts = []
for tid in sorted(TEMPLATES.keys()):
label = TEMPLATES[tid].get("label", "")
@ -43,6 +56,7 @@ def templates_list_text() -> str:
# ---- Schedule & breaks ----
def make_time(h: int, m: int) -> time:
# Короткий хелпер для заполнения расписания.
return time(hour=h, minute=m)
@ -93,6 +107,7 @@ SCHEDULE: dict[int, list[tuple[time, time, int | None]]] = {
def minutes_until_break(now: datetime | None = None) -> str:
# Возвращает статус: до перекура / идёт перекур / пары окончены.
if now is None:
now = datetime.now(ZoneInfo(DEFAULT_TZ))
elif now.tzinfo is None:
@ -129,10 +144,13 @@ def minutes_until_break(now: datetime | None = None) -> str:
return "Пары закончились, перекур только завтра."
HINT_TEXT = (
# Общая подсказка по командам.
"Команды:\n"
f"• /maket «id» «текст» — доступные: {templates_list_text()} (1: 737x414, точка 104,240).\n"
"• /gen_mem Up:верх Down:низ — отправь с фото (как подпись) или ответом на фото.\n"
"• /break — узнать, сколько минут до перекура.\n"
"• /penis — миниигра: +/ от 0.1 до 7 см, раз в 24 часа.\n"
"• /top_penis — динамический топ по текущей длине.\n"
"• /talk [сообщение] — побазарить с ИИ на фене.\n"
"• /prices — старый способ цен.\n"
"• /fetch — свежие цены с x-server.\n"
@ -144,12 +162,14 @@ logger = logging.getLogger(__name__)
def load_font(size: int) -> ImageFont.FreeTypeFont:
# Грузим Impact нужного размера.
if not FONT_PATH.exists():
raise FileNotFoundError("impact.ttf not found. Place the font in the project root.")
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]:
# Переносим текст по словам так, чтобы каждая строка влезла в max_width.
words = text.split()
if not words:
return []
@ -170,6 +190,7 @@ 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):
# Подбираем максимальный размер шрифта, который влезет в заданный прямоугольник.
draw_dummy = ImageDraw.Draw(Image.new("RGB", (max_width, max_height)))
for size in range(start_size, min_size - 1, -2):
font = load_font(size)
@ -184,7 +205,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)
if total_height <= max_height:
return font, lines, stroke_width, max(6, size // 6)
# Fallback to minimal size
# Если не влезло ни на одном размере, используем минимальный.
font = load_font(min_size)
stroke_width = max(1, min_size // 12)
lines = wrap_text_to_width(text, font, max_width, draw_dummy, stroke_width)
@ -193,6 +214,7 @@ 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,
stroke_width: int, line_spacing: int, align_center: bool = False, max_width: int | None = None):
# Рисуем набор строк с опциональным центрированием по горизонтали.
x, y = position
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font, stroke_width=stroke_width)
@ -205,6 +227,7 @@ def draw_multiline(draw: ImageDraw.ImageDraw, lines: list[str], position: tuple[
def generate_maket(text: str, template_id: str = "1") -> BytesIO:
# Генерируем картинку макета с текстом в заранее заданной области.
tpl = TEMPLATES.get(template_id)
if not tpl:
raise FileNotFoundError(f"Template {template_id} is missing")
@ -228,6 +251,7 @@ 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):
# Подбор размера шрифта для верхнего/нижнего блока мема.
start_size = max(int(image_width * max_ratio), min_size)
draw_dummy = ImageDraw.Draw(Image.new("RGB", (image_width, image_width)))
for size in range(start_size, min_size - 1, -2):
@ -248,6 +272,7 @@ 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:
# Рисуем классический мем-текст сверху/снизу и возвращаем JPEG в памяти.
img = img.convert("RGB")
draw = ImageDraw.Draw(img)
width, height = img.size
@ -277,6 +302,8 @@ def generate_meme(img: Image.Image, top_text: str, bottom_text: str) -> BytesIO:
def parse_up_down(text: str) -> tuple[str, str]:
# Парсим формат /gen_mem Up:... Down:...
# Если меток нет, используем fallback: первое слово -> up, остальное -> down.
up = ""
down = ""
# Normalize line breaks
@ -296,6 +323,7 @@ def parse_up_down(text: str) -> tuple[str, str]:
return up, down
def minutes_until_next_lesson(now: datetime | None = None) -> str:
# Отдельная "угарная" логика для /zvetok.
import random
if now is None:
@ -369,18 +397,99 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
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):
# Базовый /start.
await message.answer("Соси")
async def handle_help(message: Message):
# Вывод подсказки по возможностям бота.
await message.answer(HINT_TEXT)
async def handle_break(message: Message):
# Команда расписания перекура.
await message.answer(minutes_until_break())
async def handle_maket(message: Message):
# Генерация текста в подготовленном шаблоне /maket.
parts = message.text.split(maxsplit=2)
if len(parts) == 1:
await message.reply(f"Доступные макеты: {templates_list_text()}\nФормат: /maket «id» «текст»")
@ -406,6 +515,7 @@ async def handle_maket(message: Message):
async def handle_gen_mem(message: Message, bot: Bot):
# Мем можно сделать из фото в этом же сообщении или из reply на фото.
photo_message = None
if message.photo:
photo_message = message
@ -423,6 +533,7 @@ async def handle_gen_mem(message: Message, bot: Bot):
photo = photo_message.photo[-1]
try:
# Берём самое большое фото Telegram и скачиваем в память.
file = await bot.get_file(photo.file_id)
buf = BytesIO()
await bot.download_file(file.file_path, destination=buf)
@ -443,6 +554,7 @@ async def handle_gen_mem(message: Message, bot: Bot):
await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg"))
async def handle_size(message: Message):
# Простая рандом-команда /size.
size = round(random.uniform(0.1, 35.0), 1)
if size < 5:
@ -463,9 +575,28 @@ async def handle_size(message: Message):
f"Ты: {status}"
)
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
@ -484,6 +615,7 @@ async def handle_keywords(message: types.Message):
logger.warning(f"Файл не найден по пути: {audio_path}")
async def handle_prices(message: Message):
# Старый источник цен (/prices).
def fetch():
return requests.get(
"https://bebrik.xyz/magnit/products",
@ -517,6 +649,7 @@ async def handle_prices(message: Message):
async def handle_fetch(message: Message):
# Новый источник цен (/fetch).
def fetch():
return requests.get(
"https://mirror.porno4free.ru/magnit/magnit/prices/all",
@ -554,11 +687,13 @@ async def handle_fetch(message: Message):
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():
# Точка входа: создаём бота, регистрируем команды и запускаем polling.
token = os.getenv("BOT_TOKEN")
if not token:
raise RuntimeError("BOT_TOKEN env var is required")
@ -567,6 +702,7 @@ def main():
dp = Dispatcher()
async def on_startup(bot: Bot):
# Выставляем меню команд в интерфейсе Telegram.
try:
await bot.set_my_commands(
[
@ -576,6 +712,8 @@ def main():
BotCommand(command="gen_mem", description="Мем из фото (ответом)"),
BotCommand(command="break", 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="Цветянский бля"),
@ -587,23 +725,28 @@ def main():
dp.startup.register(on_startup)
# Регистрация команд-обработчиков.
dp.message.register(handle_start, CommandStart())
dp.message.register(handle_help, Command("help"))
dp.message.register(handle_break, Command("break"))
dp.message.register(handle_maket, Command("maket"))
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):
# Обёртка под сигнатуру aiogram для /gen_mem.
await handle_gen_mem(message, bot)
dp.message.register(handle_gen_mem_cmd, Command("gen_mem"))
# Текстовые авто-реакции (без команд).
dp.message.register(handle_keywords, F.text)
logger.info("Bot is starting...")