"""Simbiot Harness — Telegram-инбокс. Входящие сообщения Алекса → события MessageReceived в стор → минутный уровень видит их в контексте и может ответить через `cli.py tg-reply ` (событие ReplySent закрывает входящее). Поллер — отдельный процесс (systemd simbiot-harness-tg), long polling getUpdates offset=last+1. Идемпотентность: update_id → aggregate_id. """ import json import os import sys import time import urllib.request import urllib.parse import urllib.error from . import es _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _ENV_PATH = os.path.join(_HARNESS_DIR, ".env") TG_TOKEN = None ALLOWED = set() def _load_env(): global TG_TOKEN, ALLOWED if os.path.exists(_ENV_PATH): with open(_ENV_PATH) as f: for line in f: line = line.strip() if line.startswith("SIMBIOT_TG_TOKEN="): TG_TOKEN = line.split("=", 1)[1].strip() elif line.startswith("SIMBIOT_TG_ALLOWED="): ALLOWED = {x.strip() for x in line.split("=", 1)[1].split(",") if x.strip()} def _api(method: str, params: dict, timeout: int = 35): url = f"https://api.telegram.org/bot{TG_TOKEN}/{method}" data = urllib.parse.urlencode(params).encode() req = urllib.request.Request(url, data=data) with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read().decode()) def send_message(chat_id, text: str, reply_to=None, timeout: int = 35): """Отправка сообщения (используется tg-reply и алертами). timeout: сек-тик (без-LLM команды) передаёт короткий — рефлекс не должен висеть на TG дольше пары секунд. """ _load_env() params = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"} if reply_to: params["reply_to_message_id"] = reply_to try: return _api("sendMessage", params, timeout=timeout) except urllib.error.HTTPError as e: # 400 от кривого HTML (незакрытый тег, «a < b» в тексте) — шлём как plain text. # Иначе ответ/доставка молча теряются: исключение глотается на верхнем уровне. if e.code == 400: params.pop("parse_mode", None) return _api("sendMessage", params, timeout=timeout) raise def receive_message(update: dict): """MessageReceived в стор. Идемпотентно по update_id.""" msg = update.get("message") or update.get("edited_message") or {} chat = msg.get("chat") or {} user = msg.get("from") or {} # нетекстовые сообщения: помечаем, что это (стикер/фото/голос/…), # иначе пустой text выглядит как «пустое сообщение» и триаж не понимает, что случилось kind = "text" if msg.get("sticker"): kind = "sticker" elif msg.get("photo"): kind = "photo" elif msg.get("voice"): kind = "voice" elif msg.get("video_note") or msg.get("video"): kind = "video" elif msg.get("audio"): kind = "audio" elif msg.get("document"): kind = "document" elif msg.get("animation"): kind = "animation" # file_id нужен, чтобы потом скачать файл (голосовое → транскрипция, фото → OCR) file = (msg.get("voice") or msg.get("audio") or msg.get("document") or msg.get("video") or msg.get("video_note") or {}) file_id = file.get("file_id") duration = file.get("duration") or file.get("duration_seconds") photo = msg.get("photo") if photo and not file_id: file_id = photo[-1].get("file_id") # самый большой размер data = { "update_id": update["update_id"], "chat_id": chat.get("id"), "user_id": user.get("id"), "username": user.get("username"), "first_name": user.get("first_name"), "text": (msg.get("text") or msg.get("caption") or "")[:4000], "kind": kind, "file_id": file_id, # file_unique_id: ключ аудио-кэша и идемпотентности расшифровки # (file_id ротируется, unique — постоянный для одного файла) "file_unique_id": file.get("file_unique_id") or (photo[-1].get("file_unique_id") if photo else None), "duration": duration, "date": msg.get("date"), } es.append_event("inbox", f"m-{update['update_id']}", "MessageReceived", data) return f"m-{update['update_id']}" def send_reply(msg_id: str, text: str, timeout: int = 35): """Ответ на входящее: Telegram-отправка + событие ReplySent (закрывает входящее). Перед отправкой шлём sendChatAction typing — в TG видно «печатает…». sendChatAction живёт ~5с и НЕ обновляется сам — этого достаточно для минутного пульса (типичный лаг ответа < 5с после начала отправки). timeout — как в send_message (короткий для сек-рефлекса). """ model = es.run_projections() m = (model.get("inbox") or {}).get(msg_id) if m is None: raise ValueError(f"входящее {msg_id} не найдено") if m.get("replied"): raise ValueError(f"на {msg_id} уже отвечено") _load_env() # sendChatAction идёт до send_message: в свежем процессе # (демон/сек-рефлекс) токен ещё не загружен — иначе «печатает…» молча теряется try: # индикатор — best effort: недоступность TG-метода не мешает ответу _api("sendChatAction", {"chat_id": m["chat_id"], "action": "typing"}, timeout=10) except Exception: pass r = send_message(m["chat_id"], text, reply_to=None, timeout=timeout) es.append_event("inbox", msg_id, "ReplySent", { "to_chat": m["chat_id"], "text": text[:2000], "tg_ok": bool(r.get("ok")), }) return msg_id def send_batch_reply(msg_ids: list, text: str, timeout: int = 35): """Сводный ответ на БАТЧ входящих (t-260930064924): одна Telegram-отправка закрывает несколько сообщений одного разговора. Минутный тик собирает ВСЕ неотвеченные сообщения разом (секция инбокса) и отвечает одним сводным текстом — вместо по одному за тик (m-516173222..228 обрабатывались 7 тиками подряд). Событие ReplySent пишем на КАЖДОЕ закрываемое входящее (проекция ставит replied=True) — сводный ответ не должен оставлять «хвостов», на которые гейт важности потом будит минуту снова. Полный текст храним у головного (первого по ts), у остальных — ссылку на него, чтобы не дублировать текст в сторе. Сообщения разных чатов не смешиваются: батч делится по chat_id, каждому чату — своя отправка (сводный текст один, белый список — не только личка). Идемпотентность: перед отправкой перечитываем read model; уже отвеченные (гонка с сек-рефлексом/ручным tg-reply) выпадают из батча. Если выпали все — ValueError (тихий пропуск, не ошибка тика). """ model = es.run_projections() inbox = model.get("inbox") or {} pending = [] for mid in msg_ids: m = inbox.get(mid) if m is None: raise ValueError(f"входящее {mid} не найдено") if m.get("replied") or m.get("ignored"): continue # гонка: уже закрыто/чужое — не трогаем pending.append((mid, m)) if not pending: raise ValueError("на все сообщения батча уже отвечено") _load_env() head_mid = pending[0][0] batch_id = f"b-{pending[0][1].get('update_id')}-{len(pending)}" by_chat = {} for mid, m in pending: by_chat.setdefault(m["chat_id"], []).append((mid, m)) for chat_id, items in by_chat.items(): try: # индикатор — best effort, как в send_reply _api("sendChatAction", {"chat_id": chat_id, "action": "typing"}, timeout=10) except Exception: pass r = send_message(chat_id, text, reply_to=None, timeout=timeout) for mid, m in items: es.append_event("inbox", mid, "ReplySent", { "to_chat": chat_id, "text": (text if mid == head_mid else f"(сводный ответ батча {batch_id} — см. {head_mid})")[:2000], "tg_ok": bool(r.get("ok")), "batch": {"id": batch_id, "size": len(pending), "head": head_mid}, }) return head_mid def poll_loop(): """Long polling getUpdates → MessageReceived. Отдельный процесс.""" _load_env() if not TG_TOKEN: raise SystemExit("SIMBIOT_TG_TOKEN не задан в .env") es.init_db() # (t-260929210755) список команд бота — виден в чате как меню "/": # регистрируем при старте поллера (идемпотентно) и после рестартов. try: from . import tg_commands mode = tg_commands.register_bot_commands() print(f"[tg] команды бота зарегистрированы ({mode})", flush=True) except Exception as e: print(f"[tg] регистрация команд не удалась: {e}", flush=True) offset = 0 print("[tg] инбокс-поллер запущен", flush=True) while True: try: updates = _api("getUpdates", {"offset": offset, "timeout": 30, "limit": 16}) for u in updates.get("result", []): offset = max(offset, u["update_id"] + 1) msg = u.get("message") or u.get("edited_message") if not msg: continue user = (msg.get("from") or {}).get("id") if ALLOWED and str(user) not in ALLOWED: # чужой: пишем событие, но не отвечаем (потенциально спам) es.append_event("inbox", f"m-{u['update_id']}", "MessageIgnored", { "user_id": user, "username": (msg.get("from") or {}).get("username"), "reason": "not_allowed", "text": (msg.get("text") or "")[:500], }) continue mid = receive_message(u) print(f"[tg] {mid} от {(msg.get('from') or {}).get('username') or user}: " f"{(msg.get('text') or '')[:60]}", flush=True) # голосовые/аудио/видео-заметки: расшифровка сразу при приёме, # чтобы минутный уровень уже видел текст (не качает файлы сам — # таймаут тика мал). Сбой транскрипции не валит поллер. fobj = (msg.get("voice") or msg.get("audio") or msg.get("video_note")) if fobj: try: from . import voice kind = ("voice" if msg.get("voice") else "audio" if msg.get("audio") else "video_note") text, engine = voice.transcribe_message( mid, file_id=fobj.get("file_id") or "", file_unique_id=fobj.get("file_unique_id") or "", duration=fobj.get("duration"), kind=kind, ) print(f"[tg] {mid} транскрипция ({engine}): {(text or '')[:80]}", flush=True) except Exception as e: print(f"[tg] {mid} ошибка транскрипции: {e}", flush=True) except Exception as e: print(f"[tg] ошибка поллинга: {e}", flush=True) time.sleep(5) def notify_chat(text: str): """Отправить сообщение в чат харнеса (без входящего-родителя — уведомления). Чат берём из последнего входящего MessageReceived (это личка Алекса); если входящих ещё не было — тихо пропускаем (некому показывать). """ _load_env() if not TG_TOKEN: return None model = es.run_projections() inbox = [m for m in (model.get("inbox") or {}).values() if m.get("chat_id") and not m.get("ignored")] if not inbox: return None # некому: чат ещё не известен chat_id = sorted(inbox, key=lambda m: m.get("ts") or "")[-1]["chat_id"] try: return send_message(chat_id, text) except Exception: return None # уведомление — best effort, не валить тик def ladder_snapshot() -> str: """Срез лестницы для Telegram: уровни, занятость, задачи, эскалации. Занятость: busy в read model всегда False (истинное состояние живёт в памяти планировщика, событий для него нет), поэтому выводим её из running-задач: TaskClaimed закрепляет уровень за задачей в проекции. Уровень без задачи (чистый дайджест-тик) здесь выглядит как «ожидает» — приемлемо: дайджест-тик короткий, а взятие задачи — главный кейс. """ model = es.run_projections() cs = model.get("cycle_state") or {} tasks_all = list((model.get("tasks") or {}).values()) running_levels = {t.get("level") for t in tasks_all if t.get("status") == "running"} order = ["sec", "min", "hour", "day"] lines = [] for name in order: st = cs.get(name) or {} if name in running_levels: busy = "🔧 занят (running-задача)" else: busy = "ожидает" lines.append(f"{'🔹' if name != 'sec' else '⚡'} {name}: {busy}, тиков {st.get('ticks', 0)}" + (f", ошибок {st['errors']}" if st.get("errors") else "")) tasks = tasks_all tasks.sort(key=lambda t: t.get("created") or "", reverse=True) board = [t for t in tasks if t.get("status") in ("ready", "running")] lines.append(f"📋 готово/в работе: {sum(1 for t in tasks if t['status'] == 'ready')}/" f"{sum(1 for t in tasks if t['status'] == 'running')}") for t in board[:8]: lines.append(f" [{t['size']}] {t['status']}: {t['title'][:60]}") open_esc = [e for e in (model.get("escalations") or {}).values() if isinstance(e, dict) and e.get("status") == "open"] lines.append(f"🚨 открытых эскалаций: {len(open_esc)}") for e in open_esc[:5]: lines.append(f" [{e.get('severity', 'info')}] {e.get('reason', '')[:80]}") return "\n".join(lines) if __name__ == "__main__": # Единственный легитимный поллер — systemd-сервис simbiot-harness-tg. # `python -m harness.tg_inbox` с любыми аргументами (--help, list, …) # раньше поднимал второго поллера → 409 Conflict в легитимном. if len(sys.argv) > 1: print("usage: python -m harness.tg_inbox # long-poll поллер (systemd)") print(" cli.py ladder # срез лестницы (текст)") print(" cli.py tg-notify # отправка в чат харнеса") sys.exit(0) poll_loop()