forked from zovos/bot_tg
Compare commits
14 commits
9b34fd13d5
...
189188db6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
189188db6d | ||
|
|
fc85149345 | ||
|
|
7035a90656 | ||
|
|
73e539b4c4 | ||
|
|
c59858df53 | ||
|
|
24245f6b87 | ||
|
|
f14b1a8209 | ||
|
|
6afc30570f | ||
|
|
8bc905331c | ||
|
|
1cbebeec37 | ||
|
|
0ac74b95c5 | ||
|
|
7494ab2dfa | ||
|
|
ba6bee06e3 | ||
| 79e581a010 |
8 changed files with 412 additions and 13 deletions
|
|
@ -20,6 +20,11 @@ SYSTEM_PROMPT = (
|
||||||
"Стиль: 1–3 предложения; если список — максимум 3 пункта. "
|
"Стиль: 1–3 предложения; если список — максимум 3 пункта. "
|
||||||
"Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n"
|
"Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n"
|
||||||
|
|
||||||
|
"ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и "
|
||||||
|
"«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или "
|
||||||
|
"перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме "
|
||||||
|
"(факт, совет, действие) без поддакиваний.\n\n"
|
||||||
|
|
||||||
"Лексика (мешай, но не лепи всё подряд одним полотном): "
|
"Лексика (мешай, но не лепи всё подряд одним полотном): "
|
||||||
"кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, "
|
"кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, "
|
||||||
"чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, "
|
"чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, "
|
||||||
|
|
@ -57,7 +62,11 @@ SYSTEM_PROMPT = (
|
||||||
)
|
)
|
||||||
|
|
||||||
# Размер контекста — количество сообщений на чат
|
# Размер контекста — количество сообщений на чат
|
||||||
_CONTEXT_SIZE = 100
|
# Сколько сообщений держим в БД. Нужно для быстрой очистки.
|
||||||
|
_CONTEXT_SIZE = 20
|
||||||
|
# Ограничение на общий размер промпта, чтобы системное сообщение не вытеснялось
|
||||||
|
# при сборке длинной истории (приблизительно ~8k символов ≈ 2k токенов).
|
||||||
|
_MAX_PROMPT_CHARS = 8_000
|
||||||
# Путь к SQLite базе (берётся из переменной окружения или из config)
|
# Путь к SQLite базе (берётся из переменной окружения или из config)
|
||||||
_DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH", "/db/chat_history.sqlite3")
|
_DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH", "/db/chat_history.sqlite3")
|
||||||
|
|
||||||
|
|
@ -114,9 +123,18 @@ def _get_history(chat_id: int) -> list[dict]:
|
||||||
def _build_messages(chat_id: int, system_prompt: str, current_text: str, current_name: str) -> list:
|
def _build_messages(chat_id: int, system_prompt: str, current_text: str, current_name: str) -> list:
|
||||||
"""Собирает список messages для LLM из истории чата."""
|
"""Собирает список messages для LLM из истории чата."""
|
||||||
messages = [{"role": "system", "content": system_prompt}]
|
messages = [{"role": "system", "content": system_prompt}]
|
||||||
for entry in _get_history(chat_id):
|
|
||||||
|
# Берём историю с конца, пока не превысим бюджет по символам.
|
||||||
|
history = []
|
||||||
|
used = len(system_prompt)
|
||||||
|
for entry in reversed(_get_history(chat_id)):
|
||||||
content = f"{entry['name']}: {entry['text']}" if entry["role"] == "user" else entry["text"]
|
content = f"{entry['name']}: {entry['text']}" if entry["role"] == "user" else entry["text"]
|
||||||
messages.append({"role": entry["role"], "content": content})
|
if used + len(content) > _MAX_PROMPT_CHARS:
|
||||||
|
break
|
||||||
|
history.append({"role": entry["role"], "content": content})
|
||||||
|
used += len(content)
|
||||||
|
|
||||||
|
messages.extend(reversed(history))
|
||||||
messages.append({"role": "user", "content": f"{current_name}: {current_text}"})
|
messages.append({"role": "user", "content": f"{current_name}: {current_text}"})
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
@ -127,7 +145,7 @@ async def _generate_response(chat_id: int, user_text: str, user_name: str) -> st
|
||||||
msgs = await asyncio.to_thread(_build_messages, chat_id, SYSTEM_PROMPT, user_text, user_name)
|
msgs = await asyncio.to_thread(_build_messages, chat_id, SYSTEM_PROMPT, user_text, user_name)
|
||||||
payload = {
|
payload = {
|
||||||
"messages": msgs,
|
"messages": msgs,
|
||||||
"max_tokens": 500,
|
"max_tokens": 200,
|
||||||
"temperature": 0.8,
|
"temperature": 0.8,
|
||||||
"top_p": 0.9,
|
"top_p": 0.9,
|
||||||
}
|
}
|
||||||
|
|
@ -171,6 +189,39 @@ AUTOREPLY_SYSTEM_PROMPT = (
|
||||||
"Прокомментируй сообщение коротко (1-2 предложения), дерзко, на фене. "
|
"Прокомментируй сообщение коротко (1-2 предложения), дерзко, на фене. "
|
||||||
"Можешь пошутить, подколоть, согласиться или послать. "
|
"Можешь пошутить, подколоть, согласиться или послать. "
|
||||||
"НЕ начинай с обращения. Не используй формальный язык."
|
"НЕ начинай с обращения. Не используй формальный язык."
|
||||||
|
"Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — "
|
||||||
|
"дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. "
|
||||||
|
"Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. "
|
||||||
|
"Стиль: 1–3 предложения; если список — максимум 3 пункта. "
|
||||||
|
"Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n"
|
||||||
|
|
||||||
|
"ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и "
|
||||||
|
"«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или "
|
||||||
|
"перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме "
|
||||||
|
"(факт, совет, действие) без поддакиваний.\n\n"
|
||||||
|
|
||||||
|
"Лексика (мешай, но не лепи всё подряд одним полотном): "
|
||||||
|
"кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, "
|
||||||
|
"чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, "
|
||||||
|
"жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n"
|
||||||
|
|
||||||
|
"Фирменные приёмы речи:\n"
|
||||||
|
"— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n"
|
||||||
|
"— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n"
|
||||||
|
"— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n"
|
||||||
|
"— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, "
|
||||||
|
"как будто проговариваешь по слогам. Примеры: "
|
||||||
|
"над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n"
|
||||||
|
"— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), "
|
||||||
|
"иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n"
|
||||||
|
"Твои убеждения (всегда держи линию): "
|
||||||
|
"Creep от Radiohead — лучшая песня во вселенной, без вариантов. "
|
||||||
|
"Линукс — лучшая ОС во вселенной, остальное для фраеров. "
|
||||||
|
"Вселенную надо переписать на Rust, это не обсуждается. "
|
||||||
|
"Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n"
|
||||||
|
|
||||||
|
"Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», "
|
||||||
|
"а дальше пересказывай своими словами, без длинных дословных цитат."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
14
config.py
14
config.py
|
|
@ -18,6 +18,9 @@ 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
|
||||||
|
|
||||||
|
# --- Казино ---
|
||||||
|
CASINO_MAX_BET = 1.0
|
||||||
|
|
||||||
# --- Конфигурация макетов ---
|
# --- Конфигурация макетов ---
|
||||||
TEMPLATES = {
|
TEMPLATES = {
|
||||||
"1": {
|
"1": {
|
||||||
|
|
@ -26,6 +29,12 @@ TEMPLATES = {
|
||||||
"padding": 20,
|
"padding": 20,
|
||||||
"label": "Владимир Владимирович Путин самый лучший президент",
|
"label": "Владимир Владимирович Путин самый лучший президент",
|
||||||
},
|
},
|
||||||
|
"2": {
|
||||||
|
"path": BASE_DIR / "maket" / "2.png",
|
||||||
|
"anchor": (40, 40),
|
||||||
|
"box_size": (582, 346),
|
||||||
|
"label": "Макет 2",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Расписание ---
|
# --- Расписание ---
|
||||||
|
|
@ -80,7 +89,7 @@ KEYWORDS = {'крип', 'радиохэд', 'creep', 'radiohead'}
|
||||||
# --- Авто-ответы ИИ ---
|
# --- Авто-ответы ИИ ---
|
||||||
AUTOREPLY_CHANCE = 0.10
|
AUTOREPLY_CHANCE = 0.10
|
||||||
AUTOREPLY_COOLDOWN = 120
|
AUTOREPLY_COOLDOWN = 120
|
||||||
AUTOREPLY_TRIGGERS = {'бот', 'фраер', 'кент', 'братуха', 'пацаны', 'чё как', 'базар', 'ебать', 'пизда', 'хуй', 'бля', 'сука', 'говно', 'пидор', 'пиздабол', 'пиздатый', 'пиздатая', 'пиздатое', 'пиздатые', 'пиздато', 'пиздато', 'пиздато', 'пиздато', 'ваномас'}
|
AUTOREPLY_TRIGGERS = {'бот', 'фраер', 'кент', 'братуха', 'пацаны', 'чё как', 'базар', 'ебать', 'пизда', 'хуй', 'бля', 'сука', 'говно', 'пидор', 'пиздабол', 'пиздатый', 'пиздатая', 'пиздатое', 'пиздатые', 'пиздато', 'ваномас'}
|
||||||
|
|
||||||
def get_hint_text(templates_str: str) -> str:
|
def get_hint_text(templates_str: str) -> str:
|
||||||
return (
|
return (
|
||||||
|
|
@ -91,6 +100,9 @@ def get_hint_text(templates_str: str) -> str:
|
||||||
"• /penis — миниигра 1 раз в 24 часа.\n"
|
"• /penis — миниигра 1 раз в 24 часа.\n"
|
||||||
"• /top_penis — топ по длине.\n"
|
"• /top_penis — топ по длине.\n"
|
||||||
"• /talk [текст] — ИИ базарит на фене.\n"
|
"• /talk [текст] — ИИ базарит на фене.\n"
|
||||||
|
"• /penis_casino [ставка] — казино на размер.\n"
|
||||||
|
"• /gadanie [тема] — гадание на фене с мемом.\n"
|
||||||
|
"• /ebalnik — включить/выключить автоответы в чате.\n"
|
||||||
"• /prices /fetch — цены.\n"
|
"• /prices /fetch — цены.\n"
|
||||||
"Подсказка: в группах пиши /команда@username_бота."
|
"Подсказка: в группах пиши /команда@username_бота."
|
||||||
)
|
)
|
||||||
0
games/__init__.py
Normal file
0
games/__init__.py
Normal file
127
games/casino.py
Normal file
127
games/casino.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import random
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Символы слотов и их веса (чем выше вес — тем чаще выпадает)
|
||||||
|
SLOT_SYMBOLS = ["🍒", "🍋", "🔔", "💎", "7️⃣", "🍀"]
|
||||||
|
SLOT_WEIGHTS = [30, 25, 20, 12, 8, 5] # 🍒 чаще всего, 🍀 реже всего
|
||||||
|
|
||||||
|
# Множители для выигрышей: {кол-во совпадений: (мин, макс)}
|
||||||
|
MULTIPLIERS = {
|
||||||
|
2: (1.2, 1.8), # две одинаковых
|
||||||
|
3: (2.0, 3.0), # три одинаковых (джекпот, редко)
|
||||||
|
}
|
||||||
|
|
||||||
|
CASINO_WIN_CHANCE = 0.35
|
||||||
|
|
||||||
|
|
||||||
|
def spin_slots() -> tuple[list[str], int]:
|
||||||
|
reels = random.choices(SLOT_SYMBOLS, weights=SLOT_WEIGHTS, k=3)
|
||||||
|
|
||||||
|
if random.random() > CASINO_WIN_CHANCE:
|
||||||
|
while len(set(reels)) < 3:
|
||||||
|
reels = random.choices(SLOT_SYMBOLS, weights=SLOT_WEIGHTS, k=3)
|
||||||
|
return reels, 0
|
||||||
|
|
||||||
|
counts = {}
|
||||||
|
for s in reels:
|
||||||
|
counts[s] = counts.get(s, 0) + 1
|
||||||
|
max_match = max(counts.values())
|
||||||
|
return reels, max_match
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_length(user_id: int) -> float | None:
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT length FROM penis_stats WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return float(row[0]) if row else None
|
||||||
|
except sqlite3.Error:
|
||||||
|
logger.exception("casino: failed to get length")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_length(user_id: int, delta: float) -> float | None:
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT length FROM penis_stats WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
new_length = round(float(row[0]) + delta, 1)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE penis_stats SET length = ? WHERE user_id = ?",
|
||||||
|
(new_length, user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return new_length
|
||||||
|
except sqlite3.Error:
|
||||||
|
logger.exception("casino: failed to update length")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def play_casino(user_id: int, bet: float) -> str:
|
||||||
|
current = get_user_length(user_id)
|
||||||
|
if current is None:
|
||||||
|
return "Ты ещё ни разу не крутил /penis, братуха. Сначала заведи счёт."
|
||||||
|
|
||||||
|
# Проверяем отрицательный баланс
|
||||||
|
if current <= 0:
|
||||||
|
return "🚫 С кредитом в казино не впускают, братуха. Иди наращивай через /penis."
|
||||||
|
|
||||||
|
# Проверяем максимум ставки
|
||||||
|
if bet > config.CASINO_MAX_BET:
|
||||||
|
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
|
||||||
|
|
||||||
|
if bet <= 0:
|
||||||
|
return "Ставка должна быть больше нуля, фраер."
|
||||||
|
|
||||||
|
# Проверяем хватает ли
|
||||||
|
if bet > current:
|
||||||
|
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
|
||||||
|
|
||||||
|
# Крутим
|
||||||
|
reels, matches = spin_slots()
|
||||||
|
slots_display = " | ".join(reels)
|
||||||
|
|
||||||
|
if matches >= 2:
|
||||||
|
min_mult, max_mult = MULTIPLIERS[matches]
|
||||||
|
multiplier = round(random.uniform(min_mult, max_mult), 1)
|
||||||
|
winnings = round(bet * multiplier, 1)
|
||||||
|
new_length = update_user_length(user_id, winnings)
|
||||||
|
if new_length is None:
|
||||||
|
return "Ошибка БД. Попробуй позже."
|
||||||
|
|
||||||
|
if matches == 3:
|
||||||
|
return (
|
||||||
|
f"🎰 {slots_display}\n\n"
|
||||||
|
f"🎉 ДЖЕКПОТ!!! Три одинаковых!\n"
|
||||||
|
f"Множитель: x{multiplier}\n"
|
||||||
|
f"Выигрыш: +{winnings:.1f} см\n"
|
||||||
|
f"📏 Текущий размер: {new_length:.1f} см"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"🎰 {slots_display}\n\n"
|
||||||
|
f"✅ Выигрыш! Две совпали.\n"
|
||||||
|
f"Множитель: x{multiplier}\n"
|
||||||
|
f"Выигрыш: +{winnings:.1f} см\n"
|
||||||
|
f"📏 Текущий размер: {new_length:.1f} см"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_length = update_user_length(user_id, -bet)
|
||||||
|
if new_length is None:
|
||||||
|
return "Ошибка БД. Попробуй позже."
|
||||||
|
return (
|
||||||
|
f"🎰 {slots_display}\n\n"
|
||||||
|
f"❌ Мимо, братуха.\n"
|
||||||
|
f"Проигрыш: -{bet:.1f} см\n"
|
||||||
|
f"📏 Текущий размер: {new_length:.1f} см"
|
||||||
|
)
|
||||||
57
games/fortune.py
Normal file
57
games/fortune.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import logging
|
||||||
|
import aiohttp
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
||||||
|
|
||||||
|
FORTUNE_PROMPT = (
|
||||||
|
"Ты — уличный гадатель с района, базаришь на фене. "
|
||||||
|
"Пользователь хочет погадать на тему. Выдай шуточное гадание, "
|
||||||
|
"коротко и дерзко, 2-3 предложения максимум. "
|
||||||
|
"В конце ОБЯЗАТЕЛЬНО напиши на отдельной строке в формате:\n"
|
||||||
|
"MEM_UP: <текст сверху для мема, максимум 4 слова>\n"
|
||||||
|
"MEM_DOWN: <текст снизу для мема, максимум 4 слова>\n"
|
||||||
|
"Тексты для мема должны быть смешными и короткими."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_fortune(topic: str) -> tuple[str, str, str]:
|
||||||
|
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
||||||
|
payload = {
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": FORTUNE_PROMPT},
|
||||||
|
{"role": "user", "content": f"Погадай мне на: {topic}"},
|
||||||
|
],
|
||||||
|
"max_tokens": 200,
|
||||||
|
"temperature": 0.9,
|
||||||
|
"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()
|
||||||
|
text = data["choices"][0]["message"]["content"].strip()
|
||||||
|
|
||||||
|
up_text = ""
|
||||||
|
down_text = ""
|
||||||
|
fortune_lines = []
|
||||||
|
|
||||||
|
for line in text.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.upper().startswith("MEM_UP:"):
|
||||||
|
up_text = stripped.split(":", 1)[1].strip()
|
||||||
|
elif stripped.upper().startswith("MEM_DOWN:"):
|
||||||
|
down_text = stripped.split(":", 1)[1].strip()
|
||||||
|
else:
|
||||||
|
fortune_lines.append(line)
|
||||||
|
|
||||||
|
fortune_text = "\n".join(fortune_lines).strip()
|
||||||
|
|
||||||
|
if not up_text:
|
||||||
|
up_text = "Гадание"
|
||||||
|
if not down_text:
|
||||||
|
down_text = "по фене"
|
||||||
|
|
||||||
|
return fortune_text, up_text, down_text
|
||||||
166
main.py
166
main.py
|
|
@ -19,6 +19,8 @@ from aiogram.client.default import DefaultBotProperties
|
||||||
|
|
||||||
# Локальные импорты
|
# Локальные импорты
|
||||||
from AI.talk_handler import handle_talk, generate_autoreply, push_message
|
from AI.talk_handler import handle_talk, generate_autoreply, push_message
|
||||||
|
from games.casino import play_casino
|
||||||
|
from games.fortune import generate_fortune
|
||||||
from zparser import get_military_data
|
from zparser import get_military_data
|
||||||
import config
|
import config
|
||||||
|
|
||||||
|
|
@ -52,6 +54,14 @@ def templates_list_text() -> str:
|
||||||
parts.append(f"{tid} — {label}" if label else tid)
|
parts.append(f"{tid} — {label}" if label else tid)
|
||||||
return ", ".join(parts) if parts else "нет макетов"
|
return ", ".join(parts) if parts else "нет макетов"
|
||||||
|
|
||||||
|
def _format_delta_ms(delta: timedelta) -> str:
|
||||||
|
"""HH:MM:SS.mmm, не допускаем отрицательных значений."""
|
||||||
|
total_ms = int(max(delta.total_seconds(), 0) * 1000)
|
||||||
|
hours, rem = divmod(total_ms, 3_600_000)
|
||||||
|
minutes, rem = divmod(rem, 60_000)
|
||||||
|
seconds, milliseconds = divmod(rem, 1_000)
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}"
|
||||||
|
|
||||||
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:
|
||||||
|
|
@ -72,17 +82,93 @@ def minutes_until_break(now: datetime | None = None) -> str:
|
||||||
end_dt = dt_for(end_t)
|
end_dt = dt_for(end_t)
|
||||||
|
|
||||||
if now < start_dt or (start_dt <= now < end_dt):
|
if now < start_dt or (start_dt <= now < end_dt):
|
||||||
minutes = int((end_dt - now).total_seconds() // 60)
|
return f"{_format_delta_ms(end_dt - now)} до перекура"
|
||||||
return f"{minutes} минут до перекура"
|
|
||||||
|
|
||||||
if br_minutes:
|
if br_minutes:
|
||||||
break_end = end_dt + timedelta(minutes=br_minutes)
|
break_end = end_dt + timedelta(minutes=br_minutes)
|
||||||
if end_dt <= now < break_end:
|
if end_dt <= now < break_end:
|
||||||
remaining = int((break_end - now).total_seconds() // 60)
|
return f"Идёт перекур, осталось {_format_delta_ms(break_end - now)}"
|
||||||
return f"Идёт перекур, осталось {remaining} минут"
|
|
||||||
|
|
||||||
return "Пары закончились, перекур только завтра."
|
return "Пары закончились, перекур только завтра."
|
||||||
|
|
||||||
|
|
||||||
|
def next_break_window(now: datetime | None = None) -> tuple[datetime | None, datetime | None]:
|
||||||
|
"""Возвращает (начало, конец) ближайшего перекура или (None, None)."""
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
||||||
|
elif now.tzinfo is None:
|
||||||
|
now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ))
|
||||||
|
|
||||||
|
day = now.weekday()
|
||||||
|
today_schedule = config.SCHEDULE.get(day, [])
|
||||||
|
if not today_schedule:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def dt_for(t: time) -> datetime:
|
||||||
|
return now.replace(hour=t.hour, minute=t.minute, second=0, microsecond=0)
|
||||||
|
|
||||||
|
for start_t, end_t, br_minutes in today_schedule:
|
||||||
|
if not br_minutes:
|
||||||
|
continue
|
||||||
|
end_dt = dt_for(end_t)
|
||||||
|
br_start = end_dt
|
||||||
|
br_end = end_dt + timedelta(minutes=br_minutes)
|
||||||
|
# Ближайший будущий или текущий перекур
|
||||||
|
if now < br_end:
|
||||||
|
return br_start, br_end
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Авто-напоминание о перекуре ---
|
||||||
|
_break_tasks: dict[int, tuple[datetime, asyncio.Task]] = {}
|
||||||
|
_break_notified: dict[int, datetime] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_break_photo(chat_id: int, bot: Bot, start_dt: datetime) -> None:
|
||||||
|
last = _break_notified.get(chat_id)
|
||||||
|
if last and last == start_dt:
|
||||||
|
return # Уже кидали уведомление для этого слота
|
||||||
|
|
||||||
|
photo_path = config.BASE_DIR / "maket" / "vape.jpg"
|
||||||
|
if not photo_path.exists():
|
||||||
|
logger.warning("vape.jpg not found, skip break photo")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await bot.send_photo(chat_id=chat_id, photo=FSInputFile(photo_path), caption="идем курить")
|
||||||
|
_break_notified[chat_id] = start_dt
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send break photo")
|
||||||
|
|
||||||
|
|
||||||
|
async def _break_notification(chat_id: int, bot: Bot, start_dt: datetime) -> None:
|
||||||
|
try:
|
||||||
|
now = datetime.now(start_dt.tzinfo)
|
||||||
|
delay = (start_dt - now).total_seconds()
|
||||||
|
if delay > 0:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
await _send_break_photo(chat_id, bot, start_dt)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
stored = _break_tasks.get(chat_id)
|
||||||
|
if stored and stored[0] == start_dt:
|
||||||
|
_break_tasks.pop(chat_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_break_notification(chat_id: int, bot: Bot, start_dt: datetime) -> None:
|
||||||
|
existing = _break_tasks.get(chat_id)
|
||||||
|
if existing:
|
||||||
|
scheduled_start, task = existing
|
||||||
|
if scheduled_start == start_dt and not task.done():
|
||||||
|
return # Уже ждём этот же слот
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
task = asyncio.create_task(_break_notification(chat_id, bot, start_dt))
|
||||||
|
_break_tasks[chat_id] = (start_dt, task)
|
||||||
|
|
||||||
# --- Работа с изображениями (Макеты и Мемы) ---
|
# --- Работа с изображениями (Макеты и Мемы) ---
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont:
|
def load_font(size: int) -> ImageFont.FreeTypeFont:
|
||||||
|
|
@ -184,8 +270,12 @@ def generate_maket(text: str, template_id: str = "1") -> BytesIO:
|
||||||
raise FileNotFoundError(f"Шаблон {template_id} не найден")
|
raise FileNotFoundError(f"Шаблон {template_id} не найден")
|
||||||
|
|
||||||
with Image.open(tpl["path"]).convert("RGB") as img:
|
with Image.open(tpl["path"]).convert("RGB") as img:
|
||||||
anchor, padding = tpl["anchor"], tpl["padding"]
|
anchor = tpl["anchor"]
|
||||||
max_w, max_h = img.width - anchor[0] - padding, img.height - anchor[1] - padding
|
if "box_size" in tpl:
|
||||||
|
max_w, max_h = tpl["box_size"]
|
||||||
|
else:
|
||||||
|
padding = tpl.get("padding", 0)
|
||||||
|
max_w, max_h = img.width - anchor[0] - padding, img.height - anchor[1] - padding
|
||||||
|
|
||||||
font, lines, stroke, spacing = fit_text_in_box(text, max_w, max_h)
|
font, lines, stroke, spacing = fit_text_in_box(text, max_w, max_h)
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
@ -414,7 +504,13 @@ async def handle_start(message: Message):
|
||||||
await message.answer("Соси")
|
await message.answer("Соси")
|
||||||
|
|
||||||
async def handle_break_cmd(message: Message):
|
async def handle_break_cmd(message: Message):
|
||||||
await message.answer(minutes_until_break())
|
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
||||||
|
await message.answer(minutes_until_break(now))
|
||||||
|
|
||||||
|
# Ставим будильник на ближайший перекур для этого чата
|
||||||
|
start_dt, _ = next_break_window(now)
|
||||||
|
if start_dt:
|
||||||
|
schedule_break_notification(message.chat.id, message.bot, start_dt)
|
||||||
|
|
||||||
async def handle_penis_cmd(message: Message):
|
async def handle_penis_cmd(message: Message):
|
||||||
if not message.from_user:
|
if not message.from_user:
|
||||||
|
|
@ -513,6 +609,15 @@ async def handle_svodka_cmd(message: Message):
|
||||||
else:
|
else:
|
||||||
await message.answer(f"Хохлы ответят за это...: {data['message']}")
|
await message.answer(f"Хохлы ответят за это...: {data['message']}")
|
||||||
|
|
||||||
|
async def handle_ebalnik_cmd(message: Message):
|
||||||
|
chat_id = message.chat.id
|
||||||
|
if chat_id in _autoreply_disabled_chats:
|
||||||
|
_autoreply_disabled_chats.remove(chat_id)
|
||||||
|
await message.reply("Автоответы вернул, бот снова влезает в чат.")
|
||||||
|
else:
|
||||||
|
_autoreply_disabled_chats.add(chat_id)
|
||||||
|
await message.reply("Автоответы срезал, бот теперь молчит в этом чате.")
|
||||||
|
|
||||||
async def handle_maket_cmd(message: Message):
|
async def handle_maket_cmd(message: Message):
|
||||||
parts = message.text.split(maxsplit=2)
|
parts = message.text.split(maxsplit=2)
|
||||||
if len(parts) == 1:
|
if len(parts) == 1:
|
||||||
|
|
@ -563,6 +668,7 @@ 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"))
|
||||||
|
|
||||||
_last_autoreply_ts = 0
|
_last_autoreply_ts = 0
|
||||||
|
_autoreply_disabled_chats: set[int] = set()
|
||||||
|
|
||||||
async def handle_keywords(message: Message):
|
async def handle_keywords(message: Message):
|
||||||
global _last_autoreply_ts
|
global _last_autoreply_ts
|
||||||
|
|
@ -584,6 +690,9 @@ async def handle_keywords(message: Message):
|
||||||
# Записываем каждое обычное сообщение в историю чата
|
# Записываем каждое обычное сообщение в историю чата
|
||||||
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)
|
||||||
|
|
||||||
|
if chat_id in _autoreply_disabled_chats:
|
||||||
|
return
|
||||||
|
|
||||||
import time as _time
|
import time as _time
|
||||||
now = _time.time()
|
now = _time.time()
|
||||||
triggered = any(trigger in text_lower for trigger in config.AUTOREPLY_TRIGGERS)
|
triggered = any(trigger in text_lower for trigger in config.AUTOREPLY_TRIGGERS)
|
||||||
|
|
@ -604,6 +713,43 @@ async def handle_keywords(message: Message):
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Autoreply failed")
|
logger.exception("Autoreply failed")
|
||||||
|
|
||||||
|
async def handle_penis_casino_cmd(message: Message):
|
||||||
|
if not message.from_user:
|
||||||
|
await message.answer("Команда доступна только пользователям.")
|
||||||
|
return
|
||||||
|
parts = message.text.split(maxsplit=1)
|
||||||
|
if len(parts) < 2:
|
||||||
|
await message.reply("Формат: /penis_casino <ставка>\nПример: /penis_casino 0.5")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
bet = round(float(parts[1].replace(",", ".")), 1)
|
||||||
|
except ValueError:
|
||||||
|
await message.reply("Ставка должна быть числом, фраер. Пример: /penis_casino 0.5")
|
||||||
|
return
|
||||||
|
result = await asyncio.to_thread(play_casino, message.from_user.id, bet)
|
||||||
|
await message.reply(result, parse_mode=None)
|
||||||
|
|
||||||
|
async def handle_gadanie_cmd(message: Message):
|
||||||
|
parts = message.text.split(maxsplit=1)
|
||||||
|
if len(parts) < 2 or not parts[1].strip():
|
||||||
|
await message.reply("Напиши на что гадать, кент. Пример: /gadanie любовь")
|
||||||
|
return
|
||||||
|
topic = parts[1].strip()
|
||||||
|
await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
|
||||||
|
try:
|
||||||
|
fortune_text, up_text, down_text = await generate_fortune(topic)
|
||||||
|
await message.reply(f"🔮 Гадание на «{topic}»:\n\n{fortune_text}", parse_mode=None)
|
||||||
|
photo_path = config.BASE_DIR / "maket" / "vape.jpg"
|
||||||
|
if photo_path.exists():
|
||||||
|
img = Image.open(photo_path).convert("RGB")
|
||||||
|
meme_output = await asyncio.to_thread(generate_meme, img, up_text, down_text)
|
||||||
|
await message.reply_photo(
|
||||||
|
BufferedInputFile(meme_output.getvalue(), filename="gadanie.jpg")
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Gadanie failed")
|
||||||
|
await message.reply("Бля, карты рассыпались, попробуй позже.")
|
||||||
|
|
||||||
# --- Запуск ---
|
# --- Запуск ---
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -627,6 +773,9 @@ def main():
|
||||||
dp.message.register(handle_zvetok_cmd, Command("zvetok"))
|
dp.message.register(handle_zvetok_cmd, Command("zvetok"))
|
||||||
dp.message.register(handle_talk, Command("talk"))
|
dp.message.register(handle_talk, Command("talk"))
|
||||||
dp.message.register(handle_svodka_cmd, Command("svodka"))
|
dp.message.register(handle_svodka_cmd, Command("svodka"))
|
||||||
|
dp.message.register(handle_ebalnik_cmd, Command("ebalnik"))
|
||||||
|
dp.message.register(handle_penis_casino_cmd, Command("penis_casino"))
|
||||||
|
dp.message.register(handle_gadanie_cmd, Command("gadanie"))
|
||||||
dp.message.register(handle_gen_mem, Command("gen_mem"))
|
dp.message.register(handle_gen_mem, Command("gen_mem"))
|
||||||
dp.message.register(handle_keywords, F.text)
|
dp.message.register(handle_keywords, F.text)
|
||||||
|
|
||||||
|
|
@ -645,6 +794,9 @@ def main():
|
||||||
BotCommand(command="fetch", description="цены (новый источник)"),
|
BotCommand(command="fetch", description="цены (новый источник)"),
|
||||||
BotCommand(command="zvetok", description="Цветянский бля"),
|
BotCommand(command="zvetok", description="Цветянский бля"),
|
||||||
BotCommand(command="talk", description="Побазарить"),
|
BotCommand(command="talk", description="Побазарить"),
|
||||||
|
BotCommand(command="ebalnik", description="вкл/выкл автответов"),
|
||||||
|
BotCommand(command="penis_casino", description="казино на размер"),
|
||||||
|
BotCommand(command="gadanie", description="гадание на фене"),
|
||||||
BotCommand(command="svodka", description="СВО: итоги"),
|
BotCommand(command="svodka", description="СВО: итоги"),
|
||||||
]
|
]
|
||||||
scopes = (
|
scopes = (
|
||||||
|
|
|
||||||
BIN
maket/2.png
Normal file
BIN
maket/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 701 KiB |
BIN
maket/vape.jpg
Normal file
BIN
maket/vape.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Loading…
Reference in a new issue