"""Simbiot Harness — без-LLM команды бота (/лестница, /статус, /задачи, /помощь). Секундный уровень (рефлекс, без LLM) отвечает на команды мгновенно — минутный LLM-контекст на них не тратится (t-260929210755): answer_pending() в _reflex_tick находит в инбоксе входящие-команды, хендлер собирает текст из read model, send_reply отправляет. ReplySent закрывает входящее — мин-уровень команду больше не видит (build_context их дополнительно фильтрует на случай гонки). Не отправилось (TG недоступен) — следующий сек-тик попробует снова: ReplySent пишется только после попытки отправки, команда остаётся «без ответа». Регистрация в чате: setMyCommands — кириллические имена; если Bot API кириллицу не принимает, фолбэк на транслит. Вызывается стартом tg-поллера и CLI `cli.py tg-commands`. Алиасы парсер понимает независимо от того, какие имена зарегистрированы. """ import html import json import time from datetime import datetime, timezone from . import es # cooldown после неудачной отправки: сек-тик пробует команды раз в минуту, # а не каждую секунду — лежащий TG не вешает рефлекс на 15с-таймаутах _FAIL_COOLDOWN = 60 _last_fail_ts = 0.0 # каноническое имя → алиасы (регистронезависимые, без слэша) _ALIASES = { "лестница": ("лестница", "ladder", "lestnica"), "статус": ("статус", "status"), "задачи": ("задачи", "tasks", "zadachi"), "помощь": ("помощь", "help", "pomoshch", "start", "начать", "команды", "commands"), } _ALIAS_TO_CMD = {} for _c, _aliases in _ALIASES.items(): for _a in _aliases: _ALIAS_TO_CMD[_a] = _c _DESCRIPTIONS = { "лестница": "Срез лестницы: уровни, занятость, задачи, эскалации", "статус": "Сводка харнеса: тики, задачи, спенд", "задачи": "Доска задач: готово и в работе", "помощь": "Что я умею и как со мной говорить", } # транслит-фолбэк, если Bot API не примет кириллицу в именах команд _LATIN = {"лестница": "lestnica", "статус": "status", "задачи": "zadachi", "помощь": "pomoshch"} def parse(text) -> "str | None": """Сообщение — команда? Возвращает каноническое имя или None. Команда: "/имя" (первый токен; @бот отбрасывается) или ОДИНОЧНОЕ слово-алиас без слэша («лестница»). Фраза с пробелами без слэша — не команда (обычная речь, её слышит минутный LLM-уровень). """ t = (text or "").strip() if not t: return None if t.startswith("/"): tok = t.split()[0].lstrip("/") if "@" in tok: # /cmd@botname — адресация боту tok = tok.split("@", 1)[0] else: if len(t.split()) > 1: return None tok = t tok = tok.strip(".,!?;:«»\"'()").lower() return _ALIAS_TO_CMD.get(tok) def _esc(s) -> str: """TG-отправка идёт с parse_mode=HTML — динамику экранируем.""" return html.escape(str(s or ""), quote=False) def cmd_ladder() -> str: from . import tg_inbox return "⚡️ Срез лестницы:\n\n" + _esc(tg_inbox.ladder_snapshot()) def cmd_status() -> str: model = es.run_projections() cs = model.get("cycle_state") or {} tasks = list((model.get("tasks") or {}).values()) counts = {} for t in tasks: counts[t.get("status") or "?"] = counts.get(t.get("status") or "?", 0) + 1 spend = model.get("spend") or [] tokens = sum(int(e.get("prompt_tokens") or 0) + int(e.get("completion_tokens") or 0) for e in spend) open_esc = sum(1 for e in (model.get("escalations") or {}).values() if isinstance(e, dict) and e.get("status") == "open") lines = [f"📊 Статус харнеса · {datetime.now(timezone.utc).strftime('%d.%m %H:%M')} UTC"] for name in ("sec", "min", "hour", "day"): st = cs.get(name) or {} err = f", ошибок {st['errors']}" if st.get("errors") else "" lines.append(f" {name}: тиков {st.get('ticks', 0)}{err}") parts = [f"{k} {counts[k]}" for k in ("ready", "running", "done", "failed") if counts.get(k)] lines.append("📋 задачи: " + (" · ".join(parts) if parts else "нет")) lines.append(f"🚨 эскалаций открытых: {open_esc}") lines.append(f"💰 спенд (последние {len(spend)} тиков): ~{tokens:,} токенов".replace(",", " ")) return "\n".join(lines) def cmd_tasks() -> str: model = es.run_projections() tasks = list((model.get("tasks") or {}).values()) tasks.sort(key=lambda t: t.get("created") or "", reverse=True) board = [t for t in tasks if t.get("status") in ("ready", "running")] done = sum(1 for t in tasks if t.get("status") == "done") lines = [f"📋 Доска: готово {sum(1 for t in tasks if t.get('status') == 'ready')}" f" · в работе {sum(1 for t in tasks if t.get('status') == 'running')}" f" · сделано {done}"] for t in board[:12]: lines.append(f" [{t.get('size', '?')}] {_esc((t.get('title') or '')[:70])}") if len(board) > 12: lines.append(f" … ещё {len(board) - 12}") lines.append("Выполняют часовой/дневной уровни; детали — на дашборде.") return "\n".join(lines) def cmd_help() -> str: lines = ["🤖 Simbiot — команды (мгновенные, без LLM):"] for c, d in _DESCRIPTIONS.items(): lines.append(f" /{_LATIN[c]} (или /{c}) — {d}") lines.append("") lines.append("Обычный текст и голосовые уходят минутному пульсу (LLM) — отвечу по смыслу.") lines.append("Большую просьбу можно заказать задачей: результат придёт в чат сам, когда будет готов.") return "\n".join(lines) _HANDLERS = { "лестница": cmd_ladder, "статус": cmd_status, "задачи": cmd_tasks, "помощь": cmd_help, } def answer(msg_id: str, cmd: str) -> str: """Собирает ответ команды и отправляет. ReplySent закрывает входящее. Команда — не задача и не LLM-тик: это рефлекс. CommandHandled (после успешной отправки) — только наблюдаемость: видно, что входящее закрыто именно командой, а не минутным триажем. """ from . import tg_inbox text = _HANDLERS[cmd]() # короткий таймаут: сек-тик не должен висеть на TG дольше пары секунд tg_inbox.send_reply(msg_id, text, timeout=15) try: es.append_event("inbox", msg_id, "CommandHandled", { "command": cmd, "level": "sec", "handler": "tg_commands", }) except Exception: pass # событие — наблюдаемость; ReplySent уже закрыл входящее return text def answer_pending(model: "dict | None" = None): """Отвечает на все входящие-команды без ответа. [(msg_id, cmd), …]. Идемпотентно: replied-сообщения пропускаются (проекция свежая — send_reply сам перепроверяет по стору и кинет ValueError при гонке с минутным уровнем — это не ошибка, просто пропускаем). """ if model is None: model = es.run_projections() # есть ли вообще команды-кандидаты: если нет — cooldown не проверяем pending = [m for m in (model.get("inbox") or {}).values() if isinstance(m, dict) and not m.get("replied") and not m.get("ignored") and parse(m.get("text") or "")] if not pending: return [] global _last_fail_ts if time.time() - _last_fail_ts < _FAIL_COOLDOWN: return [] # недавно не смогли отправить — ждём, рефлекс не висит на TG handled = [] for m in pending: mid = m.get("id") or (f"m-{m.get('update_id')}" if m.get("update_id") else None) if not mid: continue cmd = parse(m.get("text") or "") try: answer(mid, cmd) handled.append((mid, cmd)) except ValueError: pass # уже отвечено (гонка) — не ошибка except Exception as e: # TG недоступен и пр. — НЕ отвечено; ставим cooldown и повторим # не раньше чем через минуту (рефлекс тикает каждую секунду) _last_fail_ts = time.time() print(f"[tg-cmd] {mid} /{cmd} не отвечено: {e} " f"(cooldown {_FAIL_COOLDOWN}с)", flush=True) return handled # ────────────── регистрация в чате (setMyCommands) ────────────── def commands_spec(latin: bool = False) -> list: return [{"command": (_LATIN[c] if latin else c), "description": d} for c, d in _DESCRIPTIONS.items()] def register_bot_commands(): """setMyCommands: сначала кириллица, при отказе — транслит. Возвращает 'cyrillic' | 'latin' | None (нет токена/не удалось). Повторный вызов идемпотентен — перезапишет список тем же содержимым. """ from . import tg_inbox tg_inbox._load_env() if not tg_inbox.TG_TOKEN: return None try: r = tg_inbox._api("setMyCommands", {"commands": json.dumps(commands_spec(), ensure_ascii=False)}) if r.get("ok"): return "cyrillic" raise RuntimeError(str(r)[:200]) except Exception as e: print(f"[tg-cmd] setMyCommands: кириллица не принята ({e}) — пробую транслит", flush=True) try: r = tg_inbox._api("setMyCommands", {"commands": json.dumps(commands_spec(latin=True), ensure_ascii=False)}) return "latin" if r.get("ok") else None except Exception: return None def get_my_commands() -> list: """Текущий список команд у бота ([(name, description), …]).""" from . import tg_inbox tg_inbox._load_env() if not tg_inbox.TG_TOKEN: return [] try: r = tg_inbox._api("getMyCommands", {}) return [(c.get("command"), c.get("description")) for c in r.get("result", [])] except Exception: return []