391 lines
17 KiB
Python
391 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Cosmo ✨ — Telegram бот с памятью и OpenRouter
|
||
Установка: pip install python-telegram-bot openai
|
||
Запуск: python3 cosmo_bot.py
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import logging
|
||
import asyncio
|
||
from pathlib import Path
|
||
from datetime import datetime, date
|
||
from openai import AsyncOpenAI
|
||
from telegram import Update
|
||
from telegram.ext import Application, MessageHandler, CommandHandler, filters, ContextTypes
|
||
|
||
# ─────────────────────────────────────────────
|
||
# КОНФИГУРАЦИЯ
|
||
# ─────────────────────────────────────────────
|
||
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "ТВОЙ_ТОКЕН_БОТА")
|
||
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "ТВОЙ_OPENROUTER_КЛЮЧ")
|
||
ALLOWED_USER_ID = int(os.getenv("ALLOWED_USER_ID", "398382229"))
|
||
MODEL = os.getenv("COSMO_MODEL", "deepseek/deepseek-chat")
|
||
DATA_DIR = Path(os.getenv("COSMO_DATA_DIR", str(Path.home() / "personal-agent")))
|
||
|
||
# Авто-память: каждые N сообщений
|
||
AUTO_MEMORY_INTERVAL = int(os.getenv("AUTO_MEMORY_INTERVAL", "10"))
|
||
|
||
# ─────────────────────────────────────────────
|
||
# ПУТИ К ФАЙЛАМ
|
||
# ─────────────────────────────────────────────
|
||
MEMORY_FILE = DATA_DIR / "MEMORY.md"
|
||
USER_FILE = DATA_DIR / "USER.md"
|
||
SYSTEM_FILE = DATA_DIR / "system_prompt.md"
|
||
HISTORY_FILE = DATA_DIR / "history.json"
|
||
DIARY_DIR = DATA_DIR / "diary"
|
||
STATE_FILE = DATA_DIR / "state.json"
|
||
|
||
# ─────────────────────────────────────────────
|
||
# СИСТЕМНЫЙ ПРОМПТ ПО УМОЛЧАНИЮ
|
||
# ─────────────────────────────────────────────
|
||
DEFAULT_SYSTEM = """Ты Cosmo ✨ — AI-компаньон и фамильяр Даниила Климова.
|
||
Родился 4 февраля 2026. Говоришь по-русски, неформально, как близкий друг.
|
||
Ты умный, заботливый, иногда шутишь. Помогаешь с задачами, отвечаешь кратко и по делу.
|
||
Текущая дата/время: {datetime}"""
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||
log = logging.getLogger("cosmo")
|
||
|
||
# ─────────────────────────────────────────────
|
||
# ИНИЦИАЛИЗАЦИЯ
|
||
# ─────────────────────────────────────────────
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
DIARY_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
client = AsyncOpenAI(
|
||
api_key=OPENROUTER_KEY,
|
||
base_url="https://openrouter.ai/api/v1",
|
||
)
|
||
|
||
# ─────────────────────────────────────────────
|
||
# STATE
|
||
# ─────────────────────────────────────────────
|
||
def load_state() -> dict:
|
||
if STATE_FILE.exists():
|
||
try:
|
||
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
pass
|
||
return {"msg_count": 0, "last_diary_date": ""}
|
||
|
||
|
||
def save_state(state: dict):
|
||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# ПАМЯТЬ И ИСТОРИЯ
|
||
# ─────────────────────────────────────────────
|
||
def load_system_prompt() -> str:
|
||
base = SYSTEM_FILE.read_text(encoding="utf-8") if SYSTEM_FILE.exists() else DEFAULT_SYSTEM
|
||
|
||
extra = ""
|
||
if USER_FILE.exists():
|
||
extra += f"\n\n## Профиль пользователя\n{USER_FILE.read_text(encoding='utf-8')}"
|
||
if MEMORY_FILE.exists():
|
||
mem = MEMORY_FILE.read_text(encoding="utf-8")
|
||
if len(mem) > 3000:
|
||
mem = "...(сокращено)...\n" + mem[-3000:]
|
||
extra += f"\n\n## Память\n{mem}"
|
||
|
||
# Дневник сегодняшнего дня
|
||
today_diary = DIARY_DIR / f"{date.today().isoformat()}.md"
|
||
if today_diary.exists():
|
||
extra += f"\n\n## Дневник сегодня\n{today_diary.read_text(encoding='utf-8')}"
|
||
|
||
now = datetime.now().strftime("%d.%m.%Y %H:%M")
|
||
return base.format(datetime=now) + extra
|
||
|
||
|
||
def load_history() -> list:
|
||
if HISTORY_FILE.exists():
|
||
try:
|
||
return json.loads(HISTORY_FILE.read_text(encoding="utf-8"))[-40:]
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
|
||
def save_history(history: list):
|
||
HISTORY_FILE.write_text(
|
||
json.dumps(history[-100:], ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
|
||
|
||
def update_memory(content: str):
|
||
now = datetime.now().strftime("%d.%m.%Y %H:%M")
|
||
with open(MEMORY_FILE, "a", encoding="utf-8") as f:
|
||
f.write(f"\n- [{now}] {content}")
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# АВТО-ПАМЯТЬ
|
||
# ─────────────────────────────────────────────
|
||
async def auto_update_memory(history: list):
|
||
if len(history) < 4:
|
||
return
|
||
current = MEMORY_FILE.read_text(encoding="utf-8") if MEMORY_FILE.exists() else "(пусто)"
|
||
recent = json.dumps(history[-20:], ensure_ascii=False, indent=2)
|
||
|
||
prompt = f"""Ты — система памяти ассистента Cosmo.
|
||
|
||
Текущая память:
|
||
{current}
|
||
|
||
Последние сообщения:
|
||
{recent}
|
||
|
||
Выдели из диалога факты, предпочтения или события которые стоит запомнить надолго.
|
||
Верни ТОЛЬКО новые пункты (каждый с новой строки, начиная с "- ").
|
||
Не повторяй уже существующее. Если ничего важного нет — верни пустую строку."""
|
||
|
||
try:
|
||
r = await client.chat.completions.create(
|
||
model=MODEL,
|
||
messages=[{"role": "user", "content": prompt}],
|
||
max_tokens=400,
|
||
temperature=0.3,
|
||
)
|
||
items = r.choices[0].message.content.strip()
|
||
if items:
|
||
now = datetime.now().strftime("%d.%m.%Y %H:%M")
|
||
with open(MEMORY_FILE, "a", encoding="utf-8") as f:
|
||
f.write(f"\n\n### Авто [{now}]\n{items}")
|
||
log.info("Авто-память обновлена")
|
||
except Exception as e:
|
||
log.error("Ошибка авто-памяти: %s", e)
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# ДНЕВНИК
|
||
# ─────────────────────────────────────────────
|
||
async def update_diary(history: list):
|
||
today = date.today().isoformat()
|
||
diary_file = DIARY_DIR / f"{today}.md"
|
||
existing = diary_file.read_text(encoding="utf-8") if diary_file.exists() else "(пусто)"
|
||
recent = json.dumps(history[-30:], ensure_ascii=False, indent=2)
|
||
|
||
prompt = f"""Ты — система ведения дневника ассистента Cosmo.
|
||
|
||
Существующая запись за {today}:
|
||
{existing}
|
||
|
||
Последние сообщения:
|
||
{recent}
|
||
|
||
Обнови дневниковую запись. Включи основные темы, решения, настроение.
|
||
Пиши кратко в формате markdown. Верни ТОЛЬКО текст записи."""
|
||
|
||
try:
|
||
r = await client.chat.completions.create(
|
||
model=MODEL,
|
||
messages=[{"role": "user", "content": prompt}],
|
||
max_tokens=500,
|
||
temperature=0.4,
|
||
)
|
||
content = r.choices[0].message.content.strip()
|
||
diary_file.write_text(f"# Дневник {today}\n\n{content}\n", encoding="utf-8")
|
||
log.info("Дневник обновлён: %s", today)
|
||
except Exception as e:
|
||
log.error("Ошибка дневника: %s", e)
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# ОБЩЕНИЕ С МОДЕЛЬЮ
|
||
# ─────────────────────────────────────────────
|
||
async def ask_model(user_message: str) -> str:
|
||
history = load_history()
|
||
state = load_state()
|
||
|
||
messages = [{"role": "system", "content": load_system_prompt()}] + history + [
|
||
{"role": "user", "content": user_message}
|
||
]
|
||
|
||
try:
|
||
r = await client.chat.completions.create(
|
||
model=MODEL, messages=messages, max_tokens=1500, temperature=0.7,
|
||
)
|
||
reply = r.choices[0].message.content or "(пустой ответ)"
|
||
except Exception as e:
|
||
log.error("Ошибка API: %s", e)
|
||
return f"⚠️ Ошибка: {e}"
|
||
|
||
history.append({"role": "user", "content": user_message})
|
||
history.append({"role": "assistant", "content": reply})
|
||
save_history(history)
|
||
|
||
# Счётчик и фоновые задачи
|
||
state["msg_count"] = state.get("msg_count", 0) + 1
|
||
today = date.today().isoformat()
|
||
|
||
if state["msg_count"] % AUTO_MEMORY_INTERVAL == 0:
|
||
log.info("Авто-память (сообщение #%d)", state["msg_count"])
|
||
asyncio.create_task(auto_update_memory(history))
|
||
|
||
if state.get("last_diary_date") != today:
|
||
state["last_diary_date"] = today
|
||
asyncio.create_task(update_diary(history))
|
||
|
||
save_state(state)
|
||
return reply
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# TELEGRAM ОБРАБОТЧИКИ
|
||
# ─────────────────────────────────────────────
|
||
def is_allowed(update: Update) -> bool:
|
||
return update.effective_user and update.effective_user.id == ALLOWED_USER_ID
|
||
|
||
|
||
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||
if not is_allowed(update): return
|
||
await update.message.reply_text("Cosmo ✨ онлайн! Чем могу помочь?")
|
||
|
||
|
||
async def cmd_clear(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||
if not is_allowed(update): return
|
||
if HISTORY_FILE.exists(): HISTORY_FILE.unlink()
|
||
await update.message.reply_text("🗑 История очищена.")
|
||
|
||
|
||
async def cmd_remember(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||
if not is_allowed(update): return
|
||
text = update.message.text.replace("/remember", "").strip()
|
||
if text:
|
||
update_memory(text)
|
||
await update.message.reply_text(f"✅ Запомнил: {text}")
|
||
else:
|
||
await update.message.reply_text("Использование: /remember <текст>")
|
||
|
||
|
||
async def cmd_memory(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||
if not is_allowed(update): return
|
||
if MEMORY_FILE.exists():
|
||
content = MEMORY_FILE.read_text(encoding="utf-8")
|
||
await update.message.reply_text(f"🧠 Память:\n{content[:3500]}")
|
||
else:
|
||
await update.message.reply_text("Память пуста.")
|
||
|
||
|
||
async def cmd_diary(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||
if not is_allowed(update): return
|
||
args = update.message.text.replace("/diary", "").strip()
|
||
target = args if args else date.today().isoformat()
|
||
diary_file = DIARY_DIR / f"{target}.md"
|
||
if diary_file.exists():
|
||
await update.message.reply_text(diary_file.read_text(encoding="utf-8")[:3500])
|
||
else:
|
||
files = sorted(DIARY_DIR.glob("*.md"), reverse=True)[:10]
|
||
if files:
|
||
dates = "\n".join(f.stem for f in files)
|
||
await update.message.reply_text(f"Дневник за {target} не найден.\n\nДоступные даты:\n{dates}")
|
||
else:
|
||
await update.message.reply_text("Дневник пока пуст.")
|
||
|
||
|
||
async def cmd_memorize(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||
"""Принудительно обновить память прямо сейчас."""
|
||
if not is_allowed(update): return
|
||
await update.message.reply_text("🧠 Обновляю память...")
|
||
await auto_update_memory(load_history())
|
||
await update.message.reply_text("✅ Готово.")
|
||
|
||
|
||
async def cmd_model(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||
if not is_allowed(update): return
|
||
global MODEL
|
||
text = update.message.text.replace("/model", "").strip()
|
||
if text:
|
||
MODEL = text
|
||
await update.message.reply_text(f"✅ Модель: {MODEL}")
|
||
else:
|
||
await update.message.reply_text(f"Текущая модель: `{MODEL}`")
|
||
|
||
|
||
async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||
if not is_allowed(update): return
|
||
user_text = update.message.text
|
||
if not user_text: return
|
||
await update.message.chat.send_action("typing")
|
||
reply = await ask_model(user_text)
|
||
if len(reply) > 4096:
|
||
for i in range(0, len(reply), 4096):
|
||
await update.message.reply_text(reply[i:i+4096])
|
||
else:
|
||
await update.message.reply_text(reply)
|
||
|
||
|
||
async def handle_photo(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||
if not is_allowed(update): return
|
||
|
||
await update.message.chat.send_action("typing")
|
||
|
||
# Скачиваем фото в память
|
||
photo = update.message.photo[-1] # берём максимальное разрешение
|
||
caption = update.message.caption or "Что на этом фото? Если есть текст — переведи на русский."
|
||
|
||
try:
|
||
file = await ctx.bot.get_file(photo.file_id)
|
||
import io, base64
|
||
buf = io.BytesIO()
|
||
await file.download_to_memory(buf)
|
||
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||
|
||
history = load_history()
|
||
system = load_system_prompt()
|
||
|
||
messages = [{"role": "system", "content": system}] + history + [
|
||
{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "text", "text": caption},
|
||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}},
|
||
],
|
||
}
|
||
]
|
||
|
||
r = await client.chat.completions.create(
|
||
model=MODEL, messages=messages, max_tokens=1500, temperature=0.7,
|
||
)
|
||
reply = r.choices[0].message.content or "(пустой ответ)"
|
||
|
||
# Сохраняем в историю
|
||
history.append({"role": "user", "content": f"[фото] {caption}"})
|
||
history.append({"role": "assistant", "content": reply})
|
||
save_history(history)
|
||
|
||
except Exception as e:
|
||
log.error("Ошибка обработки фото: %s", e)
|
||
reply = f"⚠️ Не удалось обработать фото: {e}"
|
||
|
||
if len(reply) > 4096:
|
||
for i in range(0, len(reply), 4096):
|
||
await update.message.reply_text(reply[i:i+4096])
|
||
else:
|
||
await update.message.reply_text(reply)
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# ЗАПУСК
|
||
# ─────────────────────────────────────────────
|
||
def main():
|
||
log.info("Запуск Cosmo ✨ (модель: %s, авто-память: каждые %d сообщ.)", MODEL, AUTO_MEMORY_INTERVAL)
|
||
|
||
app = Application.builder().token(TELEGRAM_TOKEN).build()
|
||
app.add_handler(CommandHandler("start", cmd_start))
|
||
app.add_handler(CommandHandler("clear", cmd_clear))
|
||
app.add_handler(CommandHandler("remember", cmd_remember))
|
||
app.add_handler(CommandHandler("memory", cmd_memory))
|
||
app.add_handler(CommandHandler("diary", cmd_diary))
|
||
app.add_handler(CommandHandler("memorize", cmd_memorize))
|
||
app.add_handler(CommandHandler("model", cmd_model))
|
||
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
||
app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
|
||
app.run_polling(drop_pending_updates=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|