Skip to content

Fix expandable-quote MarkdownV2 parsing; auto-clean secondary Telegram messages - #94

Open
Stelatix wants to merge 4 commits into
six-ddc:mainfrom
Stelatix:fix/expandable-quote-and-secondary-cleanup
Open

Stelatix wants to merge 4 commits into
six-ddc:mainfrom
Stelatix:fix/expandable-quote-and-secondary-cleanup

Conversation

@Stelatix

@Stelatix Stelatix commented Aug 9, 2026

Copy link
Copy Markdown

Summary

  • Expandable blockquotes rendered as >line|| instead of **>line||, so Telegram rejected the message ("Can't parse entities") and every thinking/tool_result/diff notification silently fell back to unformatted, uncollapsed plain text. Fixed by promoting the first > to **>, matching telegramify_markdown's own render_quote().
  • Added per-topic tracking that deletes the previous "secondary" message (thinking/tool_use/tool_result/local_command/user-echo) once the next message arrives, so a completed turn leaves only the final assistant answer behind instead of accumulating every intermediate message. tool_use → tool_result keeps its existing edit-in-place behavior.

Test plan

  • uv run pytest tests/ — 267 passed
  • uv run ruff check src/ tests/ — clean on changed files
  • uv run pyright src/ccbot/ — 0 errors on changed files
  • Deployed to a live ccbot instance and verified in a real Telegram topic

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

Stelatix and others added 4 commits August 9, 2026 16:04
_render_expandable_quote() built raw MarkdownV2 blockquotes as
">line1\n>line2||" — missing the "**" that Telegram's grammar requires
to open an *expandable* quote. Without it, the trailing "||" is an
unmatched spoiler entity: Telegram rejects the whole sendMessage call
with "Can't parse entities", and the caller's fallback resends the
message as unformatted plain text with no collapsing at all.

This path is hit by most non-trivial notifications: thinking blocks,
Bash/Grep/Glob/Task/WebFetch/WebSearch tool_result output, multi-line
tool errors, and Edit diffs all go through _format_expandable_quote().
So instead of a collapsed "N lines, tap to expand" block, the full raw
content (including repeated tracebacks in log-heavy output) was landing
directly in the chat.

Fix: promote the first ">" of the rendered block to "**>", matching
telegramify_markdown's own render_quote() implementation. Existing
tests asserted the old (broken) shape; updated them and added coverage
for multi-line and truncated quotes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Live notifications for a topic used to accumulate indefinitely: every
thinking block, tool_use/tool_result, local-command echo, and even the
bot's own echo of the user's outgoing message stayed in the chat
forever, alongside the actual assistant answer. For tool-call-heavy
sessions (e.g. a monitoring loop polling journalctl) this buried the
real answer under a growing wall of technical messages.

Add per-topic tracking of the last "secondary" message
(_secondary_msg_info, keyed like the existing _status_msg_info /
_tool_msg_ids). Secondary means everything except the final assistant
text answer: thinking, tool_use, tool_result, local_command, and the
role=user echo of a forwarded message. Whenever a new message arrives
for a topic, the previously tracked secondary message is deleted; the
new message is tracked in its place if it's itself secondary, or left
untouched if it's the final answer. Whatever happens to be last simply
never gets deleted, so a completed turn naturally leaves only the
answer behind — no separate "is this the final message" detection
needed. Final text answers are never deleted or tracked, per prior
discussion (multiple text blocks in one turn should all persist).

tool_use -> tool_result keeps its existing edit-in-place behavior
(same message_id, so the secondary tracking stays valid across the
edit) rather than being folded into delete+resend.

_can_merge_tasks() now also refuses to merge a secondary task with a
non-secondary one, since the merged task can only carry one
is_secondary value. clear_topic_state() clears the new tracking on
topic close.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_process_content_task's rolling delete-on-next logic popped the tracked
secondary message out of _secondary_msg_info before attempting
bot.delete_message, then swallowed all errors with a bare
`except Exception: pass` — which also caught RetryAfter. Under a burst
of several messages to the same topic within a couple seconds (thinking
+ tool_use/tool_result + task-notification text + interactive-UI
polling, all hitting the same chat), Telegram's flood control could
reject the delete; the exception was silently dropped and the message
was already forgotten, so nothing ever cleaned it up.

Keep the entry in _secondary_msg_info until the delete actually
succeeds (or is given up on for a non-retryable reason), and re-raise
RetryAfter so the existing _process_content_with_retry wrapper retries
the whole task — a retry now finds the same tracked message and can
still delete it, instead of leaking it forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_merge_content_tasks folds several secondary entries that arrive close
together (task-notification bursts observed at 3-4 messages within tens
of milliseconds) into one MessageTask with multiple `parts`. Each part
is sent as its own Telegram message, but _secondary_msg_info only
recorded the id of the last one — the earlier N-1 messages were sent,
immediately forgotten, and never deleted by the rolling cleanup, no
matter how many further messages arrived afterward.

Same issue applies to a single (unmerged) secondary task whose content
gets paginated into multiple messages by split_message.

Change _secondary_msg_info's value from a single message_id to a list
of ids, collected from every part actually sent (including one
converted from a status message). Deletion now walks the whole list;
on RetryAfter mid-list, already-deleted ids are dropped and the
remainder is written back before re-raising, so a retry of the task
resumes exactly where it left off instead of re-deleting or losing
track of entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TejNote added a commit to TejNote/ccbot that referenced this pull request Sep 16, 2026
upstream 패치 자체는 맞지만 **이 fork 의 다른 부분과 겹쳐서 깨지는** 것들이다.
upstream 테스트로는 안 잡힌다.

🚨 죽은 토픽 정리 후 타이핑 keepalive 가 살아남아 무한 API 호출
  six-ddc#81 의 cleanup_dead_topic 은 clear_topic_state 를 부르지 않는 유일한 해제 경로였다
  (다른 셋은 전부 부른다). six-ddc#98 전에는 단순 누수였지만 붙고 나서는 _typing_keepalive 가
  while True 라 **텔레그램이 "그 토픽 없다" 고 답한 thread 로 4초마다 send_chat_action 을
  영원히** 던지게 됐다. 정리한 (user_id, thread_id) 목록을 반환하도록 바꿔 연결한다.

six-ddc#97 의 8줄 스캔이 `·` 불릿을 상태줄로 읽는 구멍을 비인접 줄에 열었다
  upstream 조건이 `"…" in line` 하나뿐인데 `·` 는 스피너이면서 평범한 불릿이다.
  Claude 답변의 `· 첫째 항목은…` 이 상태줄이 되고, six-ddc#98 이 그 값으로 타이핑을 켜므로
  유휴 세션에 유령 상태 메시지 + 영구 타이핑이 남는다. 비인접은 `…` **그리고** `·` 아님.
  ⚠️ 구분선 바로 위 불릿은 main 에도 있던 기존 오탐이라 손대지 않았다(실측 대조).

퍼미션 프롬프트 동안 "작업 중" 으로 보이던 것
  six-ddc#98 의 set_typing 이 함수 맨 끝 한 번인데 그 위에 조기 return 이 셋이다. 프롬프트는
  몇 시간도 떠 있고 실제로는 봇이 사용자를 기다리는 중이다. skip_status 는 제외했다 —
  큐가 찬 일시적 상태이고 에이전트는 실제로 작업 중이다.

secondary 태스크의 이미지가 고아로 남던 것
  six-ddc#94 가 모으는 id 에 _send_task_images 의 사진이 빠졌다. tool_result 는 항상 secondary 라
  다음 메시지가 오면 설명 텍스트만 지워지고 스크린샷이 남는다. send_photo 가 id 를 반환한다.

콜백 응답이 최대 45초 지연되던 것
  query.answer() 앞에 wait_for_claude_ready(30초) + wait_for_session_map_entry(5~15초)가
  있었다. 텔레그램은 약 15초를 넘기면 query is too old 를 내고 try/except 도 없다.
  응답을 맨 앞으로 옮겼다 — 결과는 safe_edit 가 본문으로 보여준다.

TOPIC_CHECK_INTERVAL docstring 60초 → 5초 정정
  ⚠️ 바인딩당 API 호출 12배로 six-ddc#52 스로틀과 방향이 반대다. 레이트 리밋을 실제로 맞은
  관측이 없어 upstream 값은 유지하고, 근거와 되돌릴 값(15~20초)을 주석에 남겼다.

Tested: pytest 440항 (신규 8) · pyright 0 errors · ruff 103 → 102, 파일·규칙 단위로
  main 에 없던 조합 0건. 신규 테스트는 **뮤테이션 4종**으로 유효성을 확인했다 —
  각 수정을 되돌리면 대응 테스트 하나씩만 실패한다.
Not-tested: 실제 텔레그램 대상 수동 검증(봇 재시작 필요 — 머지 후 별도)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant