fix(tg): send_reply грузит .env до sendChatAction (свежий процесс терял индикатор); safe-restart: пост-рестарт верификация сек-тика на команде; tests/test_tg_commands.py (41 проверка)

This commit is contained in:
Simbiot Harness 2026-09-30 02:42:09 +00:00
parent 5935ece4ec
commit ec7fe76734
3 changed files with 225 additions and 1 deletions

View file

@ -128,6 +128,8 @@ def send_reply(msg_id: str, text: str, timeout: int = 35):
raise ValueError(f"входящее {msg_id} не найдено") raise ValueError(f"входящее {msg_id} не найдено")
if m.get("replied"): if m.get("replied"):
raise ValueError(f"на {msg_id} уже отвечено") raise ValueError(f"на {msg_id} уже отвечено")
_load_env() # sendChatAction идёт до send_message: в свежем процессе
# (демон/сек-рефлекс) токен ещё не загружен — иначе «печатает…» молча теряется
try: # индикатор — best effort: недоступность TG-метода не мешает ответу try: # индикатор — best effort: недоступность TG-метода не мешает ответу
_api("sendChatAction", {"chat_id": m["chat_id"], "action": "typing"}, timeout=10) _api("sendChatAction", {"chat_id": m["chat_id"], "action": "typing"}, timeout=10)
except Exception: except Exception:

View file

@ -37,3 +37,38 @@ else
echo "[$(date -u '+%F %T')] safe-restart: FAIL — systemctl restart ${SERVICE}" >> "$LOG" echo "[$(date -u '+%F %T')] safe-restart: FAIL — systemctl restart ${SERVICE}" >> "$LOG"
exit 1 exit 1
fi 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"

187
tests/test_tg_commands.py Normal file
View file

@ -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:<seq> под 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 + ")")