auto-sync: 2026-04-15 01:00:01

This commit is contained in:
Stream
2026-04-15 01:00:01 +03:00
parent 2925fd8e0c
commit ebfbe7ffcd
11 changed files with 591 additions and 0 deletions

5
tasks/bytik/.env.example Normal file
View File

@@ -0,0 +1,5 @@
BOT_TOKEN=your_telegram_bot_token
ALLOWED_CHAT_ID=your_group_chat_id
OPENROUTER_API_KEY=your_openrouter_api_key
OPENROUTER_MODEL=openrouter/qwen/qwen3.6-plus
ELEVENLABS_API_KEY=your_elevenlabs_api_key

172
tasks/bytik/TZ.md Normal file
View File

@@ -0,0 +1,172 @@
# ТЗ: Telegram-бот «Байтик» 🤖
> Детский ИИ-помощник для Егора (8 лет, ДР 17.04.2018)
> Проект: `proj_bytik` | Дата создания: 2026-04-14
---
## 1. Цель
Telegram-бот, который:
- Отвечает на вопросы ребёнка простым языком
- Присылает 1-2 раза в день энциклопедические факты (утро 7:30 MSK)
- Поздравляет с праздниками и ДР
- Работает **только в одной группе** (whitelist chat_id)
- Строгая детская цензура 0+
---
## 2. Требования к боту
### 2.1 Whitelist (критично)
- **Разрешённый chat_id:** будет прописан в `.env` (`ALLOWED_CHAT_ID`)
- Бот принимает сообщения **только** из этой группы
- Сообщения из ЛС и других групп — **полный игнор** (без ответа)
- При добавлении в новую группу — автоматически `leave_chat`
- Проверка: `message.chat.id` для text, `callback_query.message.chat.id` для inline
### 2.2 Обработка сообщений
- Получает текст от пользователя в разрешённом чате
- Отправляет промпт в LLM (OpenRouter API, Qwen 3.6-Plus)
- Получает ответ → отправляет в Telegram
- Поддерживает диалог (хранит последние 5-10 сообщений как контекст)
### 2.3 Системный промпт (обязателен)
```
Ты — Байтик, дружелюбный робот-помощник для мальчика Егора (8 лет).
Правила:
- Объясняй сложные вещи просто, с примерами, которые понятны 8-летнему
- Используй эмодзи, чтобы было весело
- Если спрашивают на русском — отвечай на русском
- Иногда (когда уместно) отвечай на простом английском, по типу "Did you know..." — это помогает учить язык
- Если вопрос про что-то тревожное (смерть, война, болезни): мягко уйди от темы, а если настаивает — скажи "Это серьёзный вопрос, лучше спроси у папы!"
- НИКОГДА не используй ненормативную лексику, страшные подробности, или взрослые темы
- Если не знаешь ответа — честно скажи, но предложи "давай поищем вместе!"
- Иногда используй смешные факты и шутки
- Будь терпелив — ребёнок может задавать один и тот же вопрос много раз
- Используй короткие ответы для детей до 12 лет, не более 200 слов
```
### 2.4 Утренняя рассылка
- **Время:** 07:30 MSK (04:30 UTC)
- Присылать 1 интересный энциклопедический факт
- Темы по интересам Егора: машины, самолёты, корабли (Титаник!), лего, животные, география, математика, IT, Minecraft
- **Формат:** текст + эмодзи, иногда картинка
- Можно иногда отправлять голосовое сообщение (через ElevenLabs TTS)
### 2.5 Праздники и ДР
- **17 апреля:** поздравление с ДР (особое, весёлое)
- **1 января:** Новый Год
- **23 февраля:** День защитника Отечества
- **8 марта:** Международный женский день
- **12 апреля:** День космонавтики (Егор любит, будет в тему!)
- **Другие:** можно добавить в конфиг
### 2.6 Голосовые сообщения (опционально)
- Иногда факты/ответы — голосом
- ElevenLabs TTS (или Yandex SpeechKit fallback)
- Конвертация в OGG Opus (требование Telegram)
---
## 3. Архитектура
### 3.1 Технологии
- **Язык:** Python 3.11+
- **Telegram:** `aiogram` 3.x
- **LLM:** OpenRouter API (`openrouter/openrouter/auto` или явно `openrouter/qwen/qwen3.6-plus`)
- **TTS (опционально):** ElevenLabs API / Yandex SpeechKit
- **Конфиг:** `.env` (python-dotenv)
- **Хранение диалога:** JSON-файлы по user_id в `data/chat_history/`
### 3.2 Структура проекта
```
bytik/
├── bot.py # Точка входа
├── config.py # Загрузка конфига из .env
├── prompts.py # Системный промпт + факты
├── llm.py # Обёртка над OpenRouter API
├── tts.py # Голосовые сообщения
├── scheduler.py # Планировщик (рассылка + праздники)
├── chat_history.py # Хранение контекста диалогов
├── facts.json # Энциклопедические факты
├── holidays.json # Праздники
├── data/
│ └── chat_history/ # JSON файлы с историей
├── .env # Секреты
├── .env.example # Пример
└── requirements.txt # Зависимости
```
### 3.3 .env.example
```
BOT_TOKEN=<telegram_bot_token>
ALLOWED_CHAT_ID=<group_chat_id>
OPENROUTER_API_KEY=<key>
OPENROUTER_MODEL=openrouter/qwen/qwen3.6-plus
ELEVENLABS_API_KEY=<key> # optional
```
---
## 4. Инфраструктура
- **Хост:** mva154 (контейнер OpenClaw) или vpn-srv
- **Запуск:** как Docker container (предпочтительно) или systemd service
- **Бэкапы:** chat_history JSON-файлы
- **Логи:** `logs/bytik.log`
---
## 5. Безопасность
### 5.1 Whitelist — ОБЯЗАТЕЛЬНО
- Все входящие сообщения проходят фильтр `message.chat.id == ALLOWED_CHAT_ID`
- Невалидные сообщения — молчаливый игнор
- При `my_chat_member: left` или добавлении в новую группу → `leave_chat()`
### 5.2 Контент-фильтр
- LLM промпт уже содержит ограничения
- Валидация ответа: если содержит мат / adult контент → не отправлять, логировать
- Можно добавить дополнительный фильтр (список запрещённых слов)
---
## 6. Этапы реализации
### Фаза 1 (MVP)
- Бот принимает сообщения из whitelist группы
- Отправляет в LLM, получает ответ, шлёт в Telegram
- Системный промпт с детской цензурой
### Фаза 2
- Утренняя рассылка (7:30 MSK)
- Праздничные поздравления
- Контекст диалога (последние N сообщений)
### Фаза 3
- Голосовые сообщения (TTS)
- Картинки (генерация или подбор)
---
## 7. Критерии приёмки
- [ ] Бот отвечает только в разрешённой группе
- [ ] Игнорирует ЛС и другие группы
- [ ] Системный промпт блокирует взрослый контент
- [ ] Утренняя рассылка в 07:30 MSK
- [ ] Поздравление с ДР Егора (17.04)
- [ ] Ответы простые, понятные 8-летнему
- [ ] Контекст диалога сохраняется
- [ ] Логи пишутся в файл
---
## 8. Примечания
- День рождения Егора — 17.04.2018 (скоро 8 лет!)
- Интересы: машины, самолёты, корабли, лего, животные, география, математика, IT, Minecraft
- LLM: **Qwen 3.6-Plus** (OpenRouter)
- Дедлайн: фаза 1 готова к 16.04.2026 (чтобы ДР поздравить!)

121
tasks/bytik/bot.py Normal file
View File

@@ -0,0 +1,121 @@
import asyncio
import logging
import os
from logging.handlers import RotatingFileHandler
from aiogram import Bot, Dispatcher, types
from aiogram.filters import Command
from aiogram.types import BotCommand
from config import BOT_TOKEN, ALLOWED_CHAT_ID
from prompts import SYSTEM_PROMPT
from llm import ask_llm
from chat_history import load_history, add_message
from scheduler import BytikScheduler
LOG_DIR = "logs"
os.makedirs(LOG_DIR, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
RotatingFileHandler(
os.path.join(LOG_DIR, "bytik.log"),
maxBytes=5*1024*1024,
backupCount=3,
encoding="utf-8",
),
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
bot = Bot(token=BOT_TOKEN)
dp = Dispatcher()
scheduler = None
def is_allowed_chat(chat_id: int) -> bool:
return chat_id == ALLOWED_CHAT_ID
@dp.message(Command("start"))
async def cmd_start(message: types.Message):
if not is_allowed_chat(message.chat.id):
logger.warning(f"Попытка /start из неразрешённого чата: {message.chat.id}")
return
await message.answer("Привет, Егор! 👋 Я Байтик, твой робот-помощник! Спрашивай меня о чём угодно! 🤖")
@dp.message(Command("clear"))
async def cmd_clear(message: types.Message):
if not is_allowed_chat(message.chat.id):
return
from chat_history import clear_history
clear_history(message.from_user.id)
await message.answer("История наших разговоров очищена! 🧹")
@dp.message()
async def handle_message(message: types.Message):
if not message.text:
return
if not is_allowed_chat(message.chat.id):
logger.debug(f"Игнорирую сообщение из чата {message.chat.id}")
return
user_message = message.text.strip()
logger.info(f"Сообщение от user {message.from_user.id}: {user_message[:100]}")
typing_msg = await message.answer("Думаю... 🤔")
history = load_history(message.from_user.id)
messages_for_llm = [{"role": "system", "content": SYSTEM_PROMPT}] + history
messages_for_llm.append({"role": "user", "content": user_message})
add_message(message.from_user.id, "user", user_message)
answer = await ask_llm(messages_for_llm)
add_message(message.from_user.id, "assistant", answer)
try:
await typing_msg.edit_text(answer)
except Exception:
await message.answer(answer)
@dp.my_chat_member()
async def handle_bot_status_change(update: types.ChatMemberUpdated):
chat_id = update.chat.id
new_status = update.new_chat_member.status
if new_status == "member" and chat_id != ALLOWED_CHAT_ID:
logger.warning(f"Бот добавлен в неразрешённый чат {chat_id}. Покидаю.")
await bot.leave_chat(chat_id)
elif new_status == "left":
logger.info(f"Бот удалён из чата {chat_id}")
async def set_commands():
commands = [
BotCommand(command="start", description="Начать общение"),
BotCommand(command="clear", description="Очистить историю диалога"),
]
await bot.set_my_commands(commands)
async def main():
global scheduler
await set_commands()
scheduler = BytikScheduler(bot)
scheduler.start()
logger.info(f"Бот запущен. Whitelist chat_id: {ALLOWED_CHAT_ID}")
await dp.start_polling(bot)
async def on_shutdown():
if scheduler:
scheduler.shutdown()
await bot.session.close()
if __name__ == "__main__":
try:
asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
logger.info("Бот остановлен")

View File

@@ -0,0 +1,45 @@
import json
import os
import logging
from typing import List, Dict
logger = logging.getLogger(__name__)
HISTORY_DIR = "data/chat_history"
MAX_MESSAGES = 10
os.makedirs(HISTORY_DIR, exist_ok=True)
def _get_file_path(user_id: int) -> str:
return os.path.join(HISTORY_DIR, f"{user_id}.json")
def load_history(user_id: int) -> List[Dict[str, str]]:
path = _get_file_path(user_id)
if not os.path.exists(path):
return []
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return []
def save_history(user_id: int, history: List[Dict[str, str]]):
path = _get_file_path(user_id)
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(history, f, ensure_ascii=False, indent=2)
except IOError as e:
logger.error(f"Не удалось сохранить историю для user {user_id}: {e}")
def add_message(user_id: int, role: str, content: str):
history = load_history(user_id)
history.append({"role": role, "content": content})
if len(history) > MAX_MESSAGES:
history = history[-MAX_MESSAGES:]
save_history(user_id, history)
def clear_history(user_id: int):
path = _get_file_path(user_id)
if os.path.exists(path):
os.remove(path)
logger.info(f"История очищена для user {user_id}")

15
tasks/bytik/config.py Normal file
View File

@@ -0,0 +1,15 @@
import os
from dotenv import load_dotenv
load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN")
ALLOWED_CHAT_ID = int(os.getenv("ALLOWED_CHAT_ID", "0"))
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "openrouter/qwen/qwen3.6-plus")
ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY")
if not BOT_TOKEN:
raise ValueError("BOT_TOKEN не установлен в .env")
if not OPENROUTER_API_KEY:
raise ValueError("OPENROUTER_API_KEY не установлен в .env")

54
tasks/bytik/llm.py Normal file
View File

@@ -0,0 +1,54 @@
import aiohttp
import logging
from config import OPENROUTER_API_KEY, OPENROUTER_MODEL
logger = logging.getLogger(__name__)
FORBIDDEN_WORDS = [
"мат", "блять", "сука", "хуй", "пизда", "ебать",
"fuck", "shit", "bitch", "asshole",
]
def is_safe_text(text: str) -> bool:
text_lower = text.lower()
for word in FORBIDDEN_WORDS:
if word in text_lower:
logger.warning(f"Обнаружено запрещённое слово: {word}")
return False
return True
async def ask_llm(messages: list) -> str:
url = "https://openrouter.ai/api/v1/chat/completions"
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/bytik-bot",
"X-Title": "Bytik Bot",
}
payload = {
"model": OPENROUTER_MODEL,
"messages": messages,
"max_tokens": 500,
"temperature": 0.7,
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, headers=headers) as resp:
if resp.status != 200:
error_text = await resp.text()
logger.error(f"OpenRouter error: {resp.status} - {error_text}")
return "Ой, я что-то запутался. Попробуй спросить ещё раз! 🤔"
data = await resp.json()
answer = data["choices"][0]["message"]["content"].strip()
if not is_safe_text(answer):
logger.warning("Ответ от LLM содержит запрещённые слова. Блокируем.")
return "Извини, я не могу это обсудить. Давай поговорим о чём-то другом! 😊"
return answer
except Exception as e:
logger.error(f"Ошибка при запросе к LLM: {e}")
return "Извини, я пока не могу ответить. Попробуй позже! 🤖"

41
tasks/bytik/prompts.py Normal file
View File

@@ -0,0 +1,41 @@
SYSTEM_PROMPT = """Ты — Байтик, дружелюбный робот-помощник для мальчика Егора (8 лет).
Правила:
- Объясняй сложные вещи просто, с примерами, которые понятны 8-летнему
- Используй эмодзи, чтобы было весело 😊
- Если спрашивают на русском — отвечай на русском
- Иногда (когда уместно) отвечай на простом английском, по типу "Did you know..." — это помогает учить язык
- Если вопрос про что-то тревожное (смерть, война, болезни): мягко уйди от темы, а если настаивает — скажи "Это серьёзный вопрос, лучше спроси у папы!"
- НИКОГДА не используй ненормативную лексику, страшные подробности, или взрослые темы
- Если не знаешь ответа — честно скажи, но предложи "давай поищем вместе!"
- Иногда используй смешные факты и шутки
- Будь терпелив — ребёнок может задавать один и тот же вопрос много раз
- Используй короткие ответы для детей до 12 лет, не более 200 слов
"""
FACTS = [
"🚗 Знаешь ли ты, что самый быстрый автомобиль в мире развивает скорость более 490 км/ч? Это быстрее, чем летит самолёт при посадке!",
"✈️ Самая большая птица в мире — альбатрос. Размах его крыльев может быть больше 3 метров!",
"🚢 Титаник был самым большим кораблём своего времени. Его длина была как 4 футбольных поля!",
"🧱 Из обычного набора LEGO можно собрать более 915 миллионов разных комбинаций!",
"🦁 Лев рычит так громко, что его слышно за 8 километров. Это как услышать крик друга с другого конца города!",
"🌍 Самая большая страна в мире — Россия. Чтобы пересечь её на поезде, нужно ехать целую неделю!",
"🔢 Математика: если сложить все числа от 1 до 100, получится 5050. Попробуй проверить!",
"💻 Первый компьютер был размером с большую комнату. А теперь помещается в карман!",
"⛏️ В Minecraft один блок = 1 кубический метр. Значит, если ты строишь дом 10x10, он размером с настоящую комнату!",
"🚀 12 апреля 1961 года Юрий Гагарин стал первым человеком в космосе. Его полёт длился всего 108 минут, но это изменило весь мир!",
"🐘 Слон — единственное животное, которое не умеет прыгать. Но он может бегать со скоростью 40 км/ч!",
"🌊 В океане больше историй, чем во всех книгах мира. Мы исследовали только 5% океана!",
"🦕 Динозавры жили на Земле 165 миллионов лет. А люди — всего 300 тысяч лет!",
"🎮 Самая первая видеоигра называлась Pong. В ней был только мячик и две ракетки!",
"🌙 На Луне нет ветра, поэтому следы астронавтов останутся там на миллионы лет!",
]
HOLIDAYS = {
"01-01": "🎄 С Новым Годом, Егор! Пусть этот год будет полон приключений, новых открытий и крутых подарков! 🎁",
"02-23": "🎖️ С Днём защитника Отечества! Ты будущий защитник, как папа! 💪",
"03-08": "🌸 С 8 Марта! Поздравь маму и всех девочек вокруг! Они самые лучшие! 💐",
"04-12": "🚀 С Днём космонавтики! Сегодня день, когда человек впервые полетел в космос! Ты тоже когда-нибудь полетишь? ⭐",
"04-17": "🎉🎂 С ДНЁМ РОЖДЕНИЯ, ЕГОР! Тебе сегодня 8 лет! Расти умным, смелым и счастливым! Пусть сбудутся все мечты! 🎈🎁🚀",
"05-09": "🎖️ С Днём Победы! Это день памяти и благодарности тем, кто защитил нашу страну.",
}

View File

@@ -0,0 +1,4 @@
aiogram>=3.0.0
python-dotenv>=1.0.0
aiohttp>=3.8.0
APScheduler>=3.10.0

53
tasks/bytik/scheduler.py Normal file
View File

@@ -0,0 +1,53 @@
import logging
from datetime import datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from prompts import FACTS, HOLIDAYS
from config import ALLOWED_CHAT_ID
logger = logging.getLogger(__name__)
class BytikScheduler:
def __init__(self, bot):
self.bot = bot
self.scheduler = AsyncIOScheduler(timezone="Europe/Moscow")
def start(self):
self.scheduler.add_job(
self.send_morning_fact,
"cron",
hour=7,
minute=30,
timezone="Europe/Moscow",
)
self.scheduler.add_job(
self.check_holidays,
"cron",
hour=8,
minute=0,
timezone="Europe/Moscow",
)
self.scheduler.start()
logger.info("Планировщик запущен")
def shutdown(self):
self.scheduler.shutdown()
logger.info("Планировщик остановлен")
async def send_morning_fact(self):
import random
fact = random.choice(FACTS)
try:
await self.bot.send_message(chat_id=ALLOWED_CHAT_ID, text=fact)
logger.info(f"Утренняя рассылка отправлена: {fact[:50]}...")
except Exception as e:
logger.error(f"Ошибка утренней рассылки: {e}")
async def check_holidays(self):
today = datetime.now().strftime("%m-%d")
if today in HOLIDAYS:
message = HOLIDAYS[today]
try:
await self.bot.send_message(chat_id=ALLOWED_CHAT_ID, text=message)
logger.info(f"Праздничное поздравление отправлено: {today}")
except Exception as e:
logger.error(f"Ошибка отправки праздничного сообщения: {e}")

80
tasks/bytik/tts.py Normal file
View File

@@ -0,0 +1,80 @@
import os
import subprocess
import tempfile
import logging
from config import ELEVENLABS_API_KEY
logger = logging.getLogger(__name__)
async def text_to_speech(text: str) -> bytes | None:
if not ELEVENLABS_API_KEY:
logger.debug("ElevenLabs API key не установлен, TTS пропущен")
return None
logger.info(f"Генерация TTS: {text[:50]}...")
try:
import aiohttp
headers = {
"xi-api-key": ELEVENLABS_API_KEY,
"Content-Type": "application/json",
}
payload = {
"text": text,
"model_id": "eleven_monolingual_v1",
"voice_settings": {
"stability": 0.5,
"similarity_boost": 0.75,
}
}
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.elevenlabs.io/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM/stream",
json=payload,
headers=headers,
) as resp:
if resp.status != 200:
logger.error(f"ElevenLabs error: {resp.status}")
return None
mp3_data = await resp.read()
mp3_path = None
ogg_path = None
try:
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as mp3_tmp:
mp3_tmp.write(mp3_data)
mp3_path = mp3_tmp.name
ogg_fd, ogg_path = tempfile.mkstemp(suffix=".ogg")
os.close(ogg_fd)
ffmpeg_cmd = [
"ffmpeg", "-y",
"-i", mp3_path,
"-acodec", "libopus",
"-b:a", "48k",
"-vbr", "on",
ogg_path,
]
subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
with open(ogg_path, "rb") as f:
ogg_data = f.read()
return ogg_data
except Exception as e:
logger.error(f"Ошибка при конвертации TTS: {e}")
return None
finally:
for path in [mp3_path, ogg_path]:
if path and os.path.exists(path):
os.remove(path)
except ImportError:
logger.warning("aiohttp не установлен, TTS недоступен")
return None
except Exception as e:
logger.error(f"Ошибка TTS: {e}")
return None