fix(tg): send_reply грузит .env до sendChatAction (свежий процесс терял индикатор); safe-restart: пост-рестарт верификация сек-тика на команде; tests/test_tg_commands.py (41 проверка)
This commit is contained in:
parent
5935ece4ec
commit
ec7fe76734
3 changed files with 225 additions and 1 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,39 @@ if systemctl restart "$SERVICE"; then
|
||||||
else
|
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
187
tests/test_tg_commands.py
Normal 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 + ")")
|
||||||
Loading…
Reference in a new issue