fix(telegram): last draft push is the final text so the client folds it into the message

This commit is contained in:
hh
2026-08-28 18:51:16 +02:00
parent 5a44a16ba3
commit 66325c2879
3 changed files with 36 additions and 11 deletions
@@ -1,5 +1,9 @@
"""One ``sendMessageDraft`` stream per running turn (§3.8). """One ``sendMessageDraft`` stream per running turn (§3.8).
The client folds a draft into the message that follows only when their
texts are identical - so the last push is the final text itself, rendered
exactly as the outbox will send it, with no status line.
A draft is ephemeral and lives 30 s, Telegram throttles edits to about one A draft is ephemeral and lives 30 s, Telegram throttles edits to about one
per second per chat, and thinking or a tool call would otherwise look like a per second per chat, and thinking or a tool call would otherwise look like a
hang - so the draft opens with a status line straight away, is refreshed on hang - so the draft opens with a status line straight away, is refreshed on
@@ -74,15 +78,29 @@ class Draft:
The final ``sendMessage`` must reach Telegram after the last draft, The final ``sendMessage`` must reach Telegram after the last draft,
or the draft lands on top of the message and lingers for its 30 s. or the draft lands on top of the message and lingers for its 30 s.
""" """
if self._task is None: if self._task is not None:
return self._task.cancel()
self._task.cancel() await asyncio.gather(self._task, return_exceptions=True)
with contextlib.suppress(asyncio.CancelledError): self._task = None
await self._task
self._task = None
if self._inflight is not None: if self._inflight is not None:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await self._inflight await self._inflight
self._inflight = None
async def finish(self, text: str) -> None:
"""Last push: the final text verbatim, so the message replaces the draft."""
await self.stop()
if self._broken or not text.strip():
return
try:
try:
await self._send(to_html(text), "HTML")
except TelegramBadRequest:
await self._send(text, None)
except TelegramAPIError as exc:
_log.warning(
"final draft to %s/%s failed: %s", self._chat_id, self._thread_id, exc
)
async def _run(self) -> None: async def _run(self) -> None:
while not self._broken: while not self._broken:
@@ -39,7 +39,7 @@ from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.telegram.drafts import Draft from beaver_gateway.frontends.telegram.drafts import Draft
from beaver_gateway.frontends.telegram.inbox import Inbox from beaver_gateway.frontends.telegram.inbox import Inbox
from beaver_gateway.frontends.telegram.outbox import Outbox from beaver_gateway.frontends.telegram.outbox import Outbox
from beaver_gateway.frontends.telegram.render import status_label from beaver_gateway.frontends.telegram.render import chunks, status_label
if TYPE_CHECKING: if TYPE_CHECKING:
from beaver_gateway.core.bus import Event, EventBus from beaver_gateway.core.bus import Event, EventBus
@@ -659,7 +659,9 @@ class TelegramFrontend(Frontend):
f"{status_label(str(event['name']), event.get('input'))}" f"{status_label(str(event['name']), event.get('input'))}"
) )
case "turn.end": case "turn.end":
await self._close_draft(key) draft = self._drafts.get(key)
if draft is not None:
await draft.stop()
if event.get("stop") == "error" and event.get("origin") == "user": if event.get("stop") == "error" and event.get("origin") == "user":
await self._deliver( await self._deliver(
conv, conv,
@@ -691,7 +693,11 @@ class TelegramFrontend(Frontend):
async def _on_reply(self, conv: Conversation, event: Event) -> None: async def _on_reply(self, conv: Conversation, event: Event) -> None:
turn_id = str(event.get("turn_id") or "") turn_id = str(event.get("turn_id") or "")
origin = str(event.get("item_origin") or "") origin = str(event.get("item_origin") or "")
text = str(event.get("text") or "")
await self._unreact(event.get("item")) await self._unreact(event.get("item"))
draft = self._drafts.pop(conv.external_id, None)
if draft is not None:
await draft.finish(chunks(text)[-1] if text.strip() else "")
if origin != FRONTEND and not origin.startswith("сид"): if origin != FRONTEND and not origin.startswith("сид"):
user_text = str(event.get("user_text") or "") user_text = str(event.get("user_text") or "")
if user_text: if user_text:
@@ -701,9 +707,7 @@ class TelegramFrontend(Frontend):
turn_id=turn_id, turn_id=turn_id,
key=f"{turn_id}:mirror", key=f"{turn_id}:mirror",
) )
await self._deliver( await self._deliver(conv, text, turn_id=turn_id, key=f"{turn_id}:reply")
conv, str(event.get("text") or ""), turn_id=turn_id, key=f"{turn_id}:reply"
)
# ---- drafts ------------------------------------------------------------ # ---- drafts ------------------------------------------------------------
+3
View File
@@ -251,6 +251,9 @@ async def test_general_is_master_and_reply_has_no_thread(stack: Stack) -> None:
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
final = max(i for i, o in enumerate(stack.bot.order) if o[0] == "message") final = max(i for i, o in enumerate(stack.bot.order) if o[0] == "message")
assert all(o[0] != "draft" for o in stack.bot.order[final:]) assert all(o[0] != "draft" for o in stack.bot.order[final:])
last = stack.bot.drafts[-1]
assert last["text"] == reply["text"] and last["parse_mode"] == "HTML"
assert stack.bot.drafts[0]["text"] == "⏳ думаю"
rows = await stack.world.conversations.queue.recent(master.id) rows = await stack.world.conversations.queue.recent(master.id)
assert [r.origin for r in rows] == ["telegram"] assert [r.origin for r in rows] == ["telegram"]