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:
parent
16d2b438bd
commit
72727c6339
3 changed files with 118 additions and 9 deletions
|
|
@ -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:
|
||||||
# чужой: пишем событие, но не отвечаем (потенциально спам)
|
# чужой: пишем событие, но не отвечаем (потенциально спам)
|
||||||
|
|
|
||||||
|
|
@ -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
76
tests/test_tg_group_ux.py
Normal 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)
|
||||||
Loading…
Reference in a new issue