From 72727c63395a2e52a1cb00a00e5ba77510ad260d Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Sat, 17 Oct 2026 08:01:15 +0000 Subject: [PATCH] =?UTF-8?q?TG:=20=F0=9F=91=80=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=20=D1=81=D1=82=D0=B0=D0=B2=D0=B8=D1=82=D1=81?= =?UTF-8?q?=D1=8F,=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=8B=20=D0=B2=20?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B5=20=E2=80=94=20reply'=D0=B5?= =?UTF-8?q?=D0=BC,=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D1=80=D1=83=D0=B5=D1=82=20=D0=B2?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D1=8F=D1=89=D0=B5=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Жалоба Alex: «реплаю на сообщения Коди — не реагирует». Триаж реплай на бота распознавал (07:57, rule=reply_to_bot, ответ через 25 с), но: - set_reaction слал reaction_type одиночным объектом вместо reaction — массива ReactionType: Telegram видел пустую реакцию — в личке молча «снимал реакции» (ok → MessageSeen, 596/нед, 👀 не было видно), в группе 400 REACTION_EMPTY. Тест test_seen_indication закреплял баг — исправлен под Bot API; - ответ в группе уходил отдельным сообщением: теперь reply на сообщение (батч — на последнее, досылка --followup — на исходный вопрос) с allow_sending_without_reply; личка — как было; - edited_message уже принятого сообщения писался новым входящим (m-516174914/915 — исправленная опечатка дала дубль в батче) — пропуск. Тест: test_tg_group_ux 10 PASS; полный набор — без регрессий. Co-Authored-By: Claude Opus 5.5 --- harness/tg_inbox.py | 39 ++++++++++++++++-- tests/test_seen_indication.py | 12 +++--- tests/test_tg_group_ux.py | 76 +++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 tests/test_tg_group_ux.py diff --git a/harness/tg_inbox.py b/harness/tg_inbox.py index 0ddb3be..c0c8018 100644 --- a/harness/tg_inbox.py +++ b/harness/tg_inbox.py @@ -63,6 +63,8 @@ def send_message(chat_id, text: str, reply_to=None, timeout: int = 35): params = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"} if reply_to: params["reply_to_message_id"] = reply_to + # сообщение удалили — ответ всё равно уходит (без цитаты), не теряется + params["allow_sending_without_reply"] = "true" try: return _api("sendMessage", params, timeout=timeout) except urllib.error.HTTPError as e: @@ -81,8 +83,13 @@ def set_reaction(chat_id, message_id: int, emoji: str = "👀", is_big: bool = F смена эмодзи на другой видна как изменение реакции. Идемпотентный повтор безопасен. Реакция допустима на сообщениях не старше 48 часов. """ + # Bot API: параметр reaction — МАССИВ ReactionType. Было reaction_type с + # одиночным объектом: Telegram видел пустую реакцию — в личке молча + # «снимал реакции» (ok, MessageSeen писался, 👀 не появлялся), в группе + # отвечал 400 REACTION_EMPTY (аудит 2026-10-17). + _load_env() params = {"chat_id": chat_id, "message_id": message_id, - "reaction_type": json.dumps({"type": "emoji", "emoji": emoji}), + "reaction": json.dumps([{"type": "emoji", "emoji": emoji}]), "is_big": json.dumps(is_big)} return _api("setMessageReaction", params, timeout=10) @@ -401,6 +408,17 @@ def receive_message(update: dict): return f"m-{update['update_id']}" +def _group_reply_to(items) -> "int | None": + """В группе отвечаем reply'ем на последнее сообщение батча — видно, кому + и на что ответ (аудит 2026-10-17: ответ отдельным сообщением через + 20–30 с выглядел как «бот не реагирует»). Личка — без цитаты, как было.""" + from .addressing import is_group + items = [m for m in items if m and m.get("message_id")] + if not items or not is_group(items[0]): + return None + return max(items, key=lambda m: m.get("ts") or "").get("message_id") + + def send_reply(msg_id: str, text: str, timeout: int = 35): """Ответ на входящее: Telegram-отправка + событие ReplySent (закрывает входящее). @@ -459,7 +477,7 @@ def send_reply(msg_id: str, text: str, timeout: int = 35): _api("sendChatAction", {"chat_id": m["chat_id"], "action": "typing"}, timeout=10) except Exception: pass - r = send_message(m["chat_id"], text, reply_to=None, timeout=timeout) + r = send_message(m["chat_id"], text, reply_to=_group_reply_to([m]), timeout=timeout) es.append_event("inbox", msg_id, "ReplySent", { "to_chat": m["chat_id"], "text": text[:2000], "tg_ok": bool(r.get("ok")), "channel": "tg", @@ -490,7 +508,7 @@ def send_followup(msg_id: str, text: str, timeout: int = 35) -> bool: elif chat.startswith("tui:"): ok = True # TUI читает стор: события FollowupSent достаточно else: - r = send_message(m["chat_id"], text, reply_to=None, timeout=timeout) + r = send_message(m["chat_id"], text, reply_to=_group_reply_to([m]), timeout=timeout) ok = bool((r or {}).get("ok")) if ok: es.append_event("inbox", msg_id, "FollowupSent", { @@ -601,7 +619,8 @@ def send_batch_reply(msg_ids: list, text: str, timeout: int = 35): _api("sendChatAction", {"chat_id": chat_id, "action": "typing"}, timeout=10) except Exception: pass - r = send_message(chat_id, text, reply_to=None, timeout=timeout) + r = send_message(chat_id, text, reply_to=_group_reply_to([m for _, m in items]), + timeout=timeout) for mid, m in items: es.append_event("inbox", mid, "ReplySent", { "to_chat": chat_id, @@ -613,6 +632,13 @@ def send_batch_reply(msg_ids: list, text: str, timeout: int = 35): return head_mid +def _already_received(msg: dict) -> bool: + """Есть ли в инбоксе входящее с тем же (chat_id, message_id).""" + cid, mid = (msg.get("chat") or {}).get("id"), msg.get("message_id") + inbox = es.run_projections().get("inbox") or {} + return any(m.get("chat_id") == cid and m.get("message_id") == mid for m in inbox.values()) + + def poll_loop(): """Long polling getUpdates → MessageReceived. Отдельный процесс.""" _load_env() @@ -644,6 +670,11 @@ def poll_loop(): msg = u.get("message") or u.get("edited_message") if not msg: continue + if u.get("edited_message") and _already_received(msg): + # правка уже принятого сообщения — не новое входящее (дубль + # m-516174914/915: исправил опечатку → два одинаковых в батче) + print(f"[tg] правка сообщения {msg.get('message_id')} — пропуск", flush=True) + continue user = (msg.get("from") or {}).get("id") if ALLOWED and str(user) not in ALLOWED: # чужой: пишем событие, но не отвечаем (потенциально спам) diff --git a/tests/test_seen_indication.py b/tests/test_seen_indication.py index 8da1642..cf2066a 100644 --- a/tests/test_seen_indication.py +++ b/tests/test_seen_indication.py @@ -4,7 +4,7 @@ Запуск: ./venv/bin/python tests/test_seen_indication.py Проверяет: -- set_reaction шлёт setMessageReaction с корректным reaction_type (JSON); +- set_reaction шлёт setMessageReaction с корректным reaction (JSON-массив); - mark_seen: реакция + MessageSeen (проекция seen=True), повтор — skip (нет ни реакции, ни второго события), TG-сбой — False без события; - receive_message сохраняет message_id (нативный, не хэш-фолбэк); @@ -41,14 +41,16 @@ def check(name, cond, extra=""): es.init_db() -# ── 1. set_reaction: setMessageReaction с JSON reaction_type ── +# ── 1. set_reaction: setMessageReaction с JSON reaction (массив) ── calls = [] tg_inbox._api = lambda method, params, timeout=35: calls.append((method, params)) or {"ok": True} tg_inbox.set_reaction(42, 777, emoji="👀") check("set_reaction: метод setMessageReaction", calls and calls[0][0] == "setMessageReaction") -rt = json.loads(calls[0][1]["reaction_type"]) -check("set_reaction: reaction_type emoji 👀", - rt == {"type": "emoji", "emoji": "👀"} and calls[0][1]["message_id"] == 777, str(calls)) +# Bot API: reaction — массив ReactionType (было reaction_type одиночным +# объектом: Telegram видел пустую реакцию, 👀 не появлялся — аудит 2026-10-17) +rt = json.loads(calls[0][1]["reaction"]) +check("set_reaction: reaction = [emoji 👀]", + rt == [{"type": "emoji", "emoji": "👀"}] and calls[0][1]["message_id"] == 777, str(calls)) # ── 2. receive_message хранит message_id ── tg_inbox.receive_message({"update_id": 516173900, "message": { diff --git a/tests/test_tg_group_ux.py b/tests/test_tg_group_ux.py new file mode 100644 index 0000000..947c0c5 --- /dev/null +++ b/tests/test_tg_group_ux.py @@ -0,0 +1,76 @@ +# -*- 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})) + +print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)") +sys.exit(1 if fails else 0)