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} не найдено")
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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