1
0
Fork 0
forked from zovos/bot_tg

Merge remote-tracking branch 'upstream/main' into feature/FenyaBot

This commit is contained in:
Danil 2026-03-07 15:19:51 +03:00
commit fc85149345
5 changed files with 179 additions and 12 deletions

View file

@ -20,6 +20,11 @@ SYSTEM_PROMPT = (
"Стиль: 13 предложения; если список — максимум 3 пункта. " "Стиль: 13 предложения; если список — максимум 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 предложения), дерзко, на фене. "
"Можешь пошутить, подколоть, согласиться или послать. " "Можешь пошутить, подколоть, согласиться или послать. "
"НЕ начинай с обращения. Не используй формальный язык." "НЕ начинай с обращения. Не используй формальный язык."
"Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — "
"дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. "
"Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. "
"Стиль: 13 предложения; если список — максимум 3 пункта. "
"Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n"
"ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и "
"«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или "
"перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме "
"(факт, совет, действие) без поддакиваний.\n\n"
"Лексика (мешай, но не лепи всё подряд одним полотном): "
"кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, "
"чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, "
"жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n"
"Фирменные приёмы речи:\n"
"— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n"
"— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n"
"— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n"
"— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, "
"как будто проговариваешь по слогам. Примеры: "
"над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n"
"— держи 'словарь коронок': выбирай 02 фразы на ответ (редко 3), "
"иногда делай повтор как тик (13 раза), но не превращай всё в простыню.\n"
"Твои убеждения (всегда держи линию): "
"Creep от Radiohead — лучшая песня во вселенной, без вариантов. "
"Линукс — лучшая ОС во вселенной, остальное для фраеров. "
"Вселенную надо переписать на Rust, это не обсуждается. "
"Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n"
"Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», "
"а дальше пересказывай своими словами, без длинных дословных цитат."
) )

View file

@ -26,6 +26,12 @@ TEMPLATES = {
"padding": 20, "padding": 20,
"label": "Владимир Владимирович Путин самый лучший президент", "label": "Владимир Владимирович Путин самый лучший президент",
}, },
"2": {
"path": BASE_DIR / "maket" / "2.png",
"anchor": (40, 40),
"box_size": (582, 346),
"label": "Макет 2",
},
} }
# --- Расписание --- # --- Расписание ---
@ -91,6 +97,7 @@ 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"
"• /ebalnik — включить/выключить автоответы в чате.\n"
"• /prices /fetch — цены.\n" "• /prices /fetch — цены.\n"
"Подсказка: в группах пиши /команда@username_бота." "Подсказка: в группах пиши /команда@username_бота."
) )

121
main.py
View file

@ -52,6 +52,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 +80,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,7 +268,11 @@ 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"]
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 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)
@ -414,7 +502,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 +607,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 +666,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 +688,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)
@ -627,6 +734,7 @@ 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_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 +753,7 @@ 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="svodka", description="СВО: итоги"), BotCommand(command="svodka", description="СВО: итоги"),
] ]
scopes = ( scopes = (

BIN
maket/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

BIN
maket/vape.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB