diff --git a/harness/tg_inbox.py b/harness/tg_inbox.py index df99ccd..5305550 100644 --- a/harness/tg_inbox.py +++ b/harness/tg_inbox.py @@ -128,6 +128,8 @@ def send_reply(msg_id: str, text: str, timeout: int = 35): 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: diff --git a/scripts/safe-restart.sh b/scripts/safe-restart.sh index 1dc16d8..6f1dd0f 100755 --- a/scripts/safe-restart.sh +++ b/scripts/safe-restart.sh @@ -36,4 +36,39 @@ if systemctl restart "$SERVICE"; then else echo "[$(date -u '+%F %T')] safe-restart: FAIL — systemctl restart ${SERVICE}" >> "$LOG" exit 1 -fi \ No newline at end of file +fi + +# ── пост-рестарт верификация: сек-тик НОВОГО демона отвечает на команду без LLM ── +# (инъекция синтетического сообщения в стор; через N секунд в сторе должен быть +# ReplySent от сек-тика — это доказывает, что новый код жив в продакшене) +sleep 6 # демон поднимается, первые сек-тики проходят +VERIFY_MSG_ID="${SIMBIOT_VERIFY_MSG_ID:-990102}" +cd /root/simbiot-harness || exit 1 +./venv/bin/python - "$VERIFY_MSG_ID" <<'EOF' >> "$LOG" 2>&1 +import sys, time, sqlite3 +mid_num = sys.argv[1] +from harness import es, tg_inbox +mid = tg_inbox.receive_message({'update_id': int(mid_num), 'message': { + 'message_id': int(mid_num), + 'chat': {'id': 7569223131}, + 'from': {'id': 7569223131, 'username': 'alex_test'}, + 'text': '/лестница', 'date': int(time.time())}}) +print(f"verify: инъекция {mid} (/лестница), ждём ответ сек-тика демона...") +time.sleep(8) +db = sqlite3.connect(es.DB_PATH); db.row_factory = sqlite3.Row +rows = db.execute("SELECT event_type, data FROM events WHERE aggregate_id=? ORDER BY id", (mid,)).fetchall() +types = [r["event_type"] for r in rows] +ok = "ReplySent" in types and "CommandHandled" in types +for r in rows: + print(f"verify: {r['event_type']} → {r['data'][:120]}") +print(f"verify: {'OK — сек-тик нового демона ответил на команду без LLM' if ok else 'FAIL — ответа нет, смотреть журнал демона'}") +sys.exit(0 if ok else 2) +EOF +rc=$? +if [ "$rc" -eq 0 ]; then + echo "[$(date -u '+%F %T')] safe-restart: верификация ПРОЙДЕНА (сек-тик отвечает на команды)" >> "$LOG" +elif [ "$rc" -eq 2 ]; then + echo "[$(date -u '+%F %T')] safe-restart: верификация ПРОВАЛЕНА — смотри журнал демона!" >> "$LOG" + journalctl -u "$SERVICE" --since "2 minutes ago" --no-pager | tail -20 >> "$LOG" +fi +exit "$rc" \ No newline at end of file diff --git a/tests/test_tg_commands.py b/tests/test_tg_commands.py new file mode 100644 index 0000000..06e7f41 --- /dev/null +++ b/tests/test_tg_commands.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +"""t-260929210755: офлайн-тесты команд бота (hermetic: копия БД, TG мокнут). +Запуск: ./venv/bin/python tests/test_tg_commands.py""" +"""Офлайн-тесты t-260929210755 (hermetic: копия БД, реальный TG не трогаем). + +Парсер команд, хендлеры, фильтр build_context (команды мин-уровню не видны), +answer_pending (мок send_reply: успех / гонка-ValueError / ошибка TG), +_reflex_tick (ReflexObserved + payload.commands). +""" +import json +import os +import re +import sqlite3 +import sys +import time as _t + +# ── изоляция: копия боевой БД через online-backup (WAL-консистентно) ── +_SRC = "/root/simbiot-harness/harness/harness.db" +_DST = "/tmp/simbiot-test-tg-commands.db" +for p in (_DST, _DST + "-wal", _DST + "-shm"): + if os.path.exists(p): + os.unlink(p) +s, d = sqlite3.connect(_SRC), sqlite3.connect(_DST) +s.backup(d) +d.close(); s.close() +os.environ["SIMBIOT_DB"] = _DST +sys.path.insert(0, "/root/simbiot-harness") + +from harness import es, tg_commands, tg_inbox +from harness.scheduler import build_context, _reflex_tick, LEVELS + +fails = [] + + +def check(name, cond, extra=""): + print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "") + if not cond: + fails.append(name) + + +# ── 1. парсер: алиасы, регистр, слэш, @бот, пунктуация, не-команды ── +P = tg_commands.parse +check("parse /лестница", P("/лестница") == "лестница") +check("parse ЛЕСТНИЦА верхний регистр", P("/ЛЕСТНИЦА") == "лестница") +check("parse /лестница@juno42bot", P("/лестница@juno42bot") == "лестница") +check("parse ladder транслит", P("/ladder") == "лестница") +check("parse lestnica", P("/lestnica") == "лестница") +check("parse /статус", P("/статус") == "статус") +check("parse /status", P("/status") == "статус") +check("parse /задачи", P("/задачи") == "задачи") +check("parse /tasks", P("/tasks") == "задачи") +check("parse /помощь", P("/помощь") == "помощь") +check("parse /help", P("/help") == "помощь") +check("parse /start", P("/start") == "помощь") +check("parse слово без слэша", P("лестница") == "лестница") +check("parse слово без слэша + пунктуация", P("Лестница!") == "лестница") +check("parse фраза без слэша — НЕ команда", P("лестница покажи") is None) +check("parse обычная речь", P("привет, как дела?") is None) +check("parse пусто", P("") is None) +check("parse /неизвестная", P("/фыва") is None) +check("parse /cmd с аргументом", P("/status сейчас") == "статус") + +# ── 2. хендлеры: непустой текст, без исключений, без сырого HTML-рискa ── +es.init_db() +for cmd, fn in tg_commands._HANDLERS.items(): + try: + text = fn() + check(f"handler {cmd} непустой", bool(text and text.strip())) + except Exception as e: + check(f"handler {cmd} непустой", False, repr(e)) +print("--- /статус ---\n" + tg_commands.cmd_status()) +print("--- /помощь ---\n" + tg_commands.cmd_help()) + +# ── 3. фильтр build_context: команда НЕ попадает мин-уровню ── +model = es.run_projections() +fake = json.loads(json.dumps(model)) +fake.setdefault("inbox", {}) +# ключи — как у реальных сообщений: aggregate_id = f"m-{update_id}" +fake["inbox"]["m-990001"] = { + "update_id": 990001, "chat_id": 1, "text": "/лестница", "kind": "text", + "replied": False, "ignored": False, "ts": "2026-09-30T02:10:00+00:00", +} +fake["inbox"]["m-990002"] = { + "update_id": 990002, "chat_id": 1, "text": "привет, собери дайджест", "kind": "text", + "replied": False, "ignored": False, "ts": "2026-09-30T02:11:00+00:00", +} +ctx = build_context(LEVELS["min"], fake) +m = re.search(r"ВХОДЯЩИЕ СООБЩЕНИЯ.*?(?:\n\n|$)", ctx, re.S) +inbox_sec = m.group(0) if m else "" +check("build_context: секция инбокса есть", bool(inbox_sec)) +check("build_context: команда НЕ в инбоксе мин-уровня", "m-990001" not in inbox_sec) +check("build_context: /лестница НЕ в секции инбокса", "/лестница" not in inbox_sec) +check("build_context: обычное сообщение в инбоксе", "m-990002" in inbox_sec) +check("build_context: текст обычного сообщения виден", "собери дайджест" in inbox_sec) +# на доске задач /лестница встречается в ЗАГОЛОВКЕ текущей задачи — это не инбокс +check("build_context: доска задач не задета фильтром", "ДОСКА ЗАДАЧ" in ctx) + +# ── 4. answer_pending: успех / гонка / ошибка отправки ── +sent = [] +tg_inbox.send_reply = lambda msg_id, text, timeout=35: (sent.append((msg_id, text)) or msg_id) +tg_inbox.send_message = lambda *a, **k: {"ok": True} + +f2 = json.loads(json.dumps(fake)) +h = tg_commands.answer_pending(f2) +check("answer_pending: 1 команда отвечена", len(h) == 1 and h[0] == ("m-990001", "лестница"), str(h)) +check("answer_pending: отправка была", len(sent) == 1) +f2["inbox"]["m-990001"]["replied"] = True # ReplySent закрыл +sent.clear() +h2 = tg_commands.answer_pending(f2) +check("answer_pending: replied пропускается", h2 == [] and sent == []) + +# гонка: мин-уровнь ответил первым → send_reply кидает ValueError → тихий пропуск +sent.clear() +tg_inbox.send_reply = lambda msg_id, text, timeout=35: (_ for _ in ()).throw( + ValueError("на m-990001 уже отвечено")) +h3 = tg_commands.answer_pending(json.loads(json.dumps(fake))) +check("answer_pending: гонка (ValueError) → пропуск без падения", h3 == [] and sent == []) + +# ошибка TG: не отвечено, без падения, команда остаётся «без ответа» +sent.clear() +tg_inbox.send_reply = lambda msg_id, text, timeout=35: (_ for _ in ()).throw( + OSError("TG unreachable")) +h4 = tg_commands.answer_pending(json.loads(json.dumps(fake))) +check("answer_pending: ошибка TG → неотвечено, без падения", h4 == [] and sent == []) + +# cooldown: сразу после ошибки повтор не дергает TG (рефлекс тикает каждую секунду) +sent.clear() +h5 = tg_commands.answer_pending(json.loads(json.dumps(fake))) +check("answer_pending: cooldown — повтор не раньше минуты, без отправки", + h5 == [] and sent == []) +# и это не «молчание навсегда»: после истечения cooldown попытка повторяется +tg_commands._last_fail_ts = _t.time() - 61 +tg_inbox.send_reply = lambda msg_id, text, timeout=35: (sent.append((msg_id, text)) or msg_id) +h6 = tg_commands.answer_pending(json.loads(json.dumps(fake))) +check("answer_pending: после cooldown попытка повторяется", len(h6) == 1, str(h6)) +# сброс: сек-тик ниже гоняем в нормальном режиме +tg_commands._last_fail_ts = 0.0 + +# ── 5. _reflex_tick: ReflexObserved пишется, команды в payload, идемпотентность ── +import time as _t +cfg = LEVELS["sec"] +# seq в будущем: reflex: под partial UNIQUE-индексом cycle-агрегата; +# текущая секунда может коллизить с реальным сек-тиком, пойманным в копию БД +seq = int(_t.time()) + 3600 + +# восстановливаем success-мок (выше проверяли ошибки — теперь нормальный путь) +sent.clear() +tg_inbox.send_reply = lambda msg_id, text, timeout=35: (sent.append((msg_id, text)) or msg_id) + +# изолированное входящее для сек-тика: свой id → CommandHandled только отсюда +fake3 = json.loads(json.dumps(model)) +fake3.setdefault("inbox", {}) +fake3["inbox"]["m-990003"] = { + "update_id": 990003, "chat_id": 1, "text": "/status", "kind": "text", + "replied": False, "ignored": False, "ts": "2026-09-30T02:20:00+00:00", +} + +before = es.count_events() +payload, task_id = _reflex_tick(cfg, seq, json.loads(json.dumps(fake3))) +check("_reflex_tick: ReflexObserved + CommandHandled (2 события)", + es.count_events() == before + 2, str(es.count_events() - before)) +check("_reflex_tick: команда в payload", payload.get("commands") == ["статус"], str(payload.get("commands"))) +check("_reflex_tick: телеметрия на месте", payload.get("load1") is not None) + +# производственная семантика повтора: ReplySent закрыл входящее (replied=True +# выставляет проекция ReplySent — мок send_reply этого не делает, симулируем), +# второй вызов тем же seq: reflex — дубль под UNIQUE, команда — replied → 0 событий +fake3["inbox"]["m-990003"]["replied"] = True +sent.clear() +before2 = es.count_events() +_reflex_tick(cfg, seq, json.loads(json.dumps(fake3))) +check("_reflex_tick: повтор того же seq (replied) — событий 0", + es.count_events() == before2, str(es.count_events() - before2)) + +# CommandHandled: ровно 1 на успешный ответ сек-тика (id изолирован) +db = es._conn() +rows = db.execute( + "SELECT COUNT(*) c FROM events WHERE event_type='CommandHandled' " + "AND aggregate_id='m-990003'").fetchone() +db.close() +check("CommandHandled: 1 событие на успешный ответ", rows["c"] == 1, str(rows["c"])) + +print() +if fails: + print("ПРОВАЛЕНО:", fails) + sys.exit(1) +print("ВСЕ ТЕСТЫ ПРОЙДЕНЫ (hermetic, БД:", _DST + ")") \ No newline at end of file