/
valisa
/
EditorAI
Обзор
Документация
Войти
/
valisa
/
EditorAI
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
bot/main.py
1 170 строк
44 KB
linxxxa
some chnages not really usable btw
18 июн 2026, 14:34
18 июн 2026, 14:34
17d8904
Код
Авторство
О чём код?
""" EditorAI — Telegram-бот-ассистент для переписок. Подключает скилл ReplyAssistant к Telegram-боту. Логика: - /start, /help — приветствие. - /ping — проверка связи. - Любое сообщение с триггером ("ответь за меня" и варианты) → ReplyAssistant. - Бот вызывает LLM через OpenAI-compatible endpoint (можно подключить https://api.minimax.io/anthropic/v1 или OpenAI). - Если LLM_API_KEY не задан — бот работает в stub-режиме (принимает триггер, отвечает заглушкой, демонстрирует маршрутизацию). Запуск: source .venv/bin/activate python bot/main.py """ from __future__ import annotations import asyncio import logging import os import re from typing import Any import httpx def _parse_llm_json(content: str) -> dict | None: """Robust JSON parsing for LLM responses. Handles three common failure modes: 1. Markdown code fences (```json ... ```) — strip and re-parse. 2. JSON embedded in surrounding prose — extract first {...} and parse. 3. Trailing/leading whitespace or newlines. Returns None if no valid JSON found. """ text = content.strip() # Strip ```json or ``` fences. text = re.sub(r"^\s*```(?:json)?\s*\n", "", text, flags=re.IGNORECASE) text = re.sub(r"\n```\s*$", "", text, flags=re.IGNORECASE) # Try direct parse. try: parsed = json.loads(text) if isinstance(parsed, dict): return parsed except json.JSONDecodeError: pass # Find first balanced JSON object in the response. for open_idx in (text.find("{"), ): if open_idx == -1: continue depth = 0 for close_idx in range(open_idx, len(text)): if text[close_idx] == "{": depth += 1 elif text[close_idx] == "}": depth -= 1 if depth == 0: candidate = text[open_idx:close_idx + 1] try: parsed = json.loads(candidate) if isinstance(parsed, dict): return parsed except json.JSONDecodeError: break return None def _parse_lenient_json(text: str) -> dict | None: """Lenient JSON parser for {key:value,...} without quotes. LLM sometimes returns JSON without quotes around keys/values. Standard json.loads rejects this. This parser: 1. Strips markdown code fences. 2. Finds the first balanced {...} block. 3. Splits by top-level commas (respecting strings and nesting). 4. Parses each key:value pair by the first colon. """ import json as _json s = text.strip() s = re.sub(r"^\s*```(?:json)?\s*\n", "", s, flags=re.IGNORECASE) s = re.sub(r"\n```\s*$", "", s, flags=re.IGNORECASE) # Strict JSON attempt first try: parsed = _json.loads(s) if isinstance(parsed, dict): return parsed except _json.JSONDecodeError: pass # Find balanced {...} start = s.find("{") if start == -1: return None depth = 0 in_str = False str_ch = None end = -1 i = start while i < len(s): c = s[i] if in_str: if c == "\\": i += 2 continue if c == str_ch: in_str = False i += 1 else: if c in ('"', "'"): in_str = True str_ch = c i += 1 elif c == "{": depth += 1 i += 1 elif c == "}": depth -= 1 if depth == 0: end = i + 1 break i += 1 else: i += 1 if end == -1: return None block = s[start:end] parts = [] depth = 0 in_str = False str_ch = None part_start = 1 i = 1 while i < len(block): c = block[i] if in_str: if c == "\\": i += 2 continue if c == str_ch: in_str = False i += 1 else: if c in ('"', "'"): in_str = True str_ch = c i += 1 elif c in ("{", "["): depth += 1 i += 1 elif c in ("}", "]"): depth -= 1 i += 1 elif c == "," and depth == 0: parts.append(block[part_start:i].strip()) part_start = i + 1 i += 1 else: i += 1 parts.append(block[part_start:].rstrip("}").strip()) def _strip_quotes(t): t = t.strip() if len(t) >= 2 and t[0] in ('"', "'") and t[-1] == t[0]: return t[1:-1] return t result = {} for part in parts: if not part: continue colon_idx = part.find(":") if colon_idx == -1: continue key = _strip_quotes(part[:colon_idx]) value_part = part[colon_idx + 1:].strip() if not value_part: value = None elif value_part.startswith("[") and value_part.endswith("]"): arr = value_part[1:-1].strip() items = [] if arr: cur = "" in_s = False sc = None j = 0 while j < len(arr): ch = arr[j] if in_s: if ch == "\\": j += 2 continue if ch == sc: in_s = False j += 1 else: if ch in ('"', "'"): in_s = True sc = ch j += 1 elif ch == ",": items.append(_strip_quotes(cur)) cur = "" j += 1 else: cur += ch j += 1 items.append(_strip_quotes(cur)) value = [v for v in items if v] else: value = _strip_quotes(value_part) if value == "true": value = True elif value == "false": value = False elif value == "null": value = None else: try: if "." in value: value = float(value) else: value = int(value) except ValueError: pass result[key] = value return result if result else None def _extract_name_from_text(text: str) -> str | None: """Если метаданные не дали имени собеседника, ищем в самом тексте. Patterns: - 'Имя: сообщение' or 'Имя — сообщение' in the first lines. - '@username сообщение' - 'Forwarded from Имя' / 'Переслано от Имя' """ if not text: return None for line in text.strip().splitlines()[:5]: line = line.strip() if not line: continue m = re.match(r"^([А-ЯЁA-Z][а-яёa-zA-Z]{1,20})\s*[:\-—]\s*\S", line) if m: return m.group(1) m = re.match(r"^@([a-zA-Z0-9_]{3,30})\b", line) if m: return m.group(1) m = re.search(r"[Пп]ереслано\s+от\s+([А-ЯЁA-Z][а-яёa-zA-Z]{1,20})", line) if m: return m.group(1) return None from dotenv import load_dotenv from telegram import Update from telegram.constants import ParseMode from telegram.ext import ( Application, CommandHandler, ContextTypes, MessageHandler, filters, ) def _try_openclaw_catalog_fallback() -> str: """Если LLM_API_KEY не задан в .env, попробуем взять из OpenClaw config. Полезно для minimax: ключ лежит в ~/.openclaw/agents/main/agent/plugins/minimax/catalog.json """ import json candidates = [ os.path.expanduser( "~/.openclaw/agents/main/agent/plugins/minimax/catalog.json" ), ] for path in candidates: try: with open(path, encoding="utf-8") as f: catalog = json.load(f) for prov in catalog.get("providers", {}).values(): if isinstance(prov, dict) and prov.get("apiKey"): print( f"[fallback] LLM_API_KEY not in .env; " f"using OpenClaw catalog key from {path}" ) return prov["apiKey"] except FileNotFoundError: continue except Exception as e: print(f"[fallback] catalog read failed for {path}: {e}") return "" load_dotenv( dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env"), override=True, # .env побеждает shell — иначе застрявшие переменные мешают ) TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN") LLM_API_KEY = os.environ.get("LLM_API_KEY") # Для minimax (и других Anthropic-format эндпоинтов) каталог OpenClaw # всегда приоритетнее .env — ключ там уже есть, а в .env часто лежит # ключ от другого провайдера. Пользователь может явно переопределить # через LLM_API_KEY_FORCE=1. force_env_key = os.environ.get("LLM_API_KEY_FORCE") == "1" if not LLM_API_KEY or ( "anthropic" in (os.environ.get("LLM_BASE_URL", "")).lower() and not force_env_key ): fallback_key = _try_openclaw_catalog_fallback() if fallback_key: LLM_API_KEY = fallback_key LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1") LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-4o-mini") SKILL_PATH = os.environ.get( "REPLY_ASSISTANT_SKILL_PATH", os.path.join(os.path.dirname(__file__), "..", "skills", "replyassistant", "SKILL.md"), ) USER_VOICE_PATH = os.environ.get( "USER_VOICE_PATH", os.path.expanduser("~/.openclaw/workspace/memory/styles/linxxxaa.yaml"), ) if not TELEGRAM_BOT_TOKEN or TELEGRAM_BOT_TOKEN.startswith("replace_me"): raise SystemExit( "TELEGRAM_BOT_TOKEN не задан. Положи реальный токен от @BotFather в .env" ) LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper() logging.basicConfig( format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", level=LOG_LEVEL, ) log = logging.getLogger("editorai") # Триггеры ReplyAssistant. REPLY_TRIGGERS = re.compile( r"\b(ответь\s+за\s+меня|помоги\s+ответить|сформулируй\s+ответ|что\s+мне\s+ответить)\b", re.IGNORECASE, ) WELCOME = ( "👋 *EditorAI на связи.*\n\n" "Помогаю с чатами в Telegram.\n\n" "*Команды:*\n" "• /start или /help — это сообщение\n" "• /ping — проверка связи\n" "• /status — режим работы (real-LLM / stub)\n" "• `ответь за меня` — ReplyAssistant: пришли переписку + эту фразу\n\n" "_ReplyAssistant сейчас в режиме: *{mode}*_" ).format(mode="real-LLM" if LLM_API_KEY else "stub (без LLM_API_KEY)") # ---------- Команды ---------- async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text(WELCOME, parse_mode=ParseMode.MARKDOWN) async def cmd_ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text("pong 🏓") async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if LLM_API_KEY: msg = ( f"🤖 *Режим:* real-LLM\n" f"• base_url: `{LLM_BASE_URL}`\n" f"• model: `{LLM_MODEL}`\n" f"• skill: `{SKILL_PATH}`\n" f"• user_voice: `{USER_VOICE_PATH}`" ) else: msg = ( "🟡 *Режим:* stub (LLM_API_KEY не задан)\n" "Бот реагирует на триггеры, но ответы — заглушки.\n" "Чтобы включить реальную генерацию, добавь в `.env`:\n" "```\n" "LLM_API_KEY=…\n" "LLM_BASE_URL=https://api.minimax.io/anthropic/v1\n" "LLM_MODEL=MiniMax-M3\n" "```" ) await update.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN) # ---------- ReplyAssistant ---------- def load_skill_md() -> str: try: with open(SKILL_PATH, encoding="utf-8") as f: return f.read() except FileNotFoundError: log.warning("skill md not found at %s — falling back to empty", SKILL_PATH) return "" def load_user_voice() -> str: try: with open(USER_VOICE_PATH, encoding="utf-8") as f: return f.read() except FileNotFoundError: log.warning("user voice not found at %s — falling back to empty", USER_VOICE_PATH) return "" def extract_conversation(message_text: str, reply_to) -> str: """Извлекает conversation_history из текста и/или reply-to.""" if message_text: # Убираем триггер из тела сообщения. return REPLY_TRIGGERS.sub("", message_text).strip() if reply_to and getattr(reply_to, "text", None): return reply_to.text.strip() return "" async def call_reply_assistant_stub(conversation: str, user_goal: str | None) -> dict[str, Any]: """Заглушка: возвращает структурированный ответ без вызова LLM. В проде заменяется на call_reply_assistant_llm(). """ return { "suggested_reply": ( "[STUB] Принял переписку.\n" f"Длина: {len(conversation)} символов.\n" f"Цель: {user_goal or 'не указана, восстановлена из контекста'}.\n\n" "Чтобы получать реальные черновики, добавь LLM_API_KEY в .env." ), "confidence": 65, "confidence_band": "normal", "notes": "stub-режим: реальная модель не вызвана.", "alternatives": [], "person_handle": None, } async def call_reply_assistant_llm(conversation: str, user_goal: str | None) -> dict[str, Any]: """Реальный вызов LLM с ReplyAssistant SKILL.md как system-prompt. Авто-выбор формата: - OpenAI-compatible (default): POST {base}/chat/completions, Bearer auth. - Anthropic-compatible (если LLM_BASE_URL содержит "anthropic"): POST {base}/v1/messages, x-api-key auth, формат Anthropic Messages API. """ skill_md = load_skill_md() user_voice = load_user_voice() system_prompt = ( "Ты — ассистент ReplyAssistant. Пишешь короткие сообщения за пользователя в чатах.\n\n" "=== ReplyAssistant SKILL.md ===\n" f"{skill_md}\n" "=== /ReplyAssistant SKILL.md ===\n\n" "=== User voice (linxxxaa) ===\n" f"{user_voice}\n" "=== /User voice ===\n\n" "ФОРМАТ ОТВЕТА: только валидный JSON без markdown.\n\n" "ПЕРЕД НАПИСАНИЕМ ЧЕРНОВИКА прогони 7 проверок из SKILL.md " "(conversation analysis framework): WHO, WHAT, TRAJECTORY, SUBTEXT, " "STAKE, EMOTIONAL_STATE, WHAT_USER_WANTS.\n\n" "СТРОГОЕ ПРАВИЛО ПО ПОЛЯМ:\n" "- text — ЭТО САМО СООБЩЕНИЕ, которое пользователь отправит. " "Длина 20-100 символов, в user_voice (lowercase, лол, скобки-smiles).\n" "Пример: блин, ань, сегодня не выйдет, давай на выходных?\n\n" "- alt — ОДИН альтернативный вариант того же сообщения. " "Если нечего добавить — пустая строка.\n" "Пример: сори ань, щас не смогу. в субботу ок?\n\n" "- note — ОДНА короткая фраза для пользователя (тема + регистр). " "НЕ текст сообщения.\n" "Пример: отказ подруге, casual регистр\n\n" "- person — ник собеседника (lowercase). Если не знаешь — unknown.\n" "- confidence — 0-100, насколько ты уверен в качестве черновика.\n" "- band — high|normal|medium|low|insufficient\n\n" "ЕСЛИ КОНТЕКСТА МАЛО (одно слово, нет темы):\n" " text = пришли переписку, без нее не пойму\n" " note = нужен контекст\n" " band = insufficient, confidence = 10\n\n" "ПРИМЕР ПОЛНОГО ОТВЕТА:\n" "{text:блин, ань, сегодня не выйдет, давай на выходных?," "alt:сори ань, щас не смогу. в субботу ок?," "note:отказ подруге casual,person:anya,confidence:75,band:normal}\n\n" "НЕ ПИШИ в text:\n" "- описания (нет профиля, тон шуточный)\n" "- условные конструкции (если X, то Y)\n" "- вопросы к пользователю (что за конкурс?)\n\n" "ЕСЛИ в форварде несколько разрозненных сообщений (одно-два слова каждое), это ОДИН разговор. Склей их в одну связную просьбу и перепиши как цельное сообщение.\n\n" "ЕСЛИ direction=sent и recipient неясен (нет имени в тексте) все равно напиши rewrite, адресуй нейтрально (друг / подруга / коллега), в person напиши unknown.\n\n" "В text — ТОЛЬКО то, что пользователь скопирует и отправит.\n" ) user_prompt_parts = [] if user_goal: user_prompt_parts.append(f"Цель ответа: {user_goal}") user_prompt_parts.append(f"Переписка:\n{conversation}") user_prompt = "\n\n".join(user_prompt_parts) use_anthropic = "anthropic" in LLM_BASE_URL.lower() if use_anthropic: return await _call_anthropic(system_prompt, user_prompt) return await _call_openai(system_prompt, user_prompt) async def _call_anthropic(system_prompt: str, user_prompt: str) -> dict[str, Any]: """Anthropic Messages API (используется для minimax и подобных провайдеров).""" url = f"{LLM_BASE_URL.rstrip('/')}/v1/messages" headers = { "x-api-key": LLM_API_KEY, "anthropic-version": "2023-06-01", "Content-Type": "application/json", } payload = { "model": LLM_MODEL, "max_tokens": 2048, "system": system_prompt, "messages": [ {"role": "user", "content": user_prompt}, ], } log.info("calling LLM (anthropic): model=%s url=%s", LLM_MODEL, url) async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post(url, headers=headers, json=payload) resp.raise_for_status() data = resp.json() # Anthropic response: {"content": [{"type": "text", "text": "..."}], ...} content = "" for block in data.get("content", []): if block.get("type") == "text": content += block.get("text", "") parsed = _parse_llm_json(content) if parsed is None: parsed = _parse_lenient_json(content) if parsed is not None: return parsed log.warning("Anthropic LLM did not return parseable JSON, raw: %s", content[:200]) return { "suggested_reply": content, "confidence": 60, "confidence_band": "normal", "notes": "Модель вернула не-JSON; показано как есть. Если видишь это — попробуй переформулировать запрос.", "alternatives": [], "person_handle": None, } async def _call_openai(system_prompt: str, user_prompt: str) -> dict[str, Any]: """OpenAI-compatible Chat Completions API (используется для OpenAI, Google AI Studio и т. п.).""" url = f"{LLM_BASE_URL.rstrip('/')}/chat/completions" headers = { "Authorization": f"Bearer {LLM_API_KEY}", "Content-Type": "application/json", } payload = { "model": LLM_MODEL, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], "temperature": 0.7, } log.info("calling LLM (openai): model=%s url=%s", LLM_MODEL, url) async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post(url, headers=headers, json=payload) resp.raise_for_status() data = resp.json() content = data["choices"][0]["message"]["content"] parsed = _parse_llm_json(content) if parsed is None: parsed = _parse_lenient_json(content) if parsed is not None: return parsed log.warning("OpenAI-compatible LLM did not return parseable JSON, raw: %s", content[:200]) return { "suggested_reply": content, "confidence": 60, "confidence_band": "normal", "notes": "Модель вернула не-JSON; показано как есть.", "alternatives": [], "person_handle": None, } async def call_reply_assistant(conversation: str, user_goal: str | None) -> dict[str, Any]: if LLM_API_KEY: try: return await call_reply_assistant_llm(conversation, user_goal) except Exception as e: log.exception("LLM call failed: %s", e) fallback = await call_reply_assistant_stub(conversation, user_goal) fallback["notes"] = f"LLM упал ({type(e).__name__}), показан stub." return fallback return await call_reply_assistant_stub(conversation, user_goal) def _user_or_chat_display(obj) -> str | None: """Достаёт display name из User/Chat — что есть.""" if obj is None: return None return ( getattr(obj, "first_name", None) or getattr(obj, "title", None) or getattr(obj, "username", None) or getattr(obj, "name", None) ) def extract_interlocutor(msg) -> str | None: """Извлекает имя/handle собеседника из forwarded message или reply. python-telegram-bot v22 использует новое Bot API 7.0: - forward_origin (MessageOrigin) — единый объект-источник - внутри: sender_user (User) / sender_chat (Chat) / sender_user_name (str) Старые forward_from / forward_from_chat / forward_sender_name сохранены как fallback для v20 и ниже. """ # === Новое API (v22 / Bot API 7.0+) === forward_origin = getattr(msg, "forward_origin", None) if forward_origin is not None: origin_type = getattr(forward_origin, "type", None) # HiddenUser — приватный форвард: имя неполное. if origin_type == "hidden_user": name = getattr(forward_origin, "sender_user_name", None) if name: return name # User — обычный форвард от юзера. if origin_type == "user": sender = _user_or_chat_display(getattr(forward_origin, "sender_user", None)) if sender: return sender # Chat — форвард из чата (группа / супергруппа). if origin_type == "chat": sender = _user_or_chat_display(getattr(forward_origin, "sender_chat", None)) if sender: return sender # Channel — форвард из канала. if origin_type == "channel": sender = _user_or_chat_display(getattr(forward_origin, "chat", None)) if sender: return sender # === Старое API (v20 и ниже, fallback) === forward_from = getattr(msg, "forward_from", None) if forward_from is not None: sender = _user_or_chat_display(forward_from) if sender: return sender forward_sender_name = getattr(msg, "forward_sender_name", None) if forward_sender_name: return forward_sender_name forward_from_chat = getattr(msg, "forward_from_chat", None) if forward_from_chat: sender = _user_or_chat_display(forward_from_chat) if sender: return sender # === Reply-to: чей это message, на который юзер отвечает? === reply_msg = getattr(msg, "reply_to_message", None) if reply_msg is not None: from_user = getattr(reply_msg, "from_user", None) sender_chat = getattr(reply_msg, "sender_chat", None) sender = _user_or_chat_display(from_user) or _user_or_chat_display(sender_chat) if sender: return sender # === Last resort: парсим сам текст (если юзер вставил переписку) === text = getattr(msg, "text", None) or getattr(msg, "caption", None) or "" name = _extract_name_from_text(text) if name: return name return None def detect_forward_direction(msg, current_user_id: int | None) -> str: """Определяет направление форварда: 'sent' (юзер сам отправлял) или 'received' (кто-то писал юзеру). Используется чтобы дать LLM правильную инструкцию: - 'sent' → переписать сообщение юзера в лучшей формулировке. - 'received' → написать ответ юзера собеседнику. """ if not getattr(msg, "forward_date", None) and not getattr(msg, "forward_origin", None): return "unknown" forward_origin = getattr(msg, "forward_origin", None) if forward_origin is not None: origin_type = getattr(forward_origin, "type", None) sender_user = getattr(forward_origin, "sender_user", None) if origin_type == "user" and sender_user is not None: sender_id = getattr(sender_user, "id", None) if sender_id is not None and current_user_id is not None: return "sent" if sender_id == current_user_id else "received" # Old API: forward_from forward_from = getattr(msg, "forward_from", None) if forward_from is not None: sender_id = getattr(forward_from, "id", None) if sender_id is not None and current_user_id is not None: return "sent" if sender_id == current_user_id else "received" # Hidden user, channel, chat origin → assume received return "received" def build_user_prompt(direction: str, interlocutor_hint: str | None, conversation: str, user_goal: str | None) -> str: """Builds the user-prompt for LLM. THE BOT IS A REPLY GENERATOR. It never modifies, rewrites, or reformulates the input messages. It always produces a complete, fresh, ready-to-send message FROM the user TO the interlocutor. The input is CONTEXT — the snippet may be the interlocutor's last message, several messages, or even the user's own draft. The bot treats all of these the same way: as context for generating the user's reply. Direction field is informational only (for the LLM to understand the relationship between user and interlocutor). It does NOT change the bot's task — the bot ALWAYS writes a reply, not a rewrite. """ who = interlocutor_hint or "собеседник" ctx = (conversation or "").strip() # Common rule for ALL directions: the bot must NOT modify the # input, only compose a NEW message. if direction == "sent": # The user forwarded their own message (was outgoing). Most # likely they want a reply to continue the thread, OR a fresh # rephrasing to send again. Either way: WRITE A NEW MESSAGE, # not modify the forwarded text. prompt = ( f"Контекст: пользователь только что отправил(а) {who} сообщение:\n" f"```{ctx}```\n\n" f"Задача: напиши НОВОЕ сообщение от пользователя к {who}. " f"Это либо естественное продолжение разговора (следующая реплика), " f"либо полная переформулировка исходящего в user_voice. " f"В любом случае это НОВЫЙ текст, не правка исходного. " f"Если сообщение было коротким или непонятным — разверни его " f"логично, как юзер реально сказал(а) бы." ) elif direction == "received": prompt = ( f"Контекст: сообщение от {who} пользователю.\n" f"Текст: ```{ctx}```\n\n" f"Задача: напиши ОТВЕТ пользователя {who}. " f"Это полноценное сообщение от первого лица в user_voice. " f"Тон подбери под {who} и суть сообщения. " f"НЕ ПЕРЕПИСЫВАЙ сообщение от {who} — пиши ТОЛЬКО ответ." ) else: # Default: treat the input as the interlocutor's message # (most common case), generate the user's reply. prompt = ( f"Контекст переписки (последнее сообщение — от {who}):\n" f"```{ctx}```\n\n" f"Задача: напиши ОТВЕТ пользователя {who}. " f"Это полноценное сообщение от первого лица в user_voice. " f"НЕ переписывай и не модифицируй входящее сообщение — " f"пиши ТОЛЬКО свой ответ на него. " f"Если это вопрос — дай ответ. Если просьба — отреагируй. " f"Если шутка — поддержи." ) if user_goal: prompt += f"\n\nДополнительная цель: {user_goal}." return prompt async def handle_reply_trigger(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Обработчик trigger-фразы «ответь за меня» в обычном сообщении.""" msg = update.message text = msg.text or "" conversation = extract_conversation(text, msg.reply_to_message) if not conversation: await msg.reply_text( "Пришли переписку, на которую нужен ответ, и в конце добавь «ответь за меня» " "— или просто перешли сообщение от собеседника." ) return user_goal = None person_hint = extract_interlocutor(msg) log.info( "reply trigger from user_id=%s, conversation_len=%d, person_hint=%s", update.effective_user.id, len(conversation), person_hint, ) result = await call_reply_assistant(conversation, user_goal) if person_hint and not result.get("person_handle"): result["person_handle"] = person_hint.lower() await render_result(msg, result, person_hint=person_hint) # Telegram отправляет несколько пересланных сообщений как media group # (одинаковый media_group_id). Если обрабатывать каждый отдельно — пользователь # получит N черновиков подряд. Поэтому копим их и обрабатываем одним заходом # через FORWARD_BATCH_DELAY секунд. FORWARD_BATCH_DELAY = 1.5 async def _delayed_process_batch(app, chat_id, user_key, delay): """Wait `delay` seconds, then process the batch for this user. Replaces job_queue.run_once (which is None in PTB 22.x by default). Uses app.bot_data (not app.user_data) for cross-call storage — app.user_data is keyed by user_id, so nested keys don't survive. """ await asyncio.sleep(delay) batches = app.bot_data.get("fwd_batches") or {} batch = batches.pop(user_key, None) if not batch or not batch["messages"]: return texts = [m["text"] for m in batch["messages"] if m["text"]] person_hint = batch["person_hint"] direction = batch.get("direction", "unknown") current_user_id = batch.get("current_user_id") combined = "\n\n---\n\n".join(texts) last_message_id = batch["messages"][-1]["message_id"] log.info( "processing forward batch: key=%s, count=%d, hint=%s, total_len=%d", user_key, len(texts), person_hint, len(combined), ) class _FakeMsg: def __init__(self, chat_id, message_id): self.chat_id = chat_id self.message_id = message_id async def reply_text(self, *args, **kwargs): return await app.bot.send_message( chat_id=self.chat_id, reply_to_message_id=self.message_id, *args, **kwargs, ) fake_msg = _FakeMsg(chat_id, last_message_id) if not person_hint: await fake_msg.reply_text( "Не вижу, от кого переслано (у всех юзеров приватный форвард).\n\n" "Скажи, кто это, и пришли «ответь за меня» — сделаю черновик.\n\n" "```\n" + combined[:400] + "\n```", parse_mode=ParseMode.MARKDOWN, ) return await _process_and_reply( fake_msg, texts, person_hint, direction=direction, current_user_id=current_user_id, reply_to_message_id=last_message_id, combined=combined, ) async def handle_forwarded_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handler for forwarded messages. ALL forwarded messages are buffered per-user, regardless of media_group_id. After FORWARD_BATCH_DELAY seconds of no new forwards from this user, the whole buffer is processed as ONE dialogue (separate forwards become a sequence of messages). This way: - Multiple forwards of separate messages form one context. - Bot sees the whole dialogue, identifies the last speaker, and drafts ONE reply covering everything. """ msg = update.message text = msg.text or msg.caption or "" current_user_id = update.effective_user.id if update.effective_user else None direction = detect_forward_direction(msg, current_user_id) person_hint = extract_interlocutor(msg) if not text: await msg.reply_text( "Пересланное сообщение без текста. Перешли текстовое сообщение " "или пришли описание того, что тебе написали." ) return # All forwards go into a per-user batch. # Use bot_data (not user_data) — nested keys don't work in user_data. batches = context.application.bot_data.setdefault("fwd_batches", {}) user_key = f"user:{current_user_id}" batch = batches.setdefault( user_key, { "messages": [], "person_hint": None, "direction": direction, "current_user_id": current_user_id, "scheduled": False, }, ) batch["messages"].append({"text": text, "message_id": msg.message_id}) if person_hint and not batch["person_hint"]: batch["person_hint"] = person_hint # Schedule processing if not already scheduled. if not batch["scheduled"]: batch["scheduled"] = True asyncio.create_task( _delayed_process_batch( context.application, update.effective_chat.id, user_key, FORWARD_BATCH_DELAY, ) ) log.info( "forwarded buffered (key=%s, total=%d, hint=%s, dir=%s, len=%d)", user_key, len(batch["messages"]), batch["person_hint"], direction, len(text), ) # ---------- Fallback ---------- async def handle_text_buffer(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handler for plain text messages. Buffers consecutive text messages per-user. After TEXT_BUFFER_DELAY seconds with no new messages, processes the batch as ONE dialogue. If the latest message contains the trigger phrase, processes immediately. This lets the user paste a multi-line conversation in separate messages and get ONE reply covering everything. """ msg = update.message text = msg.text or "" if not text: return current_user_id = update.effective_user.id if update.effective_user else None chat_id = update.effective_chat.id if update.effective_chat else None # If the message has the trigger phrase, process immediately. if REPLY_TRIGGERS.search(text): log.info("trigger phrase found, processing immediately") await handle_reply_trigger(update, context) return # Otherwise, buffer for batch processing. # Use bot_data (not user_data) — nested keys don't work in user_data. batches = context.application.bot_data.setdefault("text_batches", {}) user_key = f"user:{current_user_id}" batch = batches.setdefault( user_key, { "messages": [], "chat_id": chat_id, "current_user_id": current_user_id, "scheduled": False, }, ) batch["messages"].append({"text": text, "message_id": msg.message_id}) if not batch["scheduled"]: batch["scheduled"] = True # Slightly shorter delay for text (user is actively typing). asyncio.create_task( _delayed_process_text_batch( context.application, user_key, FORWARD_BATCH_DELAY, ) ) log.info( "text buffered (key=%s, total=%d, len=%d)", user_key, len(batch["messages"]), len(text), ) TEXT_BUFFER_DELAY = 2.0 async def _delayed_process_text_batch(app, user_key, delay): """Wait delay seconds, then process the text batch for this user.""" await asyncio.sleep(delay) batches = app.bot_data.get("text_batches") or {} batch = batches.pop(user_key, None) if not batch or not batch["messages"]: return texts = [m["text"] for m in batch["messages"] if m["text"]] combined = "\n\n".join(texts) last_message_id = batch["messages"][-1]["message_id"] chat_id = batch.get("chat_id") current_user_id = batch.get("current_user_id") log.info( "processing text batch: key=%s, count=%d, total_len=%d", user_key, len(texts), len(combined), ) class _FakeMsg: def __init__(self, chat_id, message_id): self.chat_id = chat_id self.message_id = message_id async def reply_text(self, *args, **kwargs): return await app.bot.send_message( chat_id=self.chat_id, reply_to_message_id=self.message_id, *args, **kwargs, ) fake_msg = _FakeMsg(chat_id, last_message_id) # Treat as conversation: the last message is typically the user's # response or the latest message in the dialogue. Generate a reply. await _process_and_reply( fake_msg, texts, person_hint=None, direction="unknown", current_user_id=current_user_id, reply_to_message_id=last_message_id, combined=combined, ) async def fallback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text( "Не понял. Напиши /help или пришли переписку с фразой «ответь за меня»." ) # ---------- main ---------- def build_app() -> Application: app = Application.builder().token(TELEGRAM_BOT_TOKEN).build() app.add_handler(CommandHandler(["start", "help"], cmd_start)) app.add_handler(CommandHandler("ping", cmd_ping)) app.add_handler(CommandHandler("status", cmd_status)) # Пересланные сообщения — автотриггер (без «ответь за меня»). # filters.FORWARDED ловит только форварды. app.add_handler( MessageHandler( filters.FORWARDED & (filters.TEXT | filters.CAPTION), handle_forwarded_message, ) ) # Обычные сообщения с trigger-фразой. app.add_handler( MessageHandler( filters.TEXT & ~filters.COMMAND & filters.Regex(REPLY_TRIGGERS), handle_reply_trigger, ) ) # Все остальные текстовые сообщения — в буфер, обрабатываем пачкой. app.add_handler( MessageHandler( filters.TEXT & ~filters.COMMAND, handle_text_buffer, ) ) return app async def on_error(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """Ловим все необработанные исключения, логируем, отвечаем юзеру «ой».""" log.exception("Unhandled exception in handler", exc_info=context.error) try: if update and getattr(update, "effective_message", None): await update.effective_message.reply_text( "⚠️ Что-то сломалось внутри бота. Логи сохранены. " "Попробуй ещё раз или напиши /start." ) except Exception: log.exception("Failed to notify user about error") def main() -> None: log.info("starting EditorAI bot (mode=%s)", "real-LLM" if LLM_API_KEY else "stub") app = build_app() app.add_error_handler(on_error) log.info("polling…") app.run_polling(allowed_updates=Update.ALL_TYPES) async def self_test() -> None: """Прогоняет ReplyAssistant-логику без Telegram. Полезно для отладки. Использование: python bot/main.py --test """ sample = ( "Пётр: ты опять сорвал сроки\n" "Пётр: я уже неделю жду отчёт" ) log.info("self-test: mode=%s", "real-LLM" if LLM_API_KEY else "stub") result = await call_reply_assistant(sample, user_goal="извиниться и пообещать завтра") print("\n--- self-test result ---") print(json.dumps(result, ensure_ascii=False, indent=2)) print("--- end ---\n") # Если LLM_API_KEY есть — ещё один раунд с другим запросом. if LLM_API_KEY: sample2 = "Аня: ты сегодня свободна? хочу в кафешку" result2 = await call_reply_assistant(sample2, user_goal=None) print("\n--- self-test #2 (no explicit goal) ---") print(json.dumps(result2, ensure_ascii=False, indent=2)) print("--- end ---\n") if __name__ == "__main__": import json import sys if "--test" in sys.argv: asyncio.run(self_test()) else: main()