TG: 👀 реально ставится, ответы в группе — reply'ем, правка сообщения не дублирует входящее

Жалоба 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 <noreply@anthropic.com>
This commit is contained in:
Simbiot Harness 2026-10-17 08:01:15 +00:00
parent 16d2b438bd
commit 72727c6339
3 changed files with 118 additions and 9 deletions

View file

@ -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"} params = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
if reply_to: if reply_to:
params["reply_to_message_id"] = reply_to params["reply_to_message_id"] = reply_to
# сообщение удалили — ответ всё равно уходит (без цитаты), не теряется
params["allow_sending_without_reply"] = "true"
try: try:
return _api("sendMessage", params, timeout=timeout) return _api("sendMessage", params, timeout=timeout)
except urllib.error.HTTPError as e: 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 часов. безопасен. Реакция допустима на сообщениях не старше 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, 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)} "is_big": json.dumps(is_big)}
return _api("setMessageReaction", params, timeout=10) return _api("setMessageReaction", params, timeout=10)
@ -401,6 +408,17 @@ def receive_message(update: dict):
return f"m-{update['update_id']}" 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): def send_reply(msg_id: str, text: str, timeout: int = 35):
"""Ответ на входящее: Telegram-отправка + событие ReplySent (закрывает входящее). """Ответ на входящее: 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) _api("sendChatAction", {"chat_id": m["chat_id"], "action": "typing"}, timeout=10)
except Exception: except Exception:
pass 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", { es.append_event("inbox", msg_id, "ReplySent", {
"to_chat": m["chat_id"], "text": text[:2000], "tg_ok": bool(r.get("ok")), "to_chat": m["chat_id"], "text": text[:2000], "tg_ok": bool(r.get("ok")),
"channel": "tg", "channel": "tg",
@ -490,7 +508,7 @@ def send_followup(msg_id: str, text: str, timeout: int = 35) -> bool:
elif chat.startswith("tui:"): elif chat.startswith("tui:"):
ok = True # TUI читает стор: события FollowupSent достаточно ok = True # TUI читает стор: события FollowupSent достаточно
else: 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")) ok = bool((r or {}).get("ok"))
if ok: if ok:
es.append_event("inbox", msg_id, "FollowupSent", { 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) _api("sendChatAction", {"chat_id": chat_id, "action": "typing"}, timeout=10)
except Exception: except Exception:
pass 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: for mid, m in items:
es.append_event("inbox", mid, "ReplySent", { es.append_event("inbox", mid, "ReplySent", {
"to_chat": chat_id, "to_chat": chat_id,
@ -613,6 +632,13 @@ def send_batch_reply(msg_ids: list, text: str, timeout: int = 35):
return head_mid 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(): def poll_loop():
"""Long polling getUpdates → MessageReceived. Отдельный процесс.""" """Long polling getUpdates → MessageReceived. Отдельный процесс."""
_load_env() _load_env()
@ -644,6 +670,11 @@ def poll_loop():
msg = u.get("message") or u.get("edited_message") msg = u.get("message") or u.get("edited_message")
if not msg: if not msg:
continue 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") user = (msg.get("from") or {}).get("id")
if ALLOWED and str(user) not in ALLOWED: if ALLOWED and str(user) not in ALLOWED:
# чужой: пишем событие, но не отвечаем (потенциально спам) # чужой: пишем событие, но не отвечаем (потенциально спам)

View file

@ -4,7 +4,7 @@
Запуск: ./venv/bin/python tests/test_seen_indication.py Запуск: ./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 - mark_seen: реакция + MessageSeen (проекция seen=True), повтор — skip
(нет ни реакции, ни второго события), TG-сбой — False без события; (нет ни реакции, ни второго события), TG-сбой — False без события;
- receive_message сохраняет message_id (нативный, не хэш-фолбэк); - receive_message сохраняет message_id (нативный, не хэш-фолбэк);
@ -41,14 +41,16 @@ def check(name, cond, extra=""):
es.init_db() es.init_db()
# ── 1. set_reaction: setMessageReaction с JSON reaction_type ── # ── 1. set_reaction: setMessageReaction с JSON reaction (массив) ──
calls = [] calls = []
tg_inbox._api = lambda method, params, timeout=35: calls.append((method, params)) or {"ok": True} tg_inbox._api = lambda method, params, timeout=35: calls.append((method, params)) or {"ok": True}
tg_inbox.set_reaction(42, 777, emoji="👀") tg_inbox.set_reaction(42, 777, emoji="👀")
check("set_reaction: метод setMessageReaction", calls and calls[0][0] == "setMessageReaction") check("set_reaction: метод setMessageReaction", calls and calls[0][0] == "setMessageReaction")
rt = json.loads(calls[0][1]["reaction_type"]) # Bot API: reaction — массив ReactionType (было reaction_type одиночным
check("set_reaction: reaction_type emoji 👀", # объектом: Telegram видел пустую реакцию, 👀 не появлялся — аудит 2026-10-17)
rt == {"type": "emoji", "emoji": "👀"} and calls[0][1]["message_id"] == 777, str(calls)) 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 ── # ── 2. receive_message хранит message_id ──
tg_inbox.receive_message({"update_id": 516173900, "message": { tg_inbox.receive_message({"update_id": 516173900, "message": {

76
tests/test_tg_group_ux.py Normal file
View file

@ -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)