Skip to content
This repository was archived by the owner on Oct 28, 2026. It is now read-only.

v1.1.0 — upstream 커뮤니티 PR 9건 선별 머지 + 리뷰 수정 6건 - #15

Merged
TejNote merged 15 commits into
mainfrom
feat/upstream-merge
Sep 16, 2026
Merged

TejNote merged 15 commits into
mainfrom
feat/upstream-merge

Conversation

@TejNote

@TejNote TejNote commented Sep 16, 2026

Copy link
Copy Markdown
Owner

결론부터

upstream six-ddc/ccbot main 은 2026-07-08 이후 멈춰 있고, 우리 fork 는 이미 완전 동기화 상태였다.

git rev-list --count fcd8db2..upstream/main   →  0

미머지 브랜치 3개(fix/prevent-window-rename · fix/restore-telegramify-markdown-default · fix/scrub-sensitive-env)는 git cherry 로 확인한 결과 전부 이미 패치 동등하게 적용돼 있었다(SHA 만 다른 PR 잔해). copilot/* 는 main 이전의 폐기된 리팩터링이다.

그래서 가져올 게 남아 있던 곳은 미머지 커뮤니티 PR 뿐이었고, 그중 9건을 골랐다.

머지한 것

이번 주제(텔레그램 소음)와 직결

나머지

제외

codex/codex-remote-agents(+3,120줄) — Codex 를 app-server WebSocket 원격 프로토콜로 다시 붙이는 실험 브랜치라 이 fork 의 OMX 로컬 tmux 방식과 정면 충돌한다.

이 fork 에 맞춰 손본 것

#97·#98·#99 는 upstream 패치를 그대로 못 써서 손으로 옮겨 적었고, #81·#94·#101 은 충돌을 수동 해결했다.

🚨 머지 리뷰가 잡은 결함 6건 — 같이 고쳤다

전부 "upstream 패치는 맞는데 이 fork 의 다른 부분과 겹쳐서 깨지는" 형태라 upstream 테스트로는 안 잡힌다.

등급 결함
Critical 죽은 토픽 정리 후 타이핑 keepalive 가 살아남아 무한 API 호출. #81 의 cleanup_dead_topic 은 clear_topic_state 를 안 부르는 유일한 해제 경로였다(다른 셋은 전부 부른다). #98 전에는 단순 누수였지만, 붙고 나서는 _typing_keepalive 가 while True 라 텔레그램이 "그 토픽 없다" 고 답한 thread 로 4초마다 send_chat_action 을 영원히 던지게 됐다
High · 불릿이 상태줄로 읽히는 구멍을 #97 이 비인접 줄에 열었다. · 는 스피너이면서 평범한 불릿이라, Claude 답변의 · 첫째 항목은… 이 상태줄이 되고 #98 이 그 값으로 타이핑을 켠다 → 유휴 세션에 유령 상태 메시지 + 영구 타이핑
High 퍼미션 프롬프트 동안 "작업 중" 으로 보이던 것. #98 의 set_typing 이 함수 맨 끝 한 번인데 그 위에 조기 return 이 셋이다. 프롬프트는 몇 시간도 떠 있고, 실제로는 봇이 사용자를 기다리는 중이다
High secondary 태스크의 이미지가 고아로 남던 것. tool_result 는 항상 secondary 라, 다음 메시지가 오면 설명 텍스트만 지워지고 스크린샷이 맥락 없이 남는다
High 콜백 응답이 최대 45초 지연되던 것. query.answer() 앞에 wait_for_claude_ready(30초) + wait_for_session_map_entry(5~15초)가 있었다. 텔레그램은 약 15초를 넘기면 query is too old 를 내고 그 호출엔 try/except 도 없다
Low TOPIC_CHECK_INTERVAL docstring 이 60초로 남아 있던 것(#81 이 5초로 낮춤)

리뷰어 표에 사실관계 오류가 하나 있어 채택하지 않은 것: "구분선 바로 위 · 불릿" 을 main 에서 None 이라고 적었는데, main 을 직접 로드해 대조하니 main 에서도 동일하게 오탐이다. 내가 만든 회귀가 아니므로 비인접 구멍만 막았다.

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

검증

uv run pytest          440 passed   (머지 전 400 → upstream 32 + 이 fork 회귀 8)
uv run pyright src/    0 errors
uv run ruff check      103 → 102

ruff 는 파일·규칙 단위로 대조해 main 에 없던 조합이 0건이고, 유일한 증가는 bot.py BLE001 12 → 15(#81 /kill 핸들러 + #99 스코프 헬퍼 2개)로 그 파일의 기존 스타일과 같다.

신규 테스트(tests/ccbot/test_upstream_merge_fixes.py 8항)는 뮤테이션 4종으로 유효성을 확인했다 — 각 수정을 되돌리면 대응 테스트 하나씩만 실패한다.

뮤테이션 1 (불릿 가드 제거)    → test_bullet_three_lines_above_is_not_status 실패
뮤테이션 2 (set_typing 제거)   → test_interactive_ui_turns_typing_off 실패
뮤테이션 3 (이미지 추적 순서)   → test_image_ids_are_tracked_with_text 실패
뮤테이션 4 (clear_topic_state) → test_cleanup_cancels_typing_task 실패

미검증: 실제 텔레그램 대상 수동 검증(봇 재시작 필요).

머지 후 필요한 것

./scripts/restart.sh — 재시작 중 텔레그램 원격 제어가 잠깐 끊긴다.

⚠️ 동작 변경이 눈에 띈다: thinking / tool_use / tool_result / 로컬 명령 에코가 다음 메시지가 오면 삭제된다(#94). 턴이 끝나면 답변만 남는다. 마음에 안 들면 그 커밋(da02e0d·284cd4a·415b31e)만 되돌리면 된다.

🤖 Generated with Claude Code

billylui and others added 15 commits September 16, 2026 12:13
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>
@TejNote
TejNote merged commit 7a63873 into main Sep 16, 2026
@TejNote
TejNote deleted the feat/upstream-merge branch September 16, 2026 07:11
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants