Загрузить файлы в «/»
This commit is contained in:
390
cosmo_bot.py
Normal file
390
cosmo_bot.py
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
#!/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()
|
||||||
Reference in New Issue
Block a user