This repository was archived by the owner on Oct 28, 2026. It is now read-only.
Repository navigation
v1.1.0 — upstream 커뮤니티 PR 9건 선별 머지 + 리뷰 수정 6건 - #15
Merged
Merged
Conversation
The send helpers in message_sender.py (send_with_fallback, safe_reply, safe_edit, safe_send) send with parse_mode=MarkdownV2 and, in a broad `except Exception`, resend the message as plain text. On a slow or flaky connection this produces duplicate messages: the formatted send reaches Telegram and is delivered, but the client then raises TimedOut/NetworkError while waiting for the API response. The broad except treats that as a failure and resends as plain text, so the user receives the message twice -- one formatted copy and one plain copy. Narrow the fallback to telegram.error.BadRequest, the only error that proves the formatted message was rejected (and therefore not delivered), so a plain-text retry is the correct recovery. Any other non-RetryAfter error (TimedOut, NetworkError, ...) is ambiguous -- the message may already have been delivered -- so log and do not resend, preferring a rare drop over a duplicate. RetryAfter is still re-raised unchanged for the queue worker's flood-control handling. Add tests covering all four helpers: transient errors send exactly once (no duplicate), BadRequest still retries as plain text, and RetryAfter propagates. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> (cherry picked from commit 3803b90)
Voice transcription hardcoded the gpt-4o-transcribe model, which blocked reuse of OPENAI_BASE_URL against OpenAI-compatible backends serving different model names (e.g. a self-hosted Whisper server). Add CCBOT_TRANSCRIBE_MODEL (default gpt-4o-transcribe) so the model is configurable, mirroring the existing OPENAI_BASE_URL / OPENAI_API_KEY overrides. Verified end-to-end against a local Speaches instance running Whisper large-v3-turbo. Backwards compatible: unset preserves existing OpenAI behaviour. (cherry picked from commit fba6ad5)
…am rate limits
When Claude Code waits on a long-running task (e.g. agent sub-tasks),
the status line timer increments every second ("Thinking… 1s", "2s", …).
The status poller detects each tick as a text change and enqueues a
Telegram edit_message_text call — roughly 60/min. This quickly exceeds
Telegram's rate limit, triggering 429 responses and a flood-control ban
that silences the bot for extended periods.
Add _should_send_status() in status_polling.py that detects timer
suffixes via regex and applies an adaptive send interval based on how
long the same status has been active:
0–10 s → every 1 s (real-time, useful for short tasks)
10–60 s → every 5 s
60 s+ → every 30 s
Key design decisions:
- Poll interval stays at 1 s — interactive UI detection is unaffected
- Non-timer status changes always send immediately (no delay)
- Timer detection uses a trailing regex matching "5s", "1m 30s", etc.
- Intervals are configurable via STATUS_THROTTLE_INTERVALS env var
(comma-separated, e.g. "1,5,30"; set "1,1,1" to disable)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit 6e94ed0)
/kill was registered only as a BotCommand menu hint with no handler, so it fell through to forward_command_handler and was sent into Claude Code as a slash command. Wire up an actual kill_command that kills the tmux window, drops the binding, purges window state, removes the session_map entry, and deletes the Telegram topic. Also fix accumulating orphan state when topics die outside /kill: - session.purge_window / remove_session_map_entry / cleanup_dead_topic - topic_closed_handler now invokes the same purge path - status_polling probe accepts both Topic_id_invalid and Message thread not found (interval lowered 60s -> 5s) - send_with_fallback / safe_send react to those markers immediately — needed because unpinAllForumTopicMessages returns ok=true for private chats with arbitrary thread_ids, so the probe alone cannot detect a deleted topic in DM mode restart.sh now sweeps any \$VENV/bin/ccbot processes outside the target pane to prevent multiple instances racing the same bot token. (cherry picked from commit 4c426c6)
Claude Code 가 상태줄과 chrome 구분선 사이에 tip 블록을 렌더하면, 구분선 바로 위 한 줄만 보던 기존 로직이 "상태 없음" 으로 판정했다. 위로 더 훑되, 바로 윗줄이 아닌 위치에서는 `…` 를 가진 줄만 상태로 인정한다 — 일반 출력의 `·` 불릿과 완료 표시(`✻ Sautéed for 7s`)를 걸러내기 위해서다. fork 고유의 백그라운드 셸 필터(_BACKGROUND_SHELL_RE)는 그대로 보존했다. 독스트링도 바뀐 동작에 맞춰 고쳤다 — 주석이 거짓이 되면 다음 사람이 속는다. Tested: pytest tests/ccbot/test_terminal_parser.py 54항 통과 (upstream 테스트 2건 추가) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
예전에는 상태 메시지를 보내거나 편집할 때마다 "esc to interrupt" 가 들어 있으면 chat action 을 한 번 쏘는 방식이었다. 텔레그램은 chat action 을 약 5초 뒤 지우므로, 상태 텍스트가 안 바뀌는 긴 구간에서는 타이핑 표시가 꺼진 채로 남았다. 이제 상태줄 파싱 결과(= 에이전트가 턴 중인지 아는 유일한 지점)가 set_typing 을 호출하고, keepalive 태스크가 4초마다 갱신한다. 상태 메시지 삭제·토픽 정리·봇 종료 세 경로에서 태스크를 취소한다. fork 고유 지점 반영: - status_polling 의 codex 분기(parse_codex_status_line)도 같은 게이트를 탄다 - upstream 은 chat action 에 thread 를 안 실었지만 여기서는 _send_kwargs 로 message_thread_id 를 넘긴다 — 토픽 모드가 기본이라 안 넘기면 엉뚱한 곳에 뜬다 Tested: pytest 402항 통과 · ruff 위반 main 103 → 100 (증가 없음) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
텔레그램 클라이언트는 **그 채팅에 맞는 스코프**의 명령 목록을 읽는다. 좁은 스코프가 기본 스코프를 이기므로, 옛 설정(BotFather·구버전)이 남긴 all_private_chats 목록 하나가 진짜 메뉴를 가린다. 포럼 토픽에서 "/" 버튼이 비어 보이던 원인이다. - 지울 때는 관리자 스코프까지 넓게(_COMMAND_CLEAR_SCOPES) - 쓸 때는 실제로 쓰는 스코프에 전부(_COMMAND_WRITE_SCOPES) - 스코프별 실패는 warning 만 남기고 나머지 스코프는 계속 시도한다 fork 고유 지점: upstream 은 post_init 2곳만 고쳤지만 이 fork 는 스킬 즐겨찾기 토글에서 런타임 재등록을 한 번 더 한다(bot.py:1860). 거기까지 같은 헬퍼를 태워야 토글 뒤에 메뉴가 다시 좁은 스코프만 갖는 상태로 돌아가지 않는다. CC_COMMANDS 에 upstream 이 추가한 5개(agents·context·recap·status·tasks)를 넣되 설명은 이 fork 의 한국어 스타일로 맞췄다. Tested: pytest 402항 통과 · ruff 신규 위반 0 (bot.py I001 은 main 에도 있는 기존 것) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_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> (cherry picked from commit 5d3bc34)
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> (cherry picked from commit e0089f1)
_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> (cherry picked from commit b381ccc)
_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> (cherry picked from commit 00b58ed)
Since Claude Code ~2.1.260 the first launch in an untrusted directory shows an unnumbered "Quick safety check" menu whose default option is "No, exit". ccbot never answered it: the SessionStart hook doesn't fire, the 5s wait times out, and the pending first message is typed into the dialog — Enter on the default option exits Claude, leaving the topic bound to a bare shell. - claude_config.ensure_trusted_directory() sets projects[<git root or dir>].hasTrustDialogAccepted = true in ~/.claude.json (honours CLAUDE_CONFIG_DIR) before creating the window. Atomic write, file mode preserved, no-op if already trusted, missing or malformed files left untouched. CCBOT_AUTO_TRUST_DIRS=false disables it. - Fallback for when the dialog still appears (e.g. the home directory, whose trust Claude Code doesn't persist): a TrustDialog UI pattern and TmuxManager.auto_answer_dialog(), which moves the ❯ cursor onto "Yes, I trust", re-captures to verify, then presses Enter. It never types a digit, since option order changed between versions. - _create_and_bind_window waits for Claude's input box (wait_for_claude_ready) before the hook wait and before forwarding the pending message; status polling answers the dialog in existing windows. Pane fixtures are real tmux captures from Claude Code 2.1.272. (cherry picked from commit 7aac525)
Named sessions render the input box's top border as '──────── my-session ─', which the plain all-dashes check rejected, so wait_for_claude_ready timed out on otherwise-ready sessions. Fixture is a real 2.1.272 capture. (cherry picked from commit e45d281)
upstream main 은 2026-07-08 이후 멈춰 있고 이 fork 는 이미 완전 동기화 상태였다. 남아 있던 건 미머지 커뮤니티 PR 뿐이라 그중 9건을 골라 가져왔다. Tested: pytest 432항 (머지 전 400) · pyright 0 errors · ruff 위반 103 → 101 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
결론부터
upstream
six-ddc/ccbotmain 은 2026-07-08 이후 멈춰 있고, 우리 fork 는 이미 완전 동기화 상태였다.미머지 브랜치 3개(
fix/prevent-window-rename·fix/restore-telegramify-markdown-default·fix/scrub-sensitive-env)는git cherry로 확인한 결과 전부 이미 패치 동등하게 적용돼 있었다(SHA 만 다른 PR 잔해).copilot/*는 main 이전의 폐기된 리팩터링이다.그래서 가져올 게 남아 있던 곳은 미머지 커뮤니티 PR 뿐이었고, 그중 9건을 골랐다.
머지한 것
이번 주제(텔레그램 소음)와 직결
except Exception하나로 다 잡아 평문 재전송하던 것.TimedOut은 이미 도착했을 수도 있는 모호한 상태라 재전송하면 서식본 + 평문본 2건이 된다. 이제BadRequest(= 서식 거부 → 확실히 미전달)일 때만.**>누락으로Can't parse entities를 내고, 폴백이 서식 없는 평문 전문을 그대로 보냈다. thinking · Bash/Grep/Task/WebFetch 결과 · 여러 줄 에러 · Edit diff 가 전부 이 경로다 — "N줄, 탭해서 펼치기" 대신 로그 원문이 매번 채팅에 떨어지고 있었다. + thinking/tool 메시지 자동 정리(다음 메시지 도착 시 삭제).나머지
/메뉴가 비어 보이던 것(좁은 스코프가 기본을 이긴다)CCBOT_AUTO_TRUST_DIRS=false로 끔)/kill이 핸들러 없이 Claude Code 로 흘러들어가던 것 + 죽은 토픽 상태 정리제외
codex/codex-remote-agents(+3,120줄) — Codex 를 app-server WebSocket 원격 프로토콜로 다시 붙이는 실험 브랜치라 이 fork 의 OMX 로컬 tmux 방식과 정면 충돌한다.이 fork 에 맞춰 손본 것
#97·#98·#99는 upstream 패치를 그대로 못 써서 손으로 옮겨 적었고,#81·#94·#101은 충돌을 수동 해결했다._send_kwargs로message_thread_id를 넘긴다. codex 분기도 같은 게이트를 탄다post_init2곳만 고쳤지만 이 fork 는 스킬 즐겨찾기 토글에서 런타임 재등록을 한 번 더 한다. 거기까지 같은 헬퍼를 태워야 토글 뒤 메뉴가 되돌아가지 않는다_check_and_send_status를 인라인 호출한다. 이 fork 는 상태 표시를status_polling에 넘기며 그 호출을 뺐으므로 되살리지 않았다BadRequest분기 안쪽에만 둔다.Topic_id_invalid·Message thread not found는BadRequest로 오고(실측 확인),TimedOut으로 토픽을 지우면 네트워크가 흔들릴 때마다 멀쩡한 바인딩이 날아간다_BACKGROUND_SHELL_RE)를 보존했다🚨 머지 리뷰가 잡은 결함 6건 — 같이 고쳤다
전부 "upstream 패치는 맞는데 이 fork 의 다른 부분과 겹쳐서 깨지는" 형태라 upstream 테스트로는 안 잡힌다.
#81의cleanup_dead_topic은clear_topic_state를 안 부르는 유일한 해제 경로였다(다른 셋은 전부 부른다).#98전에는 단순 누수였지만, 붙고 나서는_typing_keepalive가while True라 텔레그램이 "그 토픽 없다" 고 답한 thread 로 4초마다send_chat_action을 영원히 던지게 됐다·불릿이 상태줄로 읽히는 구멍을#97이 비인접 줄에 열었다.·는 스피너이면서 평범한 불릿이라, Claude 답변의· 첫째 항목은…이 상태줄이 되고#98이 그 값으로 타이핑을 켠다 → 유휴 세션에 유령 상태 메시지 + 영구 타이핑#98의set_typing이 함수 맨 끝 한 번인데 그 위에 조기return이 셋이다. 프롬프트는 몇 시간도 떠 있고, 실제로는 봇이 사용자를 기다리는 중이다tool_result는 항상 secondary 라, 다음 메시지가 오면 설명 텍스트만 지워지고 스크린샷이 맥락 없이 남는다query.answer()앞에wait_for_claude_ready(30초) +wait_for_session_map_entry(5~15초)가 있었다. 텔레그램은 약 15초를 넘기면query is too old를 내고 그 호출엔 try/except 도 없다TOPIC_CHECK_INTERVALdocstring 이 60초로 남아 있던 것(#81이 5초로 낮춤)리뷰어 표에 사실관계 오류가 하나 있어 채택하지 않은 것: "구분선 바로 위
·불릿" 을 main 에서None이라고 적었는데, main 을 직접 로드해 대조하니 main 에서도 동일하게 오탐이다. 내가 만든 회귀가 아니므로 비인접 구멍만 막았다.TOPIC_CHECK_INTERVAL은 바인딩당 API 호출이 12배가 되어#52스로틀과 방향이 반대다. 다만 레이트 리밋을 실제로 맞은 관측이 없어 upstream 값(5초)을 유지하고, 근거와 되돌릴 값(15~20초)을 주석에 남겼다.검증
ruff 는 파일·규칙 단위로 대조해 main 에 없던 조합이 0건이고, 유일한 증가는
bot.pyBLE001 12 → 15(#81/kill핸들러 +#99스코프 헬퍼 2개)로 그 파일의 기존 스타일과 같다.신규 테스트(
tests/ccbot/test_upstream_merge_fixes.py8항)는 뮤테이션 4종으로 유효성을 확인했다 — 각 수정을 되돌리면 대응 테스트 하나씩만 실패한다.미검증: 실제 텔레그램 대상 수동 검증(봇 재시작 필요).
머지 후 필요한 것
./scripts/restart.sh— 재시작 중 텔레그램 원격 제어가 잠깐 끊긴다.#94). 턴이 끝나면 답변만 남는다. 마음에 안 들면 그 커밋(da02e0d·284cd4a·415b31e)만 되돌리면 된다.🤖 Generated with Claude Code