# -*- coding: utf-8 -*- """TG в группах (аудит 2026-10-17, «реплаю Коди — не реагирует»). Hermetic. Запуск: ./venv/bin/python tests/test_tg_group_ux.py Проверяет: setMessageReaction шлёт reaction МАССИВОМ (было reaction_type одиночным объектом → REACTION_EMPTY в группе, пустая реакция в личке); ответ в группе — reply на сообщение (батч — на последнее), с allow_sending_without_reply; в личке — без цитаты; досылка итога в группе — тоже reply; правка уже принятого сообщения распознаётся как не новое. """ import json import os import sys import tempfile sys.path.insert(0, "/root/simbiot-harness") os.environ["SIMBIOT_DB"] = os.path.join(tempfile.mkdtemp(prefix="simbiot_grpux_"), "harness.db") from harness import es, tg_inbox es.init_db() fails = [] def check(name, cond, extra=""): print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "") if not cond: fails.append(name) API = [] tg_inbox._load_env = lambda: None tg_inbox._api = lambda method, params, timeout=35: API.append((method, dict(params))) or {"ok": True, "result": True} tg_inbox.set_reaction(-100, 7) meth, prm = API[-1] check("реакция: метод setMessageReaction", meth == "setMessageReaction") check("реакция: параметр reaction — массив ReactionType", json.loads(prm.get("reaction", "null")) == [{"type": "emoji", "emoji": "👀"}], prm) check("реакция: нет мусорного reaction_type", "reaction_type" not in prm, prm) G, P = -1004402779881, 7569223131 for uid, chat, ct, mid, ts in ((1, G, "supergroup", 2420, "2026-10-17T07:57:30"), (2, G, "supergroup", 2428, "2026-10-17T07:57:31"), (3, P, "private", 4950, "2026-10-17T07:57:32"), (4, G, "supergroup", 2500, "2026-10-17T08:00:00")): es.append_event("inbox", f"m-{uid}", "MessageReceived", { "update_id": uid, "chat_id": chat, "chat_type": ct, "message_id": mid, "text": "вопрос", "kind": "text", "first_name": "Alex"}) def sent(): return [p for m, p in API if m == "sendMessage"][-1] API.clear() tg_inbox.send_batch_reply(["m-1", "m-2"], "сводный ответ") s = sent() check("батч в группе: reply на последнее сообщение", s.get("reply_to_message_id") == 2428, s) check("reply не теряется, если цель удалена", s.get("allow_sending_without_reply") == "true", s) tg_inbox.send_reply("m-3", "ответ в личку") check("личка: без цитаты", "reply_to_message_id" not in sent(), sent()) tg_inbox.send_reply("m-4", "ответ в группу") check("одиночный ответ в группе: reply", sent().get("reply_to_message_id") == 2500, sent()) tg_inbox.send_followup("m-4", "итог работы") check("досылка в группе: reply на исходный вопрос", sent().get("reply_to_message_id") == 2500 and sent().get("text") == "итог работы", sent()) check("правка принятого сообщения — не новое", tg_inbox._already_received({"chat": {"id": G}, "message_id": 2428})) check("новое сообщение — новое", not tg_inbox._already_received({"chat": {"id": G}, "message_id": 9999})) # ── 👍 на отвеченное (правка Коди, t-261018103940: ✅ Telegram не принимает) ── def reactions(): return [p for m, p in API if m == "setMessageReaction"] for uid, mid in ((10, 3001), (11, 3002), (12, 3003)): es.append_event("inbox", f"m-{uid}", "MessageReceived", { "update_id": uid, "chat_id": P, "chat_type": "private", "message_id": mid, "text": "вопрос", "kind": "text", "first_name": "Alex"}) API.clear() tg_inbox.send_reply("m-10", "ответ") r = reactions() check("ответ → 👍 на это сообщение", r and r[-1]["message_id"] == 3001 and json.loads(r[-1]["reaction"]) == [{"type": "emoji", "emoji": "👍"}], r) API.clear() tg_inbox.send_batch_reply(["m-11", "m-12"], "сводный") check("батч → 👍 на каждое сообщение", sorted(p["message_id"] for p in reactions()) == [3002, 3003]) es.append_event("inbox", "m-13", "MessageReceived", {"update_id": 13, "chat_id": "portal:host", "text": "x", "kind": "text"}) API.clear() tg_inbox.mark_answered({"chat_id": "portal:host", "message_id": 1}) check("портал — без реакции", not reactions()) es.append_event("inbox", "m-14", "MessageReceived", {"update_id": 14, "chat_id": P, "chat_type": "private", "message_id": 3004, "text": "вопрос", "kind": "text"}) _orig = tg_inbox.set_reaction tg_inbox.set_reaction = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("TG 400")) try: tg_inbox.send_reply("m-14", "ответ при упавшей реакции") ok = es.run_projections()["inbox"]["m-14"].get("replied") is True except Exception as e: ok = False check("сбой реакции не ломает ответ", ok) tg_inbox.set_reaction = _orig # ── мусорный итог задачи не принимается (2026-10-18: «x» ушёл Alex в чат) ── from harness import commands es.append_event("task", "t-x", "TaskSubmitted", {"id": "t-x", "title": "задача", "size": "S"}) es.append_event("task", "t-x", "TaskClaimed", {"level": "min"}) for junk in ("x", "готово", "2"): try: commands.complete_task_with_delivery("t-x", junk, level="min") check(f"итог «{junk}» отклонён", False) except ValueError: check(f"итог «{junk}» отклонён", True) tid, _ = commands.complete_task_with_delivery("t-x", "Реакция 👍 ставится после ответа", level="min") check("нормальный итог принят", es.run_projections()["tasks"]["t-x"]["status"] == "done") print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)") sys.exit(1 if fails else 0)