diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c5c5f71..f6b028393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,115 @@ --- +## [1.1.0] - 2026-09-16 + +upstream 커뮤니티 PR 9건을 선별 머지한다. **upstream `main` 은 2026-07-08 이후 멈춰 있고 +이 fork 는 이미 완전 동기화 상태**라(`git rev-list --count <마지막머지>..upstream/main` = 0), +가져올 게 남아 있던 곳은 **머지되지 않은 커뮤니티 PR** 뿐이었다. + +upstream 의 미머지 브랜치 3개(`fix/prevent-window-rename` · `fix/restore-telegramify-markdown-default` +· `fix/scrub-sensitive-env`)는 `git cherry` 로 확인한 결과 **전부 이미 패치 동등하게 적용**돼 +있었다(SHA 만 다른 PR 잔해). `copilot/*` 는 main 이전의 폐기된 리팩터링이다. +`codex/codex-remote-agents`(+3,120줄)는 Codex 를 app-server WebSocket 원격 프로토콜로 다시 +붙이는 실험 브랜치라 이 fork 의 OMX 로컬 tmux 방식과 정면 충돌해 **제외**했다. + +### Fixed + +- **일시적 오류에도 평문으로 재전송해 메시지가 두 번 가던 것** (upstream #93) + - `except Exception` 하나로 전부 잡아 평문 재전송했다. `TimedOut`·`NetworkError` 는 + **이미 도착했을 수도 있는** 모호한 상태라, 재전송하면 서식본 + 평문본 2건이 된다 + - 이제 `BadRequest`(= 텔레그램이 서식을 거부 → 확실히 미전달)일 때만 평문으로 다시 보낸다 +- **접기 인용이 깨져 원문이 통째로 쏟아지던 것** (upstream #94) + - `_render_expandable_quote()` 가 `>line…||` 를 만들었는데, 텔레그램 문법은 첫 줄에 + `**>` 를 요구한다. 없으면 끝의 `||` 가 짝 없는 spoiler 라 `Can't parse entities` 로 + 전체가 거부되고, 폴백이 **서식 없는 평문 전문**을 그대로 보낸다 + - thinking · Bash/Grep/Glob/Task/WebFetch 결과 · 여러 줄 에러 · Edit diff 가 전부 이 경로다. + "N줄, 탭해서 펼치기" 대신 로그 원문이 매번 채팅에 떨어지고 있었다 +- **상태줄 아래 tip 블록이 끼면 상태를 놓치던 것** (upstream #97) + - 구분선 바로 윗줄만 보던 로직이 tip 블록에 막혔다. 위로 더 훑되, 인접하지 않은 위치에서는 + `…` 를 가진 줄만 상태로 인정한다(일반 출력의 `·` 불릿과 완료 표시 `✻ Sautéed for 7s` 제외) +- **타이핑 표시가 긴 구간에서 꺼지던 것** (upstream #98) + - 텔레그램은 chat action 을 약 5초 뒤 지운다. 상태 메시지를 보낼 때만 한 번 쏘던 방식이라 + 상태 텍스트가 안 바뀌는 동안 표시가 사라졌다. 이제 상태폴링이 4초 주기 keepalive 로 몬다 +- **포럼 토픽에서 "/" 메뉴가 비어 보이던 것** (upstream #99) + - 클라이언트는 그 채팅에 맞는 **스코프**를 읽고, 좁은 스코프가 기본을 이긴다. 옛 설정이 + 남긴 `all_private_chats` 목록 하나가 진짜 메뉴를 가렸다. 지울 땐 관리자 스코프까지 넓게, + 쓸 땐 실제로 쓰는 스코프 전부에 쓴다 +- **워크스페이스 신뢰 대화상자가 새 세션을 막던 것** (upstream #101) + - `claude` 실행 전 `~/.claude.json` 에 해당 디렉터리를 신뢰 표시한다. + 끄려면 `CCBOT_AUTO_TRUST_DIRS=false` +- **세션 이름이 박힌 테두리를 입력창 구분선으로 못 읽던 것** (upstream #101) +- **죽은 토픽 상태가 쌓이던 것** (upstream #81) — `/kill` 이 메뉴 힌트로만 등록돼 있어 핸들러 + 없이 Claude Code 로 흘러들어갔다. 실제 핸들러를 붙이고, 토픽이 `/kill` 밖에서 죽었을 때도 + 같은 정리 경로를 타게 했다 + +### Added + +- **보조 메시지 자동 정리** (upstream #94) — thinking · tool_use/tool_result · 로컬 명령 에코 · + 사용자 메시지 되울림이 영원히 쌓여 **진짜 답변을 묻었다.** 토픽별로 마지막 "보조" 메시지를 + 추적해 다음 메시지가 오면 지운다. 최종 답변은 추적도 삭제도 하지 않으므로, 턴이 끝나면 + 자연히 답변만 남는다. 병합 태스크·분할 전송은 메시지가 여러 개라 **id 목록**으로 추적한다 +- **타이머 상태 갱신 적응형 스로틀** (upstream #52) — 레이트 리밋 회피 +- **음성 전사 모델 설정** (upstream #91) — OpenAI 호환 백엔드용 + +### 이 fork 에 맞춰 손본 것 + +- #98 — upstream 은 chat action 에 thread 를 안 싣는다. 이 fork 는 토픽 모드가 기본이라 + `_send_kwargs` 로 `message_thread_id` 를 넘긴다. codex 분기도 같은 게이트를 탄다 +- #99 — upstream 은 `post_init` 2곳만 고쳤지만 이 fork 는 스킬 즐겨찾기 토글에서 런타임 + 재등록을 한 번 더 한다. 거기까지 같은 헬퍼를 태워야 토글 뒤 메뉴가 되돌아가지 않는다 +- #94 — upstream 은 콘텐츠 처리 끝에서 `_check_and_send_status` 를 인라인 호출한다. + 이 fork 는 상태 표시를 `status_polling` 에 넘기며 그 호출을 뺐으므로 되살리지 않았다 +- #81 + #93 — 죽은 토픽 정리(`_maybe_cleanup_dead_topic`)는 `BadRequest` 분기 안쪽에만 둔다. + `Topic_id_invalid`·`Message thread not found` 는 `BadRequest` 로 오고, `TimedOut` 으로 + 토픽을 지우면 네트워크가 흔들릴 때마다 멀쩡한 바인딩이 날아간다 +- #97 — fork 고유의 백그라운드 셸 필터(`_BACKGROUND_SHELL_RE`)를 보존했다 +- #99 — `CC_COMMANDS` 에 upstream 이 추가한 5개(agents·context·recap·status·tasks)를 넣되 + 설명은 이 fork 의 한국어 스타일로 맞췄다 + +### 머지 후 리뷰에서 잡아 고친 것 + +upstream 패치 자체는 맞지만 **이 fork 의 다른 부분과 겹쳐서 깨지는** 것들이다. +upstream 테스트로는 안 잡힌다. 전부 `tests/ccbot/test_upstream_merge_fixes.py` 로 고정했고, +수정을 되돌리는 뮤테이션 4종이 각각 해당 테스트 하나씩을 실패시키는 것까지 확인했다. + +- 🚨 **죽은 토픽을 정리해도 타이핑 keepalive 가 살아남아 무한 API 호출** — #81 의 + `cleanup_dead_topic` 은 세션 상태만 풀고 `clear_topic_state` 를 부르지 않는 **유일한** + 해제 경로였다(다른 셋은 전부 부른다). #98 이 붙기 전에는 단순 누수였지만, 붙고 나서는 + `_typing_keepalive` 가 `while True` 라 **텔레그램이 "그 토픽 없다" 고 답한 thread 로 + 4초마다 `send_chat_action` 을 영원히** 던지게 됐다. `cleanup_dead_topic` 이 정리한 + `(user_id, thread_id)` 목록을 돌려주도록 바꾸고 호출처에서 `clear_topic_state` 를 돌린다 +- **#97 의 8줄 스캔이 `·` 불릿을 상태줄로 읽는 구멍을 비인접 줄에 새로 열었다** — upstream 의 + 통과 조건이 `"…" in line` 하나뿐인데 `·` 는 스피너이면서 **평범한 불릿**이다. Claude 의 + 답변 본문 `· 첫째 항목은…` 이 상태줄이 되고, #98 이 그 값으로 타이핑을 켜므로 유휴 세션에 + 유령 상태 메시지 + 영구 타이핑이 남는다. 비인접 줄은 `…` **그리고** `·` 가 아닐 것을 요구한다. + ⚠️ 구분선 **바로 위** 불릿은 main 에도 있던 기존 오탐이라 손대지 않았다(실측 대조 확인) +- **퍼미션 프롬프트가 떠 있는 내내 "작업 중" 으로 보이던 것** — #98 의 `set_typing` 이 + `update_status_message` 맨 끝에 한 번뿐인데 그 위에 조기 `return` 이 셋이다. 프롬프트는 + 사람이 답할 때까지 몇 시간도 떠 있고, 실제로는 봇이 **사용자를 기다리는 중**이다. + 세 지점에서 타이핑을 끈다(큐가 찬 `skip_status` 는 제외 — 에이전트가 실제로 작업 중이다) +- **secondary 태스크의 이미지가 고아로 남던 것** — #94 가 모으는 id 에 `_send_task_images` + 가 보낸 사진이 빠져 있었다. `tool_result` 는 항상 secondary 라, 다음 메시지가 오면 + **설명 텍스트만 지워지고 스크린샷이 맥락 없이 남는다.** `send_photo` 가 message id 를 + 반환하게 하고 전송을 추적보다 먼저 한다 +- **콜백 응답이 최대 45초 지연되던 것** — `_create_and_bind_window` 가 `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초로 남아 있던 것** — #81 이 5초로 낮췄다. + ⚠️ 바인딩 1개당 API 호출이 12배가 되어 #52 의 스로틀과 방향이 반대다. **레이트 리밋을 + 실제로 맞은 관측이 없어** upstream 값을 유지하되, 근거와 되돌리는 값(15~20초)을 주석에 남겼다 + +### 검증 + +- `pytest` **440항 통과** (머지 전 400 → upstream 테스트 32 + 이 fork 의 회귀 8) +- `pyright src/ccbot/` **0 errors** +- `ruff check src/ tests/` 위반 **103 → 102**. 파일·규칙 단위로 대조해 **main 에 없던 조합은 0건**이고, + 유일한 증가는 `bot.py` BLE001 12 → 15(#81 `/kill` 핸들러 + #99 스코프 헬퍼 2개)로 그 파일의 기존 스타일과 같다 + +--- + ## [1.0.10] - 2026-09-04 libtmux 의 창·pane 열거를 우회해 `zip() argument 2 is shorter than argument 1` 을 없앤다. diff --git a/README.md b/README.md index cbb5675a0..f9d0e5f4a 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ ALLOWED_USERS=your_telegram_user_id | `CLAUDE_COMMAND` | `claude` | Command to run in new windows | | `MONITOR_POLL_INTERVAL` | `2.0` | Polling interval in seconds | | `CCBOT_SHOW_HIDDEN_DIRS` | `false` | Show hidden (dot) directories in directory browser | +| `CCBOT_AUTO_TRUST_DIRS` | `true` | Mark the project trusted in `~/.claude.json` before launching `claude`, so the workspace trust dialog doesn't block new sessions | | `OPENAI_API_KEY` | _(none)_ | OpenAI API key for voice message transcription | | `OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI API base URL (for proxies or compatible APIs) | | `CCBOT_BATCH_WINDOW` | `10` | 🔱 Seconds before MessageBatcher emits a summary (`0` to disable batching) | diff --git a/pyproject.toml b/pyproject.toml index 8649fabb6..c78203494 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ccbot" -version = "1.0.10" +version = "1.1.0" description = "Telegram Bot for monitoring Claude Code sessions" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/restart.sh b/scripts/restart.sh index 7c92a385b..a2ef002eb 100755 --- a/scripts/restart.sh +++ b/scripts/restart.sh @@ -167,6 +167,29 @@ else echo "No ccbot process running in $TARGET" fi +# Sweep any orphan ccbot processes elsewhere on the host (e.g. another tmux +# server). Multiple instances polling the same bot token compete for updates +# and silently break command routing. +ORPHAN_PIDS=$(pgrep -f '\.venv/bin/ccbot' | grep -vxF "$$" || true) +# Exclude pids inside our target pane's process tree (already handled above) +if [ -n "$ORPHAN_PIDS" ]; then + OWN_TREE=$(pstree -p "$PANE_PID" 2>/dev/null | grep -oE '\([0-9]+\)' | tr -d '()' || true) + for pid in $ORPHAN_PIDS; do + if ! echo "$OWN_TREE" | grep -qx "$pid"; then + echo "Killing orphan ccbot process $pid (outside $TARGET)..." + kill "$pid" 2>/dev/null || true + fi + done + sleep 1 + # Anything still alive — SIGKILL + for pid in $ORPHAN_PIDS; do + if kill -0 "$pid" 2>/dev/null; then + echo "Orphan $pid still alive, SIGKILL..." + kill -9 "$pid" 2>/dev/null || true + fi + done +fi + # Brief pause to let the shell settle sleep 1 diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 7fb1b3190..487001f57 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -41,6 +41,9 @@ from telegram import ( Bot, BotCommand, + BotCommandScopeAllChatAdministrators, + BotCommandScopeAllGroupChats, + BotCommandScopeAllPrivateChats, InlineKeyboardButton, InlineKeyboardMarkup, InputMediaDocument, @@ -57,6 +60,7 @@ filters, ) +from .claude_config import ensure_trusted_directory from .config import config from .skill_registry import SkillRegistry from .handlers.callback_data import ( @@ -155,12 +159,17 @@ # Claude Code commands shown in bot menu (forwarded via tmux) CC_COMMANDS: dict[str, str] = { + "agents": "↗ 서브에이전트 관리", "clear": "↗ 대화 기록 초기화", "compact": "↗ 컨텍스트 압축", + "context": "↗ 컨텍스트 사용량 확인", "cost": "↗ 토큰/비용 확인", "help": "↗ Claude Code 도움말", "memory": "↗ CLAUDE.md 편집", "model": "↗ AI 모델 전환", + "recap": "↗ 이 세션 요약", + "status": "↗ 세션 상태 확인", + "tasks": "↗ 백그라운드 작업·서브에이전트 목록", } _skill_registry: SkillRegistry | None = None @@ -227,6 +236,36 @@ } +# 클라이언트는 **그 채팅에 맞는 스코프**를 읽는다. 좁은 스코프가 기본 스코프를 이기므로, +# 옛 설정(BotFather·구버전)이 남긴 all_private_chats 목록 하나가 진짜 메뉴를 가린다. +# 그래서 지울 때는 관리자 스코프까지 넓게 지우고, 쓸 때는 실제로 쓰는 스코프에 다 쓴다. +# (upstream six-ddc/ccbot#99 — 포럼 토픽에서 "/" 버튼이 비어 보이던 문제) +_COMMAND_WRITE_SCOPES = ( + None, + BotCommandScopeAllPrivateChats, + BotCommandScopeAllGroupChats, +) +_COMMAND_CLEAR_SCOPES = _COMMAND_WRITE_SCOPES + (BotCommandScopeAllChatAdministrators,) + + +async def _clear_bot_commands(bot: Bot) -> None: + """Drop the command menu from every scope we might have written before.""" + for scope in _COMMAND_CLEAR_SCOPES: + try: + await bot.delete_my_commands(scope=scope() if scope else None) + except Exception as e: + logger.warning("delete_my_commands(%s) failed: %s", scope, e) + + +async def _publish_bot_commands(bot: Bot, commands: list[BotCommand]) -> None: + """Register the command menu on every scope the bot is used from.""" + for scope in _COMMAND_WRITE_SCOPES: + try: + await bot.set_my_commands(commands, scope=scope() if scope else None) + except Exception as e: + logger.warning("set_my_commands(%s) failed: %s", scope, e) + + def _build_bot_commands() -> list[BotCommand]: """Build the full list of bot commands: built-in + CC + skills.""" commands = [ @@ -378,6 +417,53 @@ async def unbind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) +async def kill_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Kill the tmux window for this topic and delete the topic itself.""" + user = update.effective_user + if not user or not is_user_allowed(user.id): + return + if not update.message: + return + + thread_id = _get_thread_id(update) + if thread_id is None: + await safe_reply(update.message, "❌ This command only works in a topic.") + return + + wid = session_manager.get_window_for_thread(user.id, thread_id) + if wid: + display = session_manager.get_display_name(wid) + w = await tmux_manager.find_window_by_id(wid) + if w: + await tmux_manager.kill_window(w.window_id) + logger.info( + "/kill: killed window %s (user=%d, thread=%d)", + display, + user.id, + thread_id, + ) + session_manager.unbind_thread(user.id, thread_id) + session_manager.purge_window(wid) + await session_manager.remove_session_map_entry(wid) + await clear_topic_state(user.id, thread_id, context.bot, context.user_data) + + chat = update.effective_chat + if chat is None: + await safe_reply(update.message, "✅ Session killed.") + return + + try: + await context.bot.delete_forum_topic( + chat_id=chat.id, message_thread_id=thread_id + ) + except Exception as e: + logger.warning("delete_forum_topic failed: %s", e) + await safe_reply( + update.message, + "✅ Session killed. Couldn't delete topic — close or delete it manually.", + ) + + async def esc_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Send Escape key to interrupt Claude.""" user = update.effective_user @@ -578,6 +664,8 @@ async def topic_closed_handler( thread_id, ) session_manager.unbind_thread(user.id, thread_id) + session_manager.purge_window(wid) + await session_manager.remove_session_map_entry(wid) # Clean up all memory state for this topic await clear_topic_state(user.id, thread_id, context.bot, context.user_data) else: @@ -1211,6 +1299,18 @@ async def _create_and_bind_window( assert isinstance(query, CallbackQuery) assert isinstance(user, User) + # 🚨 콜백 응답을 **먼저** 보낸다. 텔레그램은 answerCallbackQuery 를 약 15초 안에 + # 받아야 하고, 넘기면 "query is too old and response timeout expired" 를 낸다. + # 아래 경로는 wait_for_claude_ready(최대 30초) + wait_for_session_map_entry + # (5~15초) 를 거치므로 최악 45초다 — 예전엔 그 뒤에 응답해서, 창 하나가 느리면 + # 예외가 나고 그동안 사용자 버튼은 계속 로딩 상태였다. + # 결과(✅/❌)는 아래 safe_edit 가 메시지 본문으로 보여주므로 잃는 정보가 없다. + # answerCallbackQuery 는 쿼리당 1회라 함수 끝의 두 번째 호출은 없앴다. + await query.answer("Creating…") + + if config.auto_trust_dirs: + await asyncio.to_thread(ensure_trusted_directory, selected_path) + success, message, created_wname, created_wid = await tmux_manager.create_window( selected_path, resume_session_id=resume_session_id ) @@ -1224,6 +1324,12 @@ async def _create_and_bind_window( pending_thread_id, resume_session_id, ) + # Wait for Claude Code's input box first. This answers the workspace + # trust dialog if it still appears; the SessionStart hook only fires + # once Claude is past it, and a pending message must not be typed + # into the dialog (Enter on the default option exits Claude). + await tmux_manager.wait_for_claude_ready(created_wid, timeout=30.0) + # Wait for Claude Code's SessionStart hook to register in session_map. # Resume sessions take longer to start (loading session state), so use # a longer timeout to avoid silently dropping messages. @@ -1321,7 +1427,6 @@ async def _create_and_bind_window( if pending_thread_id is not None and context.user_data is not None: context.user_data.pop("_pending_thread_id", None) context.user_data.pop("_pending_thread_text", None) - await query.answer("Created" if success else "Failed") # --- Callback query handler --- @@ -1770,7 +1875,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - ) # Re-register commands with new order new_commands = _build_bot_commands() - await context.bot.set_my_commands(new_commands) + await _publish_bot_commands(context.bot, new_commands) else: await query.answer("Unknown skill") @@ -2013,6 +2118,12 @@ async def handle_new_message(msg: NewMessage, bot: Bot) -> None: # Enqueue content message task # Note: tool_result editing is handled inside _process_content_task # to ensure sequential processing with tool_use message sending + # + # Everything except the final assistant text answer (thinking, + # tool_use, tool_result, local_command, user-message echo) is + # "secondary" — it gets deleted once the next message for this + # topic arrives, so only the actual answer is left behind. + is_secondary = not (msg.role == "assistant" and msg.content_type == "text") await enqueue_content_message( bot=bot, user_id=user_id, @@ -2023,6 +2134,7 @@ async def handle_new_message(msg: NewMessage, bot: Bot) -> None: text=msg.text, thread_id=thread_id, image_data=msg.image_data, + is_secondary=is_secondary, ) # Update user's read offset to current file position @@ -2042,7 +2154,7 @@ async def handle_new_message(msg: NewMessage, bot: Bot) -> None: async def post_init(application: Application) -> None: global session_monitor, _status_poll_task, _skill_registry - await application.bot.delete_my_commands() + await _clear_bot_commands(application.bot) # Initialize skill registry and scan plugins _skill_registry = SkillRegistry( @@ -2052,7 +2164,7 @@ async def post_init(application: Application) -> None: _skill_registry.scan() bot_commands = _build_bot_commands() - await application.bot.set_my_commands(bot_commands) + await _publish_bot_commands(application.bot, bot_commands) # Delete status messages left over from the previous run (orphaned on restart) orphaned = session_manager.pop_all_status_msg_ids() @@ -2134,6 +2246,7 @@ def create_bot() -> Application: application.add_handler(CommandHandler("history", history_command)) application.add_handler(CommandHandler("screenshot", screenshot_command)) application.add_handler(CommandHandler("esc", esc_command)) + application.add_handler(CommandHandler("kill", kill_command)) application.add_handler(CommandHandler("unbind", unbind_command)) application.add_handler(CommandHandler("usage", usage_command)) application.add_handler(CommandHandler("favorite", favorite_command)) diff --git a/src/ccbot/claude_config.py b/src/ccbot/claude_config.py new file mode 100644 index 000000000..42e23250b --- /dev/null +++ b/src/ccbot/claude_config.py @@ -0,0 +1,101 @@ +"""Helpers for Claude Code's own config file (~/.claude.json). + +Pre-trusts a working directory before launching `claude` in it so the +interactive "Quick safety check … Yes, I trust this folder" dialog never +appears. Since ~2.1.260 that dialog defaults to "No, exit", which left +bot-created sessions stuck at the prompt with the SessionStart hook never +firing. + +Key functions: + - claude_config_file(): path of ~/.claude.json (honours CLAUDE_CONFIG_DIR) + - trust_key_for(work_dir): the projects[] key Claude uses (git root or dir) + - ensure_trusted_directory(work_dir): set hasTrustDialogAccepted=true +""" + +import json +import logging +import os +import subprocess +from pathlib import Path +from typing import Any + +from .utils import atomic_write_json + +logger = logging.getLogger(__name__) + + +def claude_config_file() -> Path: + """Location of Claude Code's user config JSON.""" + config_dir = os.environ.get("CLAUDE_CONFIG_DIR", "") + if config_dir: + return Path(config_dir).expanduser() / ".claude.json" + return Path.home() / ".claude.json" + + +def trust_key_for(work_dir: str | Path) -> str: + """Return the ``projects`` key Claude Code trusts ``work_dir`` under. + + Trust is keyed on the git repository root (main checkout root for + worktrees); outside a repo, on the directory itself. + """ + path = Path(work_dir).expanduser().resolve() + try: + result = subprocess.run( + ["git", "-C", str(path), "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + return str(Path(result.stdout.strip()).resolve()) + except (OSError, subprocess.SubprocessError): + pass + return str(path) + + +def ensure_trusted_directory(work_dir: str | Path) -> bool: + """Mark ``work_dir`` as trusted in ~/.claude.json. + + Returns True when the directory is trusted after the call (already + trusted or newly written), False when the config could not be updated + (missing/malformed file is left untouched — the pane-level dialog + handling in TmuxManager remains as the fallback). + """ + key = trust_key_for(work_dir) + cfg_path = claude_config_file() + if not cfg_path.exists(): + logger.info("No %s yet; skipping trust pre-accept for %s", cfg_path, key) + return False + try: + data: Any = json.loads(cfg_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + logger.warning("Cannot read %s (%s); not touching it", cfg_path, e) + return False + if not isinstance(data, dict): + logger.warning("%s is not a JSON object; not touching it", cfg_path) + return False + + projects = data.get("projects") + if projects is None: + projects = data["projects"] = {} + if not isinstance(projects, dict): + logger.warning("%s: 'projects' is not an object; not touching it", cfg_path) + return False + + entry = projects.get(key) + if isinstance(entry, dict) and entry.get("hasTrustDialogAccepted") is True: + return True + if not isinstance(entry, dict): + entry = projects[key] = {} + entry["hasTrustDialogAccepted"] = True + + try: + mode = cfg_path.stat().st_mode & 0o777 + atomic_write_json(cfg_path, data) + os.chmod(cfg_path, mode) + except OSError as e: + logger.warning("Failed to write %s: %s", cfg_path, e) + return False + logger.info("Pre-trusted %s in %s", key, cfg_path) + return True diff --git a/src/ccbot/config.py b/src/ccbot/config.py index 1f3572efc..f008c46f5 100644 --- a/src/ccbot/config.py +++ b/src/ccbot/config.py @@ -84,6 +84,26 @@ def __init__(self) -> None: self.monitor_poll_interval = float(os.getenv("MONITOR_POLL_INTERVAL", "2.0")) + # Adaptive throttle for timer-based status lines. + # Three comma-separated intervals (seconds) for elapsed-time tiers: + # tier 1 (0–10 s), tier 2 (10–60 s), tier 3 (60 s+) + # Default "1,5,30" means: real-time for first 10 s, then every 5 s, + # then every 30 s. Set to "1,1,1" to disable throttling. + self.status_throttle_intervals: tuple[float, float, float] = (1.0, 5.0, 30.0) + raw = os.getenv("STATUS_THROTTLE_INTERVALS", "") + if raw: + try: + parts = [float(x.strip()) for x in raw.split(",")] + if len(parts) != 3 or any(p < 0 for p in parts): + raise ValueError("need exactly 3 non-negative numbers") + self.status_throttle_intervals = (parts[0], parts[1], parts[2]) + except ValueError as e: + logger.warning( + "Invalid STATUS_THROTTLE_INTERVALS=%r (%s), using defaults", + raw, + e, + ) + # Display user messages in history and real-time notifications # When True, user messages are shown with a 👤 prefix self.show_user_messages = ( @@ -100,6 +120,12 @@ def __init__(self) -> None: # Set to 0.0 to disable batching (sends each message individually) self.batch_window = float(os.getenv("CCBOT_BATCH_WINDOW", "0.0")) + # Pre-trust directories in ~/.claude.json before launching claude, + # so Claude Code's workspace trust dialog doesn't block new sessions + self.auto_trust_dirs = ( + os.getenv("CCBOT_AUTO_TRUST_DIRS", "true").lower() != "false" + ) + # Show hidden (dot) directories in directory browser self.show_hidden_dirs = ( os.getenv("CCBOT_SHOW_HIDDEN_DIRS", "").lower() == "true" @@ -110,6 +136,12 @@ def __init__(self) -> None: self.openai_base_url: str = os.getenv( "OPENAI_BASE_URL", "https://api.openai.com/v1" ) + # Transcription model — override when pointing OPENAI_BASE_URL at an + # OpenAI-compatible backend that serves different model names (e.g. a + # local Whisper server such as Speaches). Default: OpenAI's hosted model. + self.transcribe_model: str = os.getenv( + "CCBOT_TRANSCRIBE_MODEL", "gpt-4o-transcribe" + ) # Scrub sensitive vars from os.environ so child processes never inherit them. # Values are already captured in Config attributes above. diff --git a/src/ccbot/handlers/cleanup.py b/src/ccbot/handlers/cleanup.py index cd7e75805..4751841be 100644 --- a/src/ccbot/handlers/cleanup.py +++ b/src/ccbot/handlers/cleanup.py @@ -12,7 +12,11 @@ from telegram import Bot from .interactive_ui import clear_interactive_msg -from .message_queue import clear_status_msg_info, clear_tool_msg_ids_for_topic +from .message_queue import ( + clear_secondary_msg_info, + clear_status_msg_info, + clear_tool_msg_ids_for_topic, +) async def clear_topic_state( @@ -30,6 +34,7 @@ async def clear_topic_state( Cleans up: - _status_msg_info (status message tracking) - _tool_msg_ids (tool_use → message_id mapping) + - _secondary_msg_info (last deletable secondary message per topic) - _interactive_msgs and _interactive_mode (interactive UI state) - user_data pending state (_pending_thread_id, _pending_thread_text) """ @@ -39,6 +44,9 @@ async def clear_topic_state( # Clear tool message ID tracking clear_tool_msg_ids_for_topic(user_id, thread_id) + # Clear secondary-message tracking + clear_secondary_msg_info(user_id, thread_id) + # Clear interactive UI state (also deletes message from chat) await clear_interactive_msg(user_id, bot, thread_id) diff --git a/src/ccbot/handlers/message_queue.py b/src/ccbot/handlers/message_queue.py index de3eb0f0b..5031b90c6 100644 --- a/src/ccbot/handlers/message_queue.py +++ b/src/ccbot/handlers/message_queue.py @@ -15,6 +15,10 @@ - Message queue worker: Background task processing user's queue - Content task processing with tool_use/tool_result handling - Status message tracking and conversion (keyed by (user_id, thread_id)) + - Secondary message tracking: the last non-final (thinking/tool_use/ + tool_result/local_command/user-echo) message per topic is deleted as + soon as the next message arrives, so only the final assistant answer + is left behind once a turn completes. """ import asyncio @@ -64,6 +68,10 @@ class MessageTask: content_type: str = "text" thread_id: int | None = None # Telegram topic thread_id for targeted send image_data: list[tuple[str, bytes]] | None = None # From tool_result images + # True for everything except the final assistant text answer (thinking, + # tool_use, tool_result, local_command, user-message echo). Secondary + # messages are deleted as soon as the next message for the topic arrives. + is_secondary: bool = False @dataclass @@ -95,6 +103,19 @@ class DirectMessage: # Status message tracking: (user_id, thread_id_or_0) -> (message_id, window_id, last_text) _status_msg_info: dict[tuple[int, int], tuple[int, str, str]] = {} +# Typing keepalive tasks: (user_id, thread_id_or_0) -> repeating chat-action task +_typing_tasks: dict[tuple[int, int], asyncio.Task[None]] = {} + +# Telegram clears a chat action after ~5s, so refresh just under that. +TYPING_REFRESH_INTERVAL = 4.0 + +# Last secondary (non-final) content message(s) per topic, deleted once the +# next message arrives: (user_id, thread_id_or_0) -> (message_ids, window_id). +# A list because merged tasks (several secondary entries queued together get +# folded into one MessageTask by _merge_content_tasks) or a paginated +# local_command can produce more than one Telegram message per task. +_secondary_msg_info: dict[tuple[int, int], tuple[list[int], str]] = {} + # Flood control: user_id -> monotonic time when ban expires _flood_until: dict[int, float] = {} @@ -153,7 +174,9 @@ def _can_merge_tasks(base: MessageTask, candidate: MessageTask) -> bool: return False if candidate.content_type in ("tool_use", "tool_result"): return False - return True + # Never fold a secondary (deletable) message together with the final + # answer — the merged task would inherit only one is_secondary value. + return base.is_secondary == candidate.is_secondary async def _merge_content_tasks( @@ -218,6 +241,7 @@ async def _merge_content_tasks( tool_use_id=first.tool_use_id, content_type=first.content_type, thread_id=first.thread_id, + is_secondary=first.is_secondary, ), merge_count, ) @@ -353,16 +377,16 @@ def _send_kwargs(thread_id: int | None) -> dict[str, Any]: return {} -async def _send_task_images(bot: Bot, chat_id: int, task: MessageTask) -> None: - """Send images attached to a task, if any.""" +async def _send_task_images(bot: Bot, chat_id: int, task: MessageTask) -> list[int]: + """Send images attached to a task, if any. Returns their message ids.""" if not task.image_data: - return + return [] logger.info( "Sending %d image(s) in thread %s", len(task.image_data), task.thread_id, ) - await send_photo( + return await send_photo( bot, chat_id, task.image_data, @@ -449,9 +473,38 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No logger.debug(f"Failed to edit tool msg {edit_msg_id}, sending new") # Fall through to send as new message - # 2. Send content messages, converting status message to first content part + # 2. A new message is arriving for this topic — delete whatever secondary + # (non-final) message was left over from the previous one. Applies + # whether this new task is itself secondary or the final answer: either + # way the old one is now superseded and safe to remove. + # + # Remaining (not-yet-deleted) ids are written back before re-raising + # RetryAfter, so _process_content_with_retry's retry of this whole + # task resumes deleting exactly where it left off instead of losing + # track of ids already popped — same reasoning as for a single id, + # just extended to a list (see the flood-control regression test). + skey = (user_id, tid) + old_secondary = _secondary_msg_info.get(skey) + if old_secondary: + old_msg_ids, old_wid = old_secondary + remaining_ids = list(old_msg_ids) + while remaining_ids: + old_msg_id = remaining_ids[0] + try: + await bot.delete_message(chat_id=chat_id, message_id=old_msg_id) + remaining_ids.pop(0) + except RetryAfter: + _secondary_msg_info[skey] = (remaining_ids, old_wid) + raise + except Exception as e: + logger.debug("Failed to delete secondary message %d: %s", old_msg_id, e) + remaining_ids.pop(0) + _secondary_msg_info.pop(skey, None) + + # 3. Send content messages, converting status message to first content part first_part = True last_msg_id: int | None = None + sent_msg_ids: list[int] = [] for part in task.parts: sent = None @@ -467,6 +520,7 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No ) if converted_msg_id is not None: last_msg_id = converted_msg_id + sent_msg_ids.append(converted_msg_id) continue sent = await send_with_fallback( @@ -478,16 +532,32 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No if sent: last_msg_id = sent.message_id + sent_msg_ids.append(sent.message_id) - # 3. Record tool_use message ID for later editing + # 4. Record tool_use message ID for later editing if last_msg_id and task.tool_use_id and task.content_type == "tool_use": _tool_msg_ids[(task.tool_use_id, user_id, tid)] = last_msg_id - # 4. Send images if present (from tool_result with base64 image blocks) - await _send_task_images(bot, chat_id, task) + # 5. Track every message sent for this task as the topic's current + # secondary message(s), so all of them get deleted once the next + # message (of any kind) arrives — not just the last one. A merged + # task (several secondary entries queued together, folded into one + # MessageTask by _merge_content_tasks) or a paginated local_command + # can produce more than one Telegram message here. + # 6. Send images if present (from tool_result with base64 image blocks) + # 🚨 이미지를 **먼저 보내고** 그 id 까지 함께 추적한다. 예전 순서(추적 → 전송)에서는 + # 사진 id 가 목록에 안 들어가, 다음 메시지가 오면 **설명 텍스트만 지워지고 + # 스크린샷이 맥락 없이 남았다.** tool_result 는 항상 is_secondary=True 라 + # 스크린샷을 찍는 세션에서 매번 재현된다. + sent_msg_ids.extend(await _send_task_images(bot, chat_id, task)) + + if sent_msg_ids and task.is_secondary: + _secondary_msg_info[skey] = (sent_msg_ids, wid) # Status display is delegated to status_polling (1s interval) so the answer # always remains the last visible message until polling detects working state. + # 🔎 upstream 은 여기서 _check_and_send_status 를 인라인 호출한다(#94 도 그 자리를 + # 건드린다). 이 fork 는 그 호출을 의도적으로 뺐으므로 되살리지 않는다. async def _convert_status_to_content( @@ -502,6 +572,7 @@ async def _convert_status_to_content( Returns the message_id if converted successfully, None otherwise. """ skey = (user_id, thread_id_or_0) + set_typing(bot, user_id, thread_id_or_0 or None, False) info = _status_msg_info.pop(skey, None) if not info: return None @@ -549,6 +620,42 @@ async def _convert_status_to_content( return None +def set_typing(bot: Bot, user_id: int, thread_id: int | None, active: bool) -> None: + """Light or clear the typing indicator for a session. + + Status polling is the single caller that knows whether Claude is working, + so it drives this. Telegram expires a chat action after ~5s, so an active + session needs it re-sent on a timer rather than once per state change. + """ + skey = (user_id, thread_id or 0) + if active: + if skey not in _typing_tasks: + chat_id = session_manager.resolve_chat_id(user_id, thread_id) + _typing_tasks[skey] = asyncio.create_task( + _typing_keepalive(bot, chat_id, thread_id) + ) + return + task = _typing_tasks.pop(skey, None) + if task: + task.cancel() + + +async def _typing_keepalive(bot: Bot, chat_id: int, thread_id: int | None) -> None: + """Re-send the typing chat action until cancelled.""" + while True: + try: + await bot.send_chat_action( + chat_id=chat_id, + action=ChatAction.TYPING, + **_send_kwargs(thread_id), # type: ignore[arg-type] + ) + except asyncio.CancelledError: + raise + except Exception as e: + logger.debug(f"typing keepalive failed for chat {chat_id}: {e}") + await asyncio.sleep(TYPING_REFRESH_INTERVAL) + + async def _process_status_update_task( bot: Bot, user_id: int, task: MessageTask ) -> None: @@ -578,16 +685,7 @@ async def _process_status_update_task( return else: # Same window, text changed - edit in place - # Send typing indicator when Claude is working - if "esc to interrupt" in status_text.lower(): - try: - await bot.send_chat_action( - chat_id=chat_id, action=ChatAction.TYPING - ) - except RetryAfter: - raise - except Exception: - pass + # (타이핑 표시는 set_typing 의 keepalive 가 담당한다 — upstream #98) try: await bot.edit_message_text( chat_id=chat_id, @@ -638,14 +736,6 @@ async def _do_send_status_message( await bot.delete_message(chat_id=chat_id, message_id=old[0]) except Exception: pass - # Send typing indicator when Claude is working - if "esc to interrupt" in text.lower(): - try: - await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) - except RetryAfter: - raise - except Exception: - pass sent = await send_with_fallback( bot, chat_id, @@ -712,6 +802,7 @@ async def enqueue_content_message( text: str | None = None, thread_id: int | None = None, image_data: list[tuple[str, bytes]] | None = None, + is_secondary: bool = False, ) -> None: """Enqueue a content message task.""" logger.debug( @@ -731,6 +822,7 @@ async def enqueue_content_message( content_type=content_type, thread_id=thread_id, image_data=image_data, + is_secondary=is_secondary, ) queue.put_nowait(task) @@ -801,6 +893,15 @@ def clear_status_msg_info(user_id: int, thread_id: int | None = None) -> None: """Clear status message tracking for a user (and optionally a specific thread).""" skey = (user_id, thread_id or 0) _status_msg_info.pop(skey, None) + typing = _typing_tasks.pop(skey, None) + if typing: + typing.cancel() + + +def clear_secondary_msg_info(user_id: int, thread_id: int | None = None) -> None: + """Clear secondary-message tracking for a user (and optionally a specific thread).""" + skey = (user_id, thread_id or 0) + _secondary_msg_info.pop(skey, None) def clear_tool_msg_ids_for_topic(user_id: int, thread_id: int | None = None) -> None: @@ -819,6 +920,9 @@ def clear_tool_msg_ids_for_topic(user_id: int, thread_id: int | None = None) -> async def shutdown_workers() -> None: """Stop all queue workers (called during bot shutdown).""" + for _, typing in list(_typing_tasks.items()): + typing.cancel() + _typing_tasks.clear() for _, worker in list(_queue_workers.items()): worker.cancel() try: diff --git a/src/ccbot/handlers/message_sender.py b/src/ccbot/handlers/message_sender.py index d54cd1396..8e21f8e16 100644 --- a/src/ccbot/handlers/message_sender.py +++ b/src/ccbot/handlers/message_sender.py @@ -1,7 +1,10 @@ """Safe message sending helpers with MarkdownV2 fallback. Provides utility functions for sending Telegram messages with automatic -format conversion and fallback to plain text on failure. +format conversion, falling back to plain text only when Telegram rejects the +formatting (BadRequest) — never on an ambiguous transient error such as +TimedOut, which may already have been delivered and would otherwise produce a +duplicate message. Uses telegramify-markdown for MarkdownV2 formatting. @@ -21,7 +24,7 @@ from typing import Any from telegram import Bot, InputMediaPhoto, LinkPreviewOptions, Message -from telegram.error import RetryAfter +from telegram.error import BadRequest, RetryAfter from ..markdown_v2 import convert_markdown from ..transcript_parser import TranscriptParser @@ -51,6 +54,38 @@ def _ensure_formatted(text: str) -> str: NO_LINK_PREVIEW = LinkPreviewOptions(is_disabled=True) +# Substrings in Telegram errors that mean the topic/thread is gone +_TOPIC_GONE_MARKERS = ("Topic_id_invalid", "Message thread not found") + + +async def _maybe_cleanup_dead_topic( + chat_id: int, kwargs: dict[str, Any], err: BaseException +) -> None: + """If the error indicates a dead forum topic, tear down the binding. + + Called from send fallbacks after both attempts have failed. Lazy-imports + session_manager to avoid a circular import at module load. + """ + msg = str(err) + if not any(m in msg for m in _TOPIC_GONE_MARKERS): + return + thread_id = kwargs.get("message_thread_id") + if thread_id is None: + return + from ..session import session_manager # lazy: avoid circular import + from .cleanup import clear_topic_state # lazy: avoid circular import + + # 🚨 세션 상태만 푸는 것으로 끝나지 않는다. 핸들러 쪽 토픽별 추적(상태 메시지 · + # tool id · secondary id · **타이핑 keepalive 태스크**)은 여기서 안 지우면 그대로 + # 남고, _typing_keepalive 는 while True 라 취소 전엔 죽지 않는다 — 텔레그램이 + # "그 토픽 없다" 고 답한 thread 로 4초마다 send_chat_action 을 영원히 던지게 된다. + # 다른 세 해제 경로(status_polling 2곳 · /kill · topic_closed)는 전부 부른다. + for user_id, t_id in await session_manager.cleanup_dead_topic( + int(chat_id), int(thread_id) + ): + await clear_topic_state(user_id, t_id) + + async def send_with_fallback( bot: Bot, chat_id: int, @@ -72,7 +107,9 @@ async def send_with_fallback( ) except RetryAfter: raise - except Exception: + except BadRequest: + # Telegram rejected the formatted message (e.g. invalid MarkdownV2), so + # it was NOT delivered — retry once as plain text. try: return await bot.send_message( chat_id=chat_id, text=strip_sentinels(text), **kwargs @@ -81,7 +118,18 @@ async def send_with_fallback( raise except Exception as e: logger.error(f"Failed to send message to {chat_id}: {e}") + await _maybe_cleanup_dead_topic(chat_id, kwargs, e) return None + except Exception as e: + # Any other error (TimedOut, NetworkError, ...) is ambiguous: on a slow + # or flaky connection the formatted send may already have reached + # Telegram before the client raised. Retrying would deliver a duplicate + # (one formatted copy + one plain), so log and give up instead. + logger.warning( + f"Send to {chat_id} raised {type(e).__name__}; not retrying to " + f"avoid a possible duplicate: {e}" + ) + return None async def send_photo( @@ -89,11 +137,16 @@ async def send_photo( chat_id: int, image_data: list[tuple[str, bytes]], **kwargs: Any, -) -> None: +) -> list[int]: """Send photo(s) to chat. Sends as media group if multiple images. Rate limiting is handled globally by AIORateLimiter on the Application. + Returns the message ids actually sent (empty on failure) so callers can + track them — a tool_result's screenshot belongs to the same "secondary" + message group as its text, and an untracked photo outlives the text that + explained it. + Args: bot: Telegram Bot instance chat_id: Target chat ID @@ -101,29 +154,31 @@ async def send_photo( **kwargs: Extra kwargs passed to send_photo/send_media_group """ if not image_data: - return + return [] try: if len(image_data) == 1: _media_type, raw_bytes = image_data[0] - await bot.send_photo( + sent = await bot.send_photo( chat_id=chat_id, photo=io.BytesIO(raw_bytes), **kwargs, ) - else: - media = [ - InputMediaPhoto(media=io.BytesIO(raw_bytes)) - for _media_type, raw_bytes in image_data - ] - await bot.send_media_group( - chat_id=chat_id, - media=media, - **kwargs, - ) + return [sent.message_id] + media = [ + InputMediaPhoto(media=io.BytesIO(raw_bytes)) + for _media_type, raw_bytes in image_data + ] + msgs = await bot.send_media_group( + chat_id=chat_id, + media=media, + **kwargs, + ) + return [m.message_id for m in msgs] except RetryAfter: raise except Exception as e: logger.error("Failed to send photo to %d: %s", chat_id, e) + return [] async def safe_reply(message: Message, text: str, **kwargs: Any) -> Message: @@ -137,7 +192,8 @@ async def safe_reply(message: Message, text: str, **kwargs: Any) -> Message: ) except RetryAfter: raise - except Exception: + except BadRequest: + # Formatting rejected → not delivered; retry once as plain text. try: return await message.reply_text(strip_sentinels(text), **kwargs) except RetryAfter: @@ -145,6 +201,14 @@ async def safe_reply(message: Message, text: str, **kwargs: Any) -> Message: except Exception as e: logger.error(f"Failed to reply: {e}") raise + except Exception as e: + # Ambiguous error (e.g. TimedOut): the formatted reply may already have + # been delivered. Don't retry (avoids a duplicate); propagate instead. + logger.warning( + f"Reply raised {type(e).__name__}; not retrying to avoid a " + f"possible duplicate: {e}" + ) + raise async def safe_edit(target: Any, text: str, **kwargs: Any) -> None: @@ -158,13 +222,22 @@ async def safe_edit(target: Any, text: str, **kwargs: Any) -> None: ) except RetryAfter: raise - except Exception: + except BadRequest: + # Formatting rejected → the edit was not applied; retry as plain text. try: await target.edit_message_text(strip_sentinels(text), **kwargs) except RetryAfter: raise except Exception as e: logger.error("Failed to edit message: %s", e) + except Exception as e: + # Ambiguous error (e.g. TimedOut): the edit may already have applied. + # Don't retry — a plain-text retry would drop the formatting. + logger.warning( + "Edit raised %s; not retrying to avoid clobbering formatting: %s", + type(e).__name__, + e, + ) async def safe_send( @@ -187,7 +260,8 @@ async def safe_send( ) except RetryAfter: raise - except Exception: + except BadRequest: + # Formatting rejected → not delivered; retry once as plain text. try: await bot.send_message( chat_id=chat_id, text=strip_sentinels(text), **kwargs @@ -196,3 +270,15 @@ async def safe_send( raise except Exception as e: logger.error(f"Failed to send message to {chat_id}: {e}") + await _maybe_cleanup_dead_topic(chat_id, kwargs, e) + except Exception as e: + # Ambiguous error (e.g. TimedOut): the formatted send may already have + # reached Telegram. Retrying would deliver a duplicate, so don't. + # 🔎 죽은 토픽 정리는 여기서 부르지 않는다 — Topic_id_invalid · + # Message thread not found 는 BadRequest 로 오므로 위 분기에서 잡힌다. + # TimedOut·NetworkError 로 토픽을 지우면 네트워크가 흔들릴 때마다 + # 멀쩡한 바인딩이 날아간다. + logger.warning( + f"Send to {chat_id} raised {type(e).__name__}; not retrying to " + f"avoid a possible duplicate: {e}" + ) diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py index 4fdfbbf1b..61243539b 100644 --- a/src/ccbot/handlers/status_polling.py +++ b/src/ccbot/handlers/status_polling.py @@ -8,23 +8,29 @@ - Periodically probes topic existence via unpin_all_forum_topic_messages (silent no-op when no pins); cleans up deleted topics (kills tmux window + unbinds thread) + - Adaptive throttle for timer-based status lines to avoid Telegram rate limits Key components: - STATUS_POLL_INTERVAL: Polling frequency (1 second) - - TOPIC_CHECK_INTERVAL: Topic existence probe frequency (60 seconds) + - TOPIC_CHECK_INTERVAL: Topic existence probe frequency (5 seconds) - status_poll_loop: Background polling task - update_status_message: Poll and enqueue status updates + - _should_send_status: Adaptive throttle for timer status updates """ import asyncio import logging +import re import time from telegram import Bot from telegram.error import BadRequest +from ..config import config from ..session import session_manager from ..terminal_parser import ( + AUTO_ANSWER_DIALOGS, + extract_interactive_content, is_interactive_ui, parse_codex_status_line, parse_status_line, @@ -36,15 +42,108 @@ handle_interactive_ui, ) from .cleanup import clear_topic_state -from .message_queue import enqueue_status_update, get_message_queue +from .message_queue import enqueue_status_update, get_message_queue, set_typing logger = logging.getLogger(__name__) -# Status polling interval -STATUS_POLL_INTERVAL = 1.0 # seconds - faster response (rate limiting at send layer) +# Status polling interval — kept at 1s for fast interactive UI detection. +# Timer-based status updates are throttled adaptively by _should_send_status(). +STATUS_POLL_INTERVAL = 1.0 # seconds -# Topic existence probe interval -TOPIC_CHECK_INTERVAL = 60.0 # seconds +# Topic existence probe interval. +# ⚠️ upstream #81 이 60초 → 5초로 낮췄다. 목적은 DM 모드에서 삭제된 토픽을 빨리 잡는 +# 것인데(private chat 은 unpinAllForumTopicMessages 가 아무 thread_id 에나 ok=true 를 +# 준다), 바인딩 1개당 API 호출이 **12배**가 된다 — 토픽 6개면 분당 72회다. +# 같은 브랜치의 #52 가 "레이트 리밋 때문에 봇이 장시간 침묵한다" 며 상태 업데이트를 +# 스로틀하는 것과 방향이 반대라 서로 상쇄될 수 있다. +# 지금은 upstream 값을 그대로 둔다 — **레이트 리밋을 실제로 맞은 관측이 없다.** +# 맞으면 여기를 15~20초로 올린다(죽은 토픽은 전송 실패 경로의 +# _maybe_cleanup_dead_topic 이 이미 즉시 잡으므로 이 프로브는 백스톱이다). +TOPIC_CHECK_INTERVAL = 5.0 # seconds + +# Substrings in BadRequest messages that indicate the topic is gone +_TOPIC_GONE_MARKERS = ("Topic_id_invalid", "Message thread not found") + +# ── Adaptive throttle for timer status lines ───────────────────────────── +# +# Claude Code shows a running timer in the status line (e.g. "Thinking… 5s", +# "Bash echo hello 1m 30s"). Without throttling, every tick produces a +# Telegram edit_message_text call (~60/min), which quickly hits Telegram's +# rate limit and causes the bot to go silent for extended periods. +# +# Strategy: detect timer suffixes, then increase the update interval the +# longer the same status persists. Default tiers (configurable via +# STATUS_THROTTLE_INTERVALS env var, comma-separated): +# 0–10 s → every 1 s (real-time, meaningful for short tasks) +# 10–60 s → every 5 s +# 60 s+ → every 30 s +# +# Non-timer status changes (e.g. "Reading file" → "Writing file") always +# send immediately. The poll interval itself stays at 1 s so interactive +# UI detection is never delayed. + +# Matches a timer in the status line. Claude Code uses two formats: +# 1. Bare suffix: "Thinking… 5s" or "Bash echo hello 1m 30s" +# 2. Inside parentheses: "Drizzling… (54s · ↓ 776 tokens)" +# "Coalescing… (25m 8s · ↓ 5.8k tokens · thought for 6s)" +# Both may include optional trailing text (parenthetical or · metadata). +_TIMER_RE = re.compile( + r"\s+(?:" + r"\((?:\d+m\s*)?\d+s\b[^)]*\)" # (54s · …) or (1m 30s · …) + r"|" + r"(?:(?:\d+m\s*)?\d+s|\d+m)" # bare 5s / 1m 30s / 2m + r"(?:\s+\(.*\))?" # optional trailing (Esc to interrupt) + r")\s*$" +) + +# (user_id, thread_id_or_0) → (base_text, first_seen, last_sent) +_timer_throttle: dict[tuple[int, int], tuple[str, float, float]] = {} + + +def _should_send_status( + user_id: int, thread_id: int | None, status_text: str +) -> bool: + """Decide whether a status update should be enqueued. + + For non-timer status lines, always returns True. + For timer status lines, applies adaptive interval based on elapsed time. + """ + key = (user_id, thread_id or 0) + now = time.monotonic() + + m = _TIMER_RE.search(status_text) + if not m: + # Not a timer — always send, clear any tracked state + _timer_throttle.pop(key, None) + return True + + # Extract base text (everything before the timer suffix) + base = status_text[: m.start()].rstrip() + + prev = _timer_throttle.get(key) + if prev is None or prev[0] != base: + # New status or base text changed — reset and send immediately + _timer_throttle[key] = (base, now, now) + return True + + _, first_seen, last_sent = prev + elapsed = now - first_seen + since_sent = now - last_sent + + # Adaptive interval: widen as the timer runs longer + t1, t2, t3 = config.status_throttle_intervals + if elapsed <= 10: + min_interval = t1 # real-time for the first 10 seconds + elif elapsed <= 60: + min_interval = t2 # every few seconds up to 1 minute + else: + min_interval = t3 # reduced frequency for long-running tasks + + if since_sent >= min_interval: + _timer_throttle[key] = (base, first_seen, now) + return True + + return False async def update_status_message( @@ -84,6 +183,11 @@ async def update_status_message( # User is in interactive mode for THIS window if is_interactive_ui(pane_text): # Interactive UI still showing — skip status update (user is interacting) + # 🚨 타이핑은 반드시 끈다. 여기서 빠져나가면 아래 set_typing 에 닿지 못해, + # 직전 폴링에서 켜진 표시가 4초마다 계속 나간다. 퍼미션 프롬프트는 + # 사람이 답할 때까지 몇 시간도 떠 있고, 그 내내 "작업 중" 으로 보인다. + # 실제로는 봇이 **사용자를 기다리는 중**이다. + set_typing(bot, user_id, thread_id, False) return # Interactive UI gone — clear interactive mode, fall through to status check. # Don't re-check for new UI this cycle (the old one just disappeared). @@ -94,6 +198,15 @@ async def update_status_message( # Clear stale interactive mode await clear_interactive_msg(user_id, bot, thread_id) + # Startup dialogs (workspace trust) are answered by the bot itself + # rather than rendered as a keyboard. + if should_check_new_ui: + ui = extract_interactive_content(pane_text) + if ui is not None and ui.name in AUTO_ANSWER_DIALOGS: + await tmux_manager.auto_answer_dialog(window_id, pane_text) + set_typing(bot, user_id, thread_id, False) # 위와 같은 이유 + return + # Check for permission prompt (interactive UI not triggered via JSONL) # ALWAYS check UI, regardless of skip_status if should_check_new_ui and is_interactive_ui(pane_text): @@ -104,9 +217,12 @@ async def update_status_message( thread_id, ) await handle_interactive_ui(bot, user_id, window_id, thread_id) + set_typing(bot, user_id, thread_id, False) # 위와 같은 이유 return # Normal status line check — skip if queue is non-empty + # 🔎 여기는 타이핑을 끄지 않는다 — 큐가 찬 일시적 상태이고, 에이전트는 실제로 + # 작업 중이다. 껐다 켜면 표시가 깜빡인다. if skip_status: return @@ -117,7 +233,14 @@ async def update_status_message( parse_codex_status_line(pane_text) if is_codex else parse_status_line(pane_text) ) + # A parsed status line means the agent is mid-turn — this is the one place + # that knows. Telegram clears a chat action after ~5s, so an active session + # needs it re-sent on a timer, not once per state change. (upstream #98) + set_typing(bot, user_id, thread_id, bool(status_line)) + if status_line: + if not _should_send_status(user_id, thread_id, status_line): + return await enqueue_status_update( bot, user_id, @@ -182,12 +305,14 @@ async def status_poll_loop(bot: Bot) -> None: message_thread_id=thread_id, ) except BadRequest as e: - if "Topic_id_invalid" in str(e): + if any(m in str(e) for m in _TOPIC_GONE_MARKERS): # Topic deleted — kill window, unbind, and clean up state w = await tmux_manager.find_window_by_id(wid) if w: await tmux_manager.kill_window(w.window_id) session_manager.unbind_thread(user_id, thread_id) + session_manager.purge_window(wid) + await session_manager.remove_session_map_entry(wid) await clear_topic_state(user_id, thread_id, bot) logger.info( "Topic deleted: killed window_id '%s' and " @@ -216,6 +341,8 @@ async def status_poll_loop(bot: Bot) -> None: w = await resolve_binding_window(user_id, thread_id, wid) if not w: session_manager.unbind_thread(user_id, thread_id) + session_manager.purge_window(wid) + await session_manager.remove_session_map_entry(wid) await clear_topic_state(user_id, thread_id, bot) logger.info( "Cleaned up stale binding: user=%d thread=%d window_id=%s", diff --git a/src/ccbot/markdown_v2.py b/src/ccbot/markdown_v2.py index d01bbdf86..ff3c7e7a8 100644 --- a/src/ccbot/markdown_v2.py +++ b/src/ccbot/markdown_v2.py @@ -124,6 +124,12 @@ def _escape_mdv2(text: str) -> str: def _render_expandable_quote(m: re.Match[str]) -> str: """Render an expandable blockquote block in raw MarkdownV2. + Telegram's expandable-quote grammar requires the first quoted line to + start with "**>" (not plain ">"); otherwise the trailing "||" is an + unmatched spoiler entity and the whole sendMessage call is rejected + with "Can't parse entities", silently degrading the message to + unformatted plain text via the caller's fallback path. + Truncates the rendered output to _EXPQUOTE_MAX_RENDERED chars to ensure the final message fits within Telegram's 4096 limit. """ @@ -148,9 +154,10 @@ def _render_expandable_quote(m: re.Match[str]) -> str: break built.append(f">{line}") total_len += line_cost - if truncated: - return "\n".join(built) + suffix - return "\n".join(built) + "||" + body = "\n".join(built) + (suffix if truncated else "||") + # Mark the block as expandable by promoting the first ">" to "**>". + idx = body.index(">") + return body[:idx] + "**" + body[idx:] def _markdownify(text: str) -> str: diff --git a/src/ccbot/session.py b/src/ccbot/session.py index 49f962f54..7f3ec531c 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -953,6 +953,92 @@ async def resolve_session_for_window(self, window_id: str) -> ClaudeSession | No self._save_state() return None + def purge_window(self, window_id: str) -> None: + """Remove all in-memory state for a tmux window after it is killed. + + Drops the window from window_states, window_display_names, and every + user's user_window_offsets, then persists state.json. Thread bindings + are left to the caller (unbind_thread is the inverse op there). + """ + changed = False + if window_id in self.window_states: + del self.window_states[window_id] + changed = True + if window_id in self.window_display_names: + del self.window_display_names[window_id] + changed = True + for uid, offsets in list(self.user_window_offsets.items()): + if window_id in offsets: + del offsets[window_id] + changed = True + if not offsets: + del self.user_window_offsets[uid] + if changed: + self._save_state() + logger.info("Purged window state for %s", window_id) + + async def cleanup_dead_topic( + self, chat_id: int, thread_id: int + ) -> list[tuple[int, int]]: + """Tear down any binding whose chat_id+thread_id matches a dead topic. + + Called from send-failure paths when Telegram returns "Message thread not + found" / "Topic_id_invalid". The topic-existence probe can miss this in + private chats (Telegram returns ok=true for unpinAllForumTopicMessages + on private chats with arbitrary thread_ids). + + 🚨 Returns the (user_id, thread_id) pairs it cleaned so the caller can + run clear_topic_state() on each. This method only unwinds *session* + state; the per-topic in-memory trackers in handlers/ (status message, + tool ids, secondary message ids, and the typing keepalive task) live + elsewhere and would otherwise survive. A surviving typing task is not a + harmless leak: _typing_keepalive loops forever, so the bot would keep + firing send_chat_action every 4s at a thread Telegram already told us is + gone. Upstream (#81) returned a bare bool and left that behind; the + other three unbind paths all call clear_topic_state. + """ + cleaned: list[tuple[int, int]] = [] + for user_id, t_id, wid in list(self.iter_thread_bindings()): + if t_id != thread_id: + continue + if self.resolve_chat_id(user_id, t_id) != chat_id: + continue + self.unbind_thread(user_id, t_id) + self.purge_window(wid) + await self.remove_session_map_entry(wid) + cleaned.append((user_id, t_id)) + logger.info( + "Auto-cleaned dead topic via send error: " + "user=%d thread=%d window_id=%s", + user_id, + t_id, + wid, + ) + return cleaned + + async def remove_session_map_entry(self, window_id: str) -> None: + """Delete the session_map.json entry for a window. + + session_map.json is owned by the Claude Code SessionStart hook, but + the hook does not emit a "session ended" event, so killed windows + leave orphan entries. We rewrite the file atomically. + """ + if not config.session_map_file.exists(): + return + try: + async with aiofiles.open(config.session_map_file, "r") as f: + content = await f.read() + session_map = json.loads(content) + except (json.JSONDecodeError, OSError): + return + + key = f"{config.tmux_session_name}:{window_id}" + if key not in session_map: + return + del session_map[key] + atomic_write_json(config.session_map_file, session_map) + logger.info("Removed session_map entry: %s", key) + # --- User window offset management --- def update_user_window_offset( diff --git a/src/ccbot/terminal_parser.py b/src/ccbot/terminal_parser.py index e8d542b5f..4bc5feae9 100644 --- a/src/ccbot/terminal_parser.py +++ b/src/ccbot/terminal_parser.py @@ -47,6 +47,17 @@ class UIPattern: # ── UI pattern definitions (order matters — first match wins) ──────────── UI_PATTERNS: list[UIPattern] = [ + UIPattern( + # Workspace trust dialog shown on first launch in a directory. + # Since ~2.1.260 the options are unnumbered and "No, exit" is the + # default, so an unanswered or blindly-confirmed dialog ends the session. + name="TrustDialog", + top=( + re.compile(r"^\s*Quick safety check"), + re.compile(r"^\s*Do you trust the files in this folder"), + ), + bottom=(re.compile(r"Enter to confirm"),), + ), UIPattern( name="ExitPlanMode", top=( @@ -122,6 +133,14 @@ class UIPattern: ] +# Dialogs the bot answers itself: name -> text of the option to select. +AUTO_ANSWER_DIALOGS: dict[str, str] = { + "TrustDialog": "Yes, I trust", +} + +_RE_MENU_CURSOR = re.compile(r"^\s*❯\s*\S") + + # ── Post-processing ────────────────────────────────────────────────────── _RE_LONG_DASH = re.compile(r"^─{5,}$") @@ -197,6 +216,65 @@ def is_interactive_ui(pane_text: str) -> bool: return extract_interactive_content(pane_text) is not None +def find_menu_option(pane_text: str, needle: str) -> tuple[int, int] | None: + """Locate the highlighted (``❯``) line and the option containing ``needle``. + + Returns ``(cursor_idx, option_idx)`` line indices; pressing Down + ``option_idx - cursor_idx`` times moves the cursor onto the option. + The option is searched from the bottom (dialogs render at the end of the + pane) and the cursor is the ``❯`` nearest to it, so an earlier shell + prompt such as ``❯ claude`` is never mistaken for the menu cursor. + """ + lines = pane_text.split("\n") + option_idx = next( + (i for i in range(len(lines) - 1, -1, -1) if needle in lines[i]), None + ) + if option_idx is None: + return None + lo, hi = max(0, option_idx - 10), min(len(lines), option_idx + 11) + cursor_idx = min( + (i for i in range(lo, hi) if _RE_MENU_CURSOR.match(lines[i])), + key=lambda i: abs(i - option_idx), + default=None, + ) + if cursor_idx is None: + return None + return cursor_idx, option_idx + + +def _is_separator(line: str) -> bool: + """A chrome border line. + + Named sessions (--resume , --name, /rename) put the name in the + input box's top border: "──────── my-session ─" (one trailing dash). + """ + stripped = line.strip() + if len(stripped) < 20 or not stripped.startswith("────"): + return False + return stripped.endswith("─") and stripped.count("─") / len(stripped) >= 0.6 + + +def is_prompt_ready(pane_text: str) -> bool: + """True when Claude Code shows its idle input box. + + Layout at the bottom of the pane:: + + ──────────────── (separator) + ❯ … (input line) + ──────────────── (separator) + ⏵⏵ … (footer) + """ + if not pane_text: + return False + tail = pane_text.rstrip().split("\n")[-8:] + for i in range(len(tail) - 2): + if not _is_separator(tail[i]) or not tail[i + 1].lstrip().startswith("❯"): + continue + if any(_is_separator(tail[j]) for j in range(i + 2, min(i + 5, len(tail)))): + return True + return False + + # ── Status line parsing ───────────────────────────────────────────────── # Spinner characters Claude Code uses in its status line @@ -267,10 +345,15 @@ def parse_codex_status_line(pane_text: str) -> str | None: def parse_status_line(pane_text: str) -> str | None: """Extract the Claude Code status line from terminal output. - The status line (spinner + working text) appears immediately above - the chrome separator (a full line of ``─`` characters). We locate - the separator first, then check the line just above it — this avoids - false positives from ``·`` bullets in Claude's regular output. + The status line (spinner + working text) appears above the chrome + separator (a full line of ``─`` characters). We locate the separator + first, then scan upward. The line *directly* above it counts as a status + line on the spinner alone; further up, Claude Code may have rendered a tip + block in between, so a spinner line only counts when it carries the ``…`` + of a live status **and** is not a plain ``·`` bullet. That pair of + conditions applies to non-adjacent lines only — directly above the + separator, ``✻ Sautéed for 7s`` is still returned as status (unchanged + behaviour). Returns the text after the spinner, or None if no status line found. @@ -297,12 +380,29 @@ def parse_status_line(pane_text: str) -> str | None: if chrome_idx is None: return None # No chrome visible — can't determine status - # Check lines just above the separator (skip blanks, up to 4 lines) - for i in range(chrome_idx - 1, max(chrome_idx - 5, -1), -1): + # Scan upward for the spinner line. Claude Code can render a tip block + # between the status line and the chrome, so keep looking past non-spinner + # lines (upstream six-ddc/ccbot#97). + # + # 🚨 Past the line directly above the separator the bar is higher, because + # prose lives up there. Two conditions, and both are needed: + # - "…" — the finished marker ("✻ Sautéed for 7s") has no ellipsis + # - not "·" — this is the one spinner glyph that is also an ordinary + # bullet. Claude's own answers are full of "· item one…" lines, and + # upstream's ellipsis-only rule let them through: a bullet three + # lines above the separator became the status text. That misfire is + # not cosmetic — status_polling lights the typing keepalive off this + # return value, so a stray bullet in an idle pane leaves a ghost + # status message and a typing indicator that never turns off. + # tests/ccbot/test_terminal_parser.py has guarded this since before + # the merge ("· in regular output must NOT be detected as status"). + adjacent = True + for i in range(chrome_idx - 1, max(chrome_idx - 9, -1), -1): line = lines[i].strip() if not line: continue - if line[0] in STATUS_SPINNERS: + live = "…" in line and line[0] != "·" + if line[0] in STATUS_SPINNERS and (adjacent or live): rest = line[1:].strip() if ( _BACKGROUND_SHELL_RE.search(rest) @@ -310,8 +410,7 @@ def parse_status_line(pane_text: str) -> str | None: ): return None return rest - # First non-empty line above separator isn't a spinner → no status - return None + adjacent = False return None diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py index 969f28a87..e1c759b41 100644 --- a/src/ccbot/tmux_manager.py +++ b/src/ccbot/tmux_manager.py @@ -18,12 +18,19 @@ import re import secrets import subprocess +import time from dataclasses import dataclass from pathlib import Path import libtmux from .config import SENSITIVE_ENV_VARS, config +from .terminal_parser import ( + AUTO_ANSWER_DIALOGS, + extract_interactive_content, + find_menu_option, + is_prompt_ready, +) logger = logging.getLogger(__name__) @@ -364,6 +371,65 @@ def _send_enter() -> bool: await asyncio.sleep(0.5) return await asyncio.to_thread(_send_enter) + async def auto_answer_dialog(self, window_id: str, pane_text: str) -> str | None: + """Answer a startup dialog the bot handles itself (workspace trust). + + Moves the ``❯`` cursor onto the wanted option with Up/Down, re-captures + to verify it landed there, then presses Enter. Never types a digit — + the option order has changed between Claude Code versions. + + Returns the dialog name when answered, else None. + """ + ui = extract_interactive_content(pane_text) + if ui is None or ui.name not in AUTO_ANSWER_DIALOGS: + return None + needle = AUTO_ANSWER_DIALOGS[ui.name] + offsets = find_menu_option(pane_text, needle) + if offsets is None: + logger.warning("%s in %s: option %r not found", ui.name, window_id, needle) + return None + cursor, target = offsets + steps = target - cursor + key = "Down" if steps > 0 else "Up" + for _ in range(abs(steps)): + await self.send_keys(window_id, key, enter=False, literal=False) + await asyncio.sleep(0.15) + if steps: + await asyncio.sleep(0.3) + check = find_menu_option(await self.capture_pane(window_id) or "", needle) + if check is None or check[0] != check[1]: + logger.warning( + "%s in %s: cursor did not reach %r, not confirming", + ui.name, + window_id, + needle, + ) + return None + await self.send_keys(window_id, "Enter", enter=False, literal=False) + logger.info("Answered %s in %s with %r", ui.name, window_id, needle) + return ui.name + + async def wait_for_claude_ready( + self, window_id: str, timeout: float = 30.0 + ) -> bool: + """Wait until Claude Code shows its input box, answering startup dialogs. + + Returns False on timeout (the caller still proceeds; this only avoids + typing the first message into a dialog or a half-started TUI). + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + pane = await self.capture_pane(window_id) + if pane: + if is_prompt_ready(pane): + return True + if await self.auto_answer_dialog(window_id, pane): + await asyncio.sleep(1.0) + continue + await asyncio.sleep(0.5) + logger.warning("Claude Code not ready in %s after %.0fs", window_id, timeout) + return False + async def send_keys( self, window_id: str, diff --git a/src/ccbot/transcribe.py b/src/ccbot/transcribe.py index 8e62a4cd2..8c5710c8d 100644 --- a/src/ccbot/transcribe.py +++ b/src/ccbot/transcribe.py @@ -1,7 +1,9 @@ """Voice-to-text transcription via OpenAI's audio API. -Provides a single async function to transcribe voice messages using -the gpt-4o-transcribe model. Uses httpx directly (no OpenAI SDK needed). +Provides a single async function to transcribe voice messages using a +configurable model (default: gpt-4o-transcribe). Works with any +OpenAI-compatible /audio/transcriptions endpoint. Uses httpx directly +(no OpenAI SDK needed). Key function: transcribe_voice(ogg_data) -> str """ @@ -38,7 +40,7 @@ async def transcribe_voice(ogg_data: bytes) -> str: url, headers={"Authorization": f"Bearer {config.openai_api_key}"}, files={"file": ("voice.ogg", ogg_data, "audio/ogg")}, - data={"model": "gpt-4o-transcribe"}, + data={"model": config.transcribe_model}, ) response.raise_for_status() diff --git a/tests/ccbot/fixtures/panes/ready_bypass.txt b/tests/ccbot/fixtures/panes/ready_bypass.txt new file mode 100644 index 000000000..15ab75f61 --- /dev/null +++ b/tests/ccbot/fixtures/panes/ready_bypass.txt @@ -0,0 +1,24 @@ + + ▐▛███▛█ Claude Code v2.1.272 +▝▜██████▀ Opus 5 with high effort · Claude Pro + ▝▝ ▝▝ /private/tmp + + + + + + + + + + + + + + + + ● high · /effort +──────────────────────────────────────────────────────────────────────────────── +❯  +──────────────────────────────────────────────────────────────────────────────── + ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents diff --git a/tests/ccbot/fixtures/panes/ready_labelled_separator.txt b/tests/ccbot/fixtures/panes/ready_labelled_separator.txt new file mode 100644 index 000000000..48ce047f2 --- /dev/null +++ b/tests/ccbot/fixtures/panes/ready_labelled_separator.txt @@ -0,0 +1,51 @@ + + ✕ +● Everything I need is done except committing the docs. Two things remain, both depending only on the + edit that just landed: a structural sanity check on the file, and the commit/push. I'll chain them so + the commit only runs if the file still parses as expected. + + Thought for 7s, committed 11d9edc, pushed to main + +● Nothing further needed — the work is complete and verified end to end. Both commits are pushed. + + Done: TON sweep floor 0.2 → 0.05 + + The change — MIN_TRANSFER_THRESHOLD: "0.05" on the x-ton-shkeeper-env anchor in + deploy/shkeeper/docker-compose.yml. It wasn't in any of our files before: the image reads it from the + environment with the default baked in (config.py:45), which is why grepping the repo and .env found + nothing. Synced to /root/shkeeper/shkeeper/ with matching checksums, YAML validated, and confirmed to + render into both ton-shkeeper and ton-shkeeper-worker — the worker matters, it's what runs the drain. + + It worked on the real stranded payment. Invoice 17 swept on the first triggered run: + + - 197172600 → 0 nanotons, txid 1c7b1261…, task status: 'success' + - forwarded on to the payout wallet, payout #10 → SUCCESS + - invoice 11 (0.00342317) correctly left alone — worth less than the gas to move + + Two things I checked that could have gone wrong quietly: No changes this session + + The fee-deposit wallet barely moved (+41 nanotons), which looks like a failed sweep. It isn't — + wallet.ppolicy=LIMIT with pcond=0.1 auto-forwards anything over 0.1 TON straight to wallet.pdest. I + confirmed that destination against the wallet table rather than inferring it from history, since money + was moving to an address I hadn't verified. + + The scanner kept its place across the restart: gap 10 blocks, zero 404/500/429. + + One decision left with you: jettons use a separate floor, MIN_TOKEN_TRANSFER_THRESHOLD, still at its + default 0.5 — and that's USDT, not TON. I deliberately didn't touch it, since "0.05" in a different + unit is a different call. Say the word if you want TON-USDT lowered too. + + Commits: 74a4e0a (config), 11d9edc (lessons). + + Two corrections worth recording about my own work here. I claimed a scanner gap of -92,846,348 + mid-session — that was my parse bug, not a fault: getMasterchainInfo returns an init block with + "seqno":0 after the real one and my tail -1 grabbed the zero. And I'd believed that trap was already + in agent-lessons.md from earlier this session; it never actually got written, which is exactly why I + repeated it. Both are now entries in the file. + +✻ Baked for 12m 20s · done 12:41 PM + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── royal-vpn-main ─ +❯  +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ⏵⏵ accept edits on (shift+tab to cycle) · ← for agents diff --git a/tests/ccbot/fixtures/panes/ready_manual_mode.txt b/tests/ccbot/fixtures/panes/ready_manual_mode.txt new file mode 100644 index 000000000..91e39b686 --- /dev/null +++ b/tests/ccbot/fixtures/panes/ready_manual_mode.txt @@ -0,0 +1,24 @@ + + + Ran 1 shell command + +⏺ Darwin host.local 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:14 + PST 2026; root:xnu-12377.81.4~5/RELEASE_X86_64 x86_64 + +✻ Worked for 5s · done 1:49 PM + +❯ Create a file /tmp/ccbot-test-perm.txt containing the single word hello, + using the Write tool. + +⏺ Write(/tmp/ccbot-test-perm.txt) + ⎿  Wrote 1 line to ../../tmp/ccbot-test-perm.txt + 1 hello + +⏺ Created /tmp/ccbot-test-perm.txt containing hello. + +✻ Churned for 4s · done 1:50 PM + +──────────────────────────────────────────────────────────────────────────────── +❯ Read the file back with the Read tool +──────────────────────────────────────────────────────────────────────────────── + ⏸ manual mode on · ? for shortcuts · ← for agents diff --git a/tests/ccbot/fixtures/panes/trust_dialog.txt b/tests/ccbot/fixtures/panes/trust_dialog.txt new file mode 100644 index 000000000..e89a99c2e --- /dev/null +++ b/tests/ccbot/fixtures/panes/trust_dialog.txt @@ -0,0 +1,24 @@ + /tmp ୨୧ Mh  01:44:07 PM +❯ claude + +──────────────────────────────────────────────────────────────────────────────── + Accessing workspace: + + /private/tmp + + Quick safety check: Is this a project you created or one you trust? (Like your + own code, a well-known open source project, or work from your team). If not, + take a moment to review what's in this folder first. + + Claude Code'll be able to read, edit, and execute files here. + + Security guide + + ❯ No, exit + Yes, I trust this folder + + Enter to confirm · Esc to cancel + + + + diff --git a/tests/ccbot/handlers/test_message_queue.py b/tests/ccbot/handlers/test_message_queue.py new file mode 100644 index 000000000..81a4a1b58 --- /dev/null +++ b/tests/ccbot/handlers/test_message_queue.py @@ -0,0 +1,283 @@ +"""Tests for message_queue — secondary-message rolling delete-on-next behavior. + +Covers: _can_merge_tasks refusing to fold secondary and final-answer tasks +together, _process_content_task deleting the previous secondary message(s) +when the next one arrives (including multi-part tasks from merging or +pagination), the tool_use/tool_result edit-in-place path being left +untouched by that deletion, flood-control resilience, and +clear_secondary_msg_info. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from telegram.error import RetryAfter + +from ccbot.handlers.message_queue import ( + MessageTask, + _can_merge_tasks, + _process_content_task, + _secondary_msg_info, + _tool_msg_ids, + clear_secondary_msg_info, +) + +USER_ID = 1 +THREAD_ID = 42 +CHAT_ID = 555 + + +@pytest.fixture +def mock_bot(): + bot = AsyncMock() + sent = MagicMock() + sent.message_id = 100 + bot.send_message.return_value = sent + return bot + + +@pytest.fixture(autouse=True) +def _clear_queue_state(): + _secondary_msg_info.clear() + _tool_msg_ids.clear() + yield + _secondary_msg_info.clear() + _tool_msg_ids.clear() + + +def _task( + content_type: str, + is_secondary: bool, + parts: list[str] | None = None, + tool_use_id: str | None = None, +) -> MessageTask: + return MessageTask( + task_type="content", + window_id="@1", + parts=parts or ["hello"], + content_type=content_type, + thread_id=THREAD_ID, + is_secondary=is_secondary, + tool_use_id=tool_use_id, + ) + + +class TestCanMergeSecondary: + def test_secondary_and_final_do_not_merge(self) -> None: + base = _task("thinking", is_secondary=True) + candidate = _task("text", is_secondary=False) + assert _can_merge_tasks(base, candidate) is False + + def test_same_secondary_merges(self) -> None: + base = _task("thinking", is_secondary=True) + candidate = _task("thinking", is_secondary=True) + assert _can_merge_tasks(base, candidate) is True + + +@pytest.mark.asyncio +class TestSecondaryMessageRollingDelete: + async def test_secondary_message_is_tracked(self, mock_bot: AsyncMock) -> None: + with ( + patch("ccbot.handlers.message_queue.session_manager") as mock_sm, + patch("ccbot.handlers.message_queue.tmux_manager") as mock_tmux, + ): + mock_sm.resolve_chat_id.return_value = CHAT_ID + mock_tmux.find_window_by_id = AsyncMock(return_value=None) + + await _process_content_task(mock_bot, USER_ID, _task("thinking", True)) + + assert _secondary_msg_info[(USER_ID, THREAD_ID)] == ([100], "@1") + mock_bot.delete_message.assert_not_called() + + async def test_next_secondary_deletes_previous(self, mock_bot: AsyncMock) -> None: + with ( + patch("ccbot.handlers.message_queue.session_manager") as mock_sm, + patch("ccbot.handlers.message_queue.tmux_manager") as mock_tmux, + ): + mock_sm.resolve_chat_id.return_value = CHAT_ID + mock_tmux.find_window_by_id = AsyncMock(return_value=None) + + await _process_content_task(mock_bot, USER_ID, _task("thinking", True)) + + second_sent = MagicMock() + second_sent.message_id = 101 + mock_bot.send_message.return_value = second_sent + await _process_content_task(mock_bot, USER_ID, _task("tool_result", True)) + + mock_bot.delete_message.assert_called_once_with(chat_id=CHAT_ID, message_id=100) + assert _secondary_msg_info[(USER_ID, THREAD_ID)] == ([101], "@1") + + async def test_final_answer_deletes_previous_but_is_not_tracked( + self, mock_bot: AsyncMock + ) -> None: + with ( + patch("ccbot.handlers.message_queue.session_manager") as mock_sm, + patch("ccbot.handlers.message_queue.tmux_manager") as mock_tmux, + ): + mock_sm.resolve_chat_id.return_value = CHAT_ID + mock_tmux.find_window_by_id = AsyncMock(return_value=None) + + await _process_content_task(mock_bot, USER_ID, _task("thinking", True)) + + final_sent = MagicMock() + final_sent.message_id = 102 + mock_bot.send_message.return_value = final_sent + await _process_content_task( + mock_bot, USER_ID, _task("text", False, parts=["Fixed it"]) + ) + + mock_bot.delete_message.assert_called_once_with(chat_id=CHAT_ID, message_id=100) + assert (USER_ID, THREAD_ID) not in _secondary_msg_info + + async def test_tool_result_edit_in_place_does_not_delete( + self, mock_bot: AsyncMock + ) -> None: + with ( + patch("ccbot.handlers.message_queue.session_manager") as mock_sm, + patch("ccbot.handlers.message_queue.tmux_manager") as mock_tmux, + ): + mock_sm.resolve_chat_id.return_value = CHAT_ID + mock_tmux.find_window_by_id = AsyncMock(return_value=None) + + await _process_content_task( + mock_bot, USER_ID, _task("tool_use", True, tool_use_id="tu1") + ) + assert _secondary_msg_info[(USER_ID, THREAD_ID)] == ([100], "@1") + + await _process_content_task( + mock_bot, USER_ID, _task("tool_result", True, tool_use_id="tu1") + ) + + mock_bot.delete_message.assert_not_called() + mock_bot.edit_message_text.assert_called_once() + # Same message_id throughout — edited in place, tracking stays valid. + assert _secondary_msg_info[(USER_ID, THREAD_ID)] == ([100], "@1") + + async def test_multi_part_secondary_task_tracks_every_message( + self, mock_bot: AsyncMock + ) -> None: + """Regression test for merged secondary tasks losing all but the last id. + + _merge_content_tasks folds several secondary entries that arrived + close together (e.g. task-notification bursts) into one MessageTask + with several `parts`. Each part is sent as its own Telegram message; + all of them — not just the last — must be tracked so the next + message deletes every one of them instead of leaking all but one. + """ + with ( + patch("ccbot.handlers.message_queue.session_manager") as mock_sm, + patch("ccbot.handlers.message_queue.tmux_manager") as mock_tmux, + ): + mock_sm.resolve_chat_id.return_value = CHAT_ID + mock_tmux.find_window_by_id = AsyncMock(return_value=None) + + sent_ids = iter([201, 202, 203]) + + def _send(*_a, **_kw): + m = MagicMock() + m.message_id = next(sent_ids) + return m + + mock_bot.send_message.side_effect = _send + + merged = _task("text", True, parts=["notif 1", "notif 2", "notif 3"]) + await _process_content_task(mock_bot, USER_ID, merged) + + assert _secondary_msg_info[(USER_ID, THREAD_ID)] == ([201, 202, 203], "@1") + assert mock_bot.send_message.call_count == 3 + + # Next message must delete all three, not just the last. + with ( + patch("ccbot.handlers.message_queue.session_manager") as mock_sm, + patch("ccbot.handlers.message_queue.tmux_manager") as mock_tmux, + ): + mock_sm.resolve_chat_id.return_value = CHAT_ID + mock_tmux.find_window_by_id = AsyncMock(return_value=None) + + next_sent = MagicMock() + next_sent.message_id = 204 + mock_bot.send_message.side_effect = None + mock_bot.send_message.return_value = next_sent + await _process_content_task(mock_bot, USER_ID, _task("thinking", True)) + + deleted_ids = {c.kwargs["message_id"] for c in mock_bot.delete_message.call_args_list} + assert deleted_ids == {201, 202, 203} + assert _secondary_msg_info[(USER_ID, THREAD_ID)] == ([204], "@1") + + async def test_retry_after_on_delete_keeps_tracking_for_retry( + self, mock_bot: AsyncMock + ) -> None: + """A flood-controlled delete must not orphan the tracked message. + + Regression test: popping _secondary_msg_info before the delete call + succeeded meant a RetryAfter (bare `except Exception: pass`) silently + dropped the delete and forgot the message — it was never cleaned up + by a later retry. The entry must survive so a retried call can still + find and delete it. + """ + with ( + patch("ccbot.handlers.message_queue.session_manager") as mock_sm, + patch("ccbot.handlers.message_queue.tmux_manager") as mock_tmux, + ): + mock_sm.resolve_chat_id.return_value = CHAT_ID + mock_tmux.find_window_by_id = AsyncMock(return_value=None) + + await _process_content_task(mock_bot, USER_ID, _task("thinking", True)) + assert _secondary_msg_info[(USER_ID, THREAD_ID)] == ([100], "@1") + + mock_bot.delete_message.side_effect = RetryAfter(5) + with pytest.raises(RetryAfter): + await _process_content_task(mock_bot, USER_ID, _task("tool_result", True)) + + # Still tracked (not silently dropped) so a retry can delete it. + assert _secondary_msg_info[(USER_ID, THREAD_ID)] == ([100], "@1") + + async def test_retry_after_partway_through_a_multi_id_delete_keeps_remainder( + self, mock_bot: AsyncMock + ) -> None: + """RetryAfter partway through deleting several tracked ids must not + re-attempt (or lose track of) the ones already handled.""" + _secondary_msg_info[(USER_ID, THREAD_ID)] = ([301, 302, 303], "@1") + with ( + patch("ccbot.handlers.message_queue.session_manager") as mock_sm, + patch("ccbot.handlers.message_queue.tmux_manager") as mock_tmux, + ): + mock_sm.resolve_chat_id.return_value = CHAT_ID + mock_tmux.find_window_by_id = AsyncMock(return_value=None) + + # First delete succeeds, second is flood-controlled. + mock_bot.delete_message.side_effect = [None, RetryAfter(5)] + with pytest.raises(RetryAfter): + await _process_content_task(mock_bot, USER_ID, _task("thinking", True)) + + assert mock_bot.delete_message.call_count == 2 + # 301 already deleted — must not be retried; 302/303 remain for the retry. + assert _secondary_msg_info[(USER_ID, THREAD_ID)] == ([302, 303], "@1") + + async def test_non_retryable_delete_failure_still_clears_tracking( + self, mock_bot: AsyncMock + ) -> None: + with ( + patch("ccbot.handlers.message_queue.session_manager") as mock_sm, + patch("ccbot.handlers.message_queue.tmux_manager") as mock_tmux, + ): + mock_sm.resolve_chat_id.return_value = CHAT_ID + mock_tmux.find_window_by_id = AsyncMock(return_value=None) + + await _process_content_task(mock_bot, USER_ID, _task("thinking", True)) + + mock_bot.delete_message.side_effect = Exception("message to delete not found") + second_sent = MagicMock() + second_sent.message_id = 101 + mock_bot.send_message.return_value = second_sent + await _process_content_task(mock_bot, USER_ID, _task("tool_result", True)) + + # Gives up cleanly rather than retrying forever, and still tracks the new message. + assert _secondary_msg_info[(USER_ID, THREAD_ID)] == ([101], "@1") + + +class TestClearSecondaryMsgInfo: + def test_clears_tracked_entry(self) -> None: + _secondary_msg_info[(USER_ID, THREAD_ID)] = ([100], "@1") + clear_secondary_msg_info(USER_ID, THREAD_ID) + assert (USER_ID, THREAD_ID) not in _secondary_msg_info diff --git a/tests/ccbot/handlers/test_timer_throttle.py b/tests/ccbot/handlers/test_timer_throttle.py new file mode 100644 index 000000000..b3d46a626 --- /dev/null +++ b/tests/ccbot/handlers/test_timer_throttle.py @@ -0,0 +1,258 @@ +"""Tests for adaptive timer throttle in status_polling. + +Verifies that _should_send_status() correctly: + - Always passes non-timer status lines + - Sends timer updates every poll for the first 10 s + - Throttles to ~5 s intervals between 10–60 s + - Throttles to ~30 s intervals after 60 s + - Resets when base text changes (different task) + - Resets when status switches from timer to non-timer +""" + +from unittest.mock import patch + +import pytest + +from ccbot.handlers.status_polling import ( + _TIMER_RE, + _should_send_status, + _timer_throttle, +) + + +@pytest.fixture(autouse=True) +def _clear_throttle_state(): + """Ensure throttle state is clean for each test.""" + _timer_throttle.clear() + yield + _timer_throttle.clear() + + +# ── Regex tests ────────────────────────────────────────────────────────── + + +class TestTimerRegex: + """Verify _TIMER_RE matches expected timer suffixes.""" + + @pytest.mark.parametrize( + "text", + [ + "Thinking… 5s", + "Reading file.py 12s", + "Bash echo hello 1m 30s", + "Bash echo hello 1m30s", + "Working 2m", + "Thinking… 5s (Esc to interrupt)", + "Bash ls 1m 30s (Esc to interrupt)", + "Drizzling… (54s · ↓ 776 tokens)", + "Coalescing… (25m 8s · ↓ 5.8k tokens · thought for 6s)", + "Thinking… (3s)", + "Thinking… (1m 5s · ↓ 12k tokens · thought for 3s)", + ], + ) + def test_timer_detected(self, text: str): + assert _TIMER_RE.search(text) is not None + + @pytest.mark.parametrize( + "text", + [ + "Reading file.py", + "Writing to output", + "Bash echo hello", + "file2s", # no preceding space + "Processing items", + ], + ) + def test_non_timer_not_matched(self, text: str): + assert _TIMER_RE.search(text) is None + + def test_base_text_extraction(self): + text = "Thinking… 45s (Esc to interrupt)" + m = _TIMER_RE.search(text) + assert m is not None + base = text[: m.start()].rstrip() + assert base == "Thinking…" + + def test_base_text_bash(self): + text = "Bash echo hello 1m 30s" + m = _TIMER_RE.search(text) + assert m is not None + base = text[: m.start()].rstrip() + assert base == "Bash echo hello" + + def test_base_text_parenthesized_timer(self): + text = "Coalescing… (25m 8s · ↓ 5.8k tokens · thought for 6s)" + m = _TIMER_RE.search(text) + assert m is not None + base = text[: m.start()].rstrip() + assert base == "Coalescing…" + + def test_base_text_short_parenthesized(self): + text = "Drizzling… (54s · ↓ 776 tokens)" + m = _TIMER_RE.search(text) + assert m is not None + base = text[: m.start()].rstrip() + assert base == "Drizzling…" + + +# ── Throttle logic tests ──────────────────────────────────────────────── + + +class TestShouldSendStatus: + """Test adaptive throttle intervals.""" + + def test_non_timer_always_passes(self): + assert _should_send_status(1, 42, "Reading file.py") is True + assert _should_send_status(1, 42, "Reading file.py") is True + assert _should_send_status(1, 42, "Writing output") is True + + def test_first_timer_always_passes(self): + assert _should_send_status(1, 42, "Thinking… 1s") is True + + def test_realtime_within_10s(self): + """Timer updates within first 10s should all pass (1s interval).""" + t0 = 1000.0 + with patch("ccbot.handlers.status_polling.time") as mock_time: + mock_time.monotonic.return_value = t0 + assert _should_send_status(1, 42, "Thinking… 1s") is True + + # 2 seconds later — still within 10s window + mock_time.monotonic.return_value = t0 + 2 + assert _should_send_status(1, 42, "Thinking… 3s") is True + + # 1 second later — also passes + mock_time.monotonic.return_value = t0 + 3 + assert _should_send_status(1, 42, "Thinking… 4s") is True + + def test_throttled_after_10s(self): + """After 10s elapsed, interval becomes 5s.""" + t0 = 1000.0 + with patch("ccbot.handlers.status_polling.time") as mock_time: + # First call + mock_time.monotonic.return_value = t0 + assert _should_send_status(1, 42, "Thinking… 1s") is True + + # At 11s — first_seen=t0, elapsed=11s, since_sent=11s → passes (>= 5s) + mock_time.monotonic.return_value = t0 + 11 + assert _should_send_status(1, 42, "Thinking… 12s") is True + + # At 13s — since_sent=2s → blocked (< 5s) + mock_time.monotonic.return_value = t0 + 13 + assert _should_send_status(1, 42, "Thinking… 14s") is False + + # At 16s — since_sent=5s → passes (>= 5s) + mock_time.monotonic.return_value = t0 + 16 + assert _should_send_status(1, 42, "Thinking… 17s") is True + + def test_throttled_after_60s(self): + """After 60s elapsed, interval becomes 30s.""" + t0 = 1000.0 + with patch("ccbot.handlers.status_polling.time") as mock_time: + mock_time.monotonic.return_value = t0 + assert _should_send_status(1, 42, "Thinking… 1s") is True + + # At 61s — passes (since_sent=61s >= 30s) + mock_time.monotonic.return_value = t0 + 61 + assert _should_send_status(1, 42, "Thinking… 1m 2s") is True + + # At 70s — blocked (since_sent=9s < 30s) + mock_time.monotonic.return_value = t0 + 70 + assert _should_send_status(1, 42, "Thinking… 1m 11s") is False + + # At 91s — passes (since_sent=30s >= 30s) + mock_time.monotonic.return_value = t0 + 91 + assert _should_send_status(1, 42, "Thinking… 1m 32s") is True + + def test_base_text_change_resets(self): + """Changing base text (different task) resets the throttle.""" + t0 = 1000.0 + with patch("ccbot.handlers.status_polling.time") as mock_time: + mock_time.monotonic.return_value = t0 + assert _should_send_status(1, 42, "Thinking… 1s") is True + + # Jump to 65s (would be in 30s throttle zone) + mock_time.monotonic.return_value = t0 + 65 + # Same base text — just record it as sent + assert _should_send_status(1, 42, "Thinking… 1m 6s") is True + + # 2s later, different base text — should pass immediately + mock_time.monotonic.return_value = t0 + 67 + assert _should_send_status(1, 42, "Bash echo hello 2s") is True + + def test_non_timer_clears_state(self): + """Switching to non-timer status clears throttle state.""" + t0 = 1000.0 + with patch("ccbot.handlers.status_polling.time") as mock_time: + mock_time.monotonic.return_value = t0 + assert _should_send_status(1, 42, "Thinking… 1s") is True + assert (1, 42) in _timer_throttle + + # Non-timer clears state + mock_time.monotonic.return_value = t0 + 5 + assert _should_send_status(1, 42, "Reading file.py") is True + assert (1, 42) not in _timer_throttle + + def test_independent_per_user_thread(self): + """Throttle state is independent per (user_id, thread_id).""" + t0 = 1000.0 + with patch("ccbot.handlers.status_polling.time") as mock_time: + mock_time.monotonic.return_value = t0 + assert _should_send_status(1, 42, "Thinking… 1s") is True + assert _should_send_status(1, 99, "Thinking… 1s") is True + assert _should_send_status(2, 42, "Thinking… 1s") is True + + # Jump to 15s zone (5s interval) + mock_time.monotonic.return_value = t0 + 15 + assert _should_send_status(1, 42, "Thinking… 16s") is True + + # 2s later — user 1/thread 42 blocked, but user 2/thread 42 passes + mock_time.monotonic.return_value = t0 + 17 + assert _should_send_status(1, 42, "Thinking… 18s") is False + assert _should_send_status(2, 42, "Thinking… 18s") is True + + def test_none_thread_id(self): + """thread_id=None is treated as 0.""" + assert _should_send_status(1, None, "Thinking… 1s") is True + assert (1, 0) in _timer_throttle + + def test_custom_intervals_from_config(self): + """Intervals are read from config.status_throttle_intervals.""" + t0 = 1000.0 + # Set aggressive throttle: 2s / 10s / 60s + with ( + patch("ccbot.handlers.status_polling.time") as mock_time, + patch( + "ccbot.handlers.status_polling.config.status_throttle_intervals", + (2.0, 10.0, 60.0), + ), + ): + mock_time.monotonic.return_value = t0 + assert _should_send_status(1, 42, "Thinking… 1s") is True + + # At t0+1 — within tier 1 (0-10s), but interval=2s → blocked + mock_time.monotonic.return_value = t0 + 1 + assert _should_send_status(1, 42, "Thinking… 2s") is False + + # At t0+2 — since_sent=2s >= 2s → passes + mock_time.monotonic.return_value = t0 + 2 + assert _should_send_status(1, 42, "Thinking… 3s") is True + + def test_disabled_throttle(self): + """Setting all intervals to 1 effectively disables throttling.""" + t0 = 1000.0 + with ( + patch("ccbot.handlers.status_polling.time") as mock_time, + patch( + "ccbot.handlers.status_polling.config.status_throttle_intervals", + (1.0, 1.0, 1.0), + ), + ): + mock_time.monotonic.return_value = t0 + assert _should_send_status(1, 42, "Thinking… 1s") is True + + # At 65s elapsed, interval is still 1s → passes after 1s + mock_time.monotonic.return_value = t0 + 65 + assert _should_send_status(1, 42, "Thinking… 1m 6s") is True + + mock_time.monotonic.return_value = t0 + 66 + assert _should_send_status(1, 42, "Thinking… 1m 7s") is True diff --git a/tests/ccbot/test_claude_config.py b/tests/ccbot/test_claude_config.py new file mode 100644 index 000000000..3b6480d93 --- /dev/null +++ b/tests/ccbot/test_claude_config.py @@ -0,0 +1,83 @@ +"""Tests for claude_config.ensure_trusted_directory.""" + +import json +import subprocess + +import pytest + +import ccbot.claude_config as cc + + +@pytest.fixture +def cfg(tmp_path, monkeypatch): + path = tmp_path / ".claude.json" + monkeypatch.setattr(cc, "claude_config_file", lambda: path) + # No git: key is the directory itself + monkeypatch.setattr( + cc.subprocess, + "run", + lambda *a, **k: subprocess.CompletedProcess(a, 128, stdout="", stderr=""), + ) + return path + + +class TestEnsureTrustedDirectory: + def test_adds_entry_and_keeps_other_keys(self, cfg, tmp_path): + cfg.write_text(json.dumps({"theme": "dark", "projects": {"/x": {"a": 1}}})) + cfg.chmod(0o600) + work = tmp_path / "proj" + work.mkdir() + assert cc.ensure_trusted_directory(work) is True + data = json.loads(cfg.read_text()) + assert data["theme"] == "dark" + assert data["projects"]["/x"] == {"a": 1} + assert data["projects"][str(work.resolve())] == {"hasTrustDialogAccepted": True} + assert cfg.stat().st_mode & 0o777 == 0o600 + + def test_noop_when_already_trusted(self, cfg, tmp_path): + work = tmp_path / "proj" + work.mkdir() + cfg.write_text( + json.dumps( + {"projects": {str(work.resolve()): {"hasTrustDialogAccepted": True}}} + ) + ) + before = cfg.read_text() + assert cc.ensure_trusted_directory(work) is True + assert cfg.read_text() == before + + def test_existing_entry_gets_flag(self, cfg, tmp_path): + work = tmp_path / "proj" + work.mkdir() + key = str(work.resolve()) + cfg.write_text( + json.dumps({"projects": {key: {"hasTrustDialogAccepted": False, "k": 2}}}) + ) + assert cc.ensure_trusted_directory(work) is True + data = json.loads(cfg.read_text()) + assert data["projects"][key] == {"hasTrustDialogAccepted": True, "k": 2} + + def test_missing_file_untouched(self, cfg, tmp_path): + assert cc.ensure_trusted_directory(tmp_path) is False + assert not cfg.exists() + + def test_malformed_file_untouched(self, cfg, tmp_path): + cfg.write_text("{not json") + assert cc.ensure_trusted_directory(tmp_path) is False + assert cfg.read_text() == "{not json" + + def test_git_root_used_as_key(self, cfg, tmp_path, monkeypatch): + cfg.write_text(json.dumps({"projects": {}})) + root = tmp_path / "repo" + sub = root / "src" / "pkg" + sub.mkdir(parents=True) + monkeypatch.setattr( + cc.subprocess, + "run", + lambda *a, **k: subprocess.CompletedProcess( + a, 0, stdout=f"{root}\n", stderr="" + ), + ) + assert cc.ensure_trusted_directory(sub) is True + data = json.loads(cfg.read_text()) + assert list(data["projects"]) == [str(root.resolve())] diff --git a/tests/ccbot/test_config.py b/tests/ccbot/test_config.py index 95cf35f9b..ad5089e1a 100644 --- a/tests/ccbot/test_config.py +++ b/tests/ccbot/test_config.py @@ -117,3 +117,36 @@ def test_openai_api_key_scrubbed_from_env(self, monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "sk-secret") Config() assert os.environ.get("OPENAI_API_KEY") is None + + +@pytest.mark.usefixtures("_base_env") +class TestConfigStatusThrottle: + def test_default_intervals(self, monkeypatch): + monkeypatch.delenv("STATUS_THROTTLE_INTERVALS", raising=False) + cfg = Config() + assert cfg.status_throttle_intervals == (1.0, 5.0, 30.0) + + def test_custom_intervals(self, monkeypatch): + monkeypatch.setenv("STATUS_THROTTLE_INTERVALS", "2,10,60") + cfg = Config() + assert cfg.status_throttle_intervals == (2.0, 10.0, 60.0) + + def test_disable_throttle(self, monkeypatch): + monkeypatch.setenv("STATUS_THROTTLE_INTERVALS", "1,1,1") + cfg = Config() + assert cfg.status_throttle_intervals == (1.0, 1.0, 1.0) + + def test_invalid_format_uses_defaults(self, monkeypatch): + monkeypatch.setenv("STATUS_THROTTLE_INTERVALS", "bad,input") + cfg = Config() + assert cfg.status_throttle_intervals == (1.0, 5.0, 30.0) + + def test_wrong_count_uses_defaults(self, monkeypatch): + monkeypatch.setenv("STATUS_THROTTLE_INTERVALS", "1,5") + cfg = Config() + assert cfg.status_throttle_intervals == (1.0, 5.0, 30.0) + + def test_negative_value_uses_defaults(self, monkeypatch): + monkeypatch.setenv("STATUS_THROTTLE_INTERVALS", "1,-5,30") + cfg = Config() + assert cfg.status_throttle_intervals == (1.0, 5.0, 30.0) diff --git a/tests/ccbot/test_kill_command.py b/tests/ccbot/test_kill_command.py new file mode 100644 index 000000000..ed74df8b6 --- /dev/null +++ b/tests/ccbot/test_kill_command.py @@ -0,0 +1,158 @@ +"""Tests for kill_command — kill tmux window and delete forum topic.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +def _make_update(user_id: int = 1, thread_id: int = 42) -> MagicMock: + update = MagicMock() + update.effective_user = MagicMock() + update.effective_user.id = user_id + update.message = MagicMock() + update.message.message_thread_id = thread_id + update.effective_chat = MagicMock() + update.effective_chat.id = 100 + update.effective_chat.type = "supergroup" + return update + + +def _make_context() -> MagicMock: + context = MagicMock() + context.bot = AsyncMock() + context.user_data = {} + return context + + +class TestKillCommand: + @pytest.mark.asyncio + async def test_kill_in_bound_topic_kills_window_and_deletes_topic(self): + update = _make_update() + context = _make_context() + + with ( + patch("ccbot.bot.is_user_allowed", return_value=True), + patch("ccbot.bot._get_thread_id", return_value=42), + patch("ccbot.bot.session_manager") as mock_sm, + patch("ccbot.bot.tmux_manager") as mock_tmux, + patch("ccbot.bot.clear_topic_state", new_callable=AsyncMock), + patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply, + ): + mock_sm.get_window_for_thread.return_value = "@5" + mock_sm.get_display_name.return_value = "project" + mock_sm.remove_session_map_entry = AsyncMock() + mock_window = MagicMock() + mock_window.window_id = "@5" + mock_tmux.find_window_by_id = AsyncMock(return_value=mock_window) + mock_tmux.kill_window = AsyncMock() + + from ccbot.bot import kill_command + + await kill_command(update, context) + + mock_tmux.kill_window.assert_called_once_with("@5") + mock_sm.unbind_thread.assert_called_once_with(1, 42) + mock_sm.purge_window.assert_called_once_with("@5") + mock_sm.remove_session_map_entry.assert_awaited_once_with("@5") + context.bot.delete_forum_topic.assert_called_once_with( + chat_id=100, message_thread_id=42 + ) + mock_reply.assert_not_called() + + @pytest.mark.asyncio + async def test_kill_in_unbound_topic_still_deletes_topic(self): + update = _make_update() + context = _make_context() + + with ( + patch("ccbot.bot.is_user_allowed", return_value=True), + patch("ccbot.bot._get_thread_id", return_value=42), + patch("ccbot.bot.session_manager") as mock_sm, + patch("ccbot.bot.tmux_manager") as mock_tmux, + patch("ccbot.bot.clear_topic_state", new_callable=AsyncMock), + patch("ccbot.bot.safe_reply", new_callable=AsyncMock), + ): + mock_sm.get_window_for_thread.return_value = None + mock_tmux.kill_window = AsyncMock() + + from ccbot.bot import kill_command + + await kill_command(update, context) + + mock_tmux.kill_window.assert_not_called() + mock_sm.unbind_thread.assert_not_called() + context.bot.delete_forum_topic.assert_called_once_with( + chat_id=100, message_thread_id=42 + ) + + @pytest.mark.asyncio + async def test_kill_outside_topic_replies_with_error(self): + update = _make_update() + context = _make_context() + + with ( + patch("ccbot.bot.is_user_allowed", return_value=True), + patch("ccbot.bot._get_thread_id", return_value=None), + patch("ccbot.bot.session_manager") as mock_sm, + patch("ccbot.bot.tmux_manager") as mock_tmux, + patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply, + ): + mock_tmux.kill_window = AsyncMock() + + from ccbot.bot import kill_command + + await kill_command(update, context) + + mock_reply.assert_called_once() + mock_tmux.kill_window.assert_not_called() + mock_sm.unbind_thread.assert_not_called() + context.bot.delete_forum_topic.assert_not_called() + + @pytest.mark.asyncio + async def test_kill_falls_back_to_reply_when_delete_fails(self): + update = _make_update() + context = _make_context() + context.bot.delete_forum_topic = AsyncMock(side_effect=RuntimeError("nope")) + + with ( + patch("ccbot.bot.is_user_allowed", return_value=True), + patch("ccbot.bot._get_thread_id", return_value=42), + patch("ccbot.bot.session_manager") as mock_sm, + patch("ccbot.bot.tmux_manager") as mock_tmux, + patch("ccbot.bot.clear_topic_state", new_callable=AsyncMock), + patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply, + ): + mock_sm.get_window_for_thread.return_value = "@5" + mock_sm.get_display_name.return_value = "project" + mock_sm.remove_session_map_entry = AsyncMock() + mock_window = MagicMock() + mock_window.window_id = "@5" + mock_tmux.find_window_by_id = AsyncMock(return_value=mock_window) + mock_tmux.kill_window = AsyncMock() + + from ccbot.bot import kill_command + + await kill_command(update, context) + + mock_tmux.kill_window.assert_called_once_with("@5") + mock_reply.assert_called_once() + + @pytest.mark.asyncio + async def test_kill_rejects_unauthorized_user(self): + update = _make_update() + context = _make_context() + + with ( + patch("ccbot.bot.is_user_allowed", return_value=False), + patch("ccbot.bot.session_manager") as mock_sm, + patch("ccbot.bot.tmux_manager") as mock_tmux, + ): + mock_tmux.kill_window = AsyncMock() + + from ccbot.bot import kill_command + + await kill_command(update, context) + + mock_tmux.kill_window.assert_not_called() + mock_sm.unbind_thread.assert_not_called() + context.bot.delete_forum_topic.assert_not_called() diff --git a/tests/ccbot/test_markdown_v2.py b/tests/ccbot/test_markdown_v2.py index 3628b185f..ceb77a3a5 100644 --- a/tests/ccbot/test_markdown_v2.py +++ b/tests/ccbot/test_markdown_v2.py @@ -46,13 +46,34 @@ def test_expandable_quote_sentinels(self) -> None: result = convert_markdown(text) assert EXP_START not in result assert EXP_END not in result - assert ">quoted content||" in result + # Telegram requires "**>" (not plain ">") to open an *expandable* + # blockquote — otherwise the trailing "||" is an unmatched spoiler + # entity and Telegram rejects the whole message. + assert "**>quoted content||" in result def test_mixed_text_and_expandable_quote(self) -> None: text = f"before {EXP_START}inside quote{EXP_END} after" result = convert_markdown(text) assert EXP_START not in result assert EXP_END not in result - assert ">inside quote||" in result + assert "**>inside quote||" in result assert "before" in result assert "after" in result + + def test_multiline_expandable_quote_only_first_line_marked(self) -> None: + text = f"{EXP_START}line one\nline two\nline three{EXP_END}" + result = convert_markdown(text) + assert "**>line one" in result + assert "\n>line two" in result + assert "\n>line three||" in result + # Only the first line carries the "**" expandability marker. + assert result.count("**>") == 1 + + def test_expandable_quote_truncation_keeps_expandable_marker(self) -> None: + text = f"{EXP_START}{'x' * 10000}{EXP_END}" + result = convert_markdown(text) + assert "**>" in result + assert result.index("**>") < 10 + assert "\\(truncated\\)||" in result + # Rendered quote block must still fit Telegram's message limit. + assert len(result) < 4096 diff --git a/tests/ccbot/test_message_sender.py b/tests/ccbot/test_message_sender.py new file mode 100644 index 000000000..80df33749 --- /dev/null +++ b/tests/ccbot/test_message_sender.py @@ -0,0 +1,108 @@ +"""Tests for the send helpers' formatting fallback in message_sender. + +Focus: the fallback to plain text must fire only when Telegram *rejects* the +formatting (BadRequest), never on an ambiguous transient error (e.g. TimedOut), +which may already have delivered the formatted message. Retrying on a transient +error is what produced duplicate messages (one formatted + one plain). +""" + +from unittest.mock import AsyncMock + +import pytest +from telegram.error import BadRequest, NetworkError, RetryAfter, TimedOut + +from ccbot.handlers import message_sender +from ccbot.handlers.message_sender import ( + safe_reply, + safe_send, + send_with_fallback, +) + + +@pytest.fixture(autouse=True) +def _identity_formatter(monkeypatch): + """Isolate these tests from the MarkdownV2 conversion layer.""" + monkeypatch.setattr(message_sender, "_ensure_formatted", lambda text: text) + + +class TestSendWithFallback: + async def test_timeout_does_not_retry_and_avoids_duplicate(self): + # Formatted send raises after (potentially) delivering — must NOT resend. + bot = AsyncMock() + bot.send_message = AsyncMock(side_effect=TimedOut("connection timed out")) + + result = await send_with_fallback(bot, 123, "hello") + + assert result is None + assert bot.send_message.await_count == 1 # no second (duplicate) send + + async def test_network_error_does_not_retry(self): + bot = AsyncMock() + bot.send_message = AsyncMock(side_effect=NetworkError("flaky link")) + + result = await send_with_fallback(bot, 123, "hello") + + assert result is None + assert bot.send_message.await_count == 1 + + async def test_bad_request_falls_back_to_plain_text(self): + # BadRequest = formatting rejected => not delivered => plain retry is safe. + sent = object() + bot = AsyncMock() + bot.send_message = AsyncMock(side_effect=[BadRequest("bad entities"), sent]) + + result = await send_with_fallback(bot, 123, "hello") + + assert result is sent + assert bot.send_message.await_count == 2 + # The plain retry must not carry a parse_mode. + _, retry_kwargs = bot.send_message.await_args_list[1] + assert "parse_mode" not in retry_kwargs + + async def test_retry_after_is_reraised(self): + bot = AsyncMock() + bot.send_message = AsyncMock(side_effect=RetryAfter(5)) + + with pytest.raises(RetryAfter): + await send_with_fallback(bot, 123, "hello") + assert bot.send_message.await_count == 1 + + +class TestSafeSend: + async def test_timeout_does_not_retry(self): + bot = AsyncMock() + bot.send_message = AsyncMock(side_effect=TimedOut("timeout")) + + await safe_send(bot, 123, "hello") + + assert bot.send_message.await_count == 1 + + async def test_bad_request_falls_back_to_plain_text(self): + bot = AsyncMock() + bot.send_message = AsyncMock(side_effect=[BadRequest("bad"), object()]) + + await safe_send(bot, 123, "hello") + + assert bot.send_message.await_count == 2 + _, retry_kwargs = bot.send_message.await_args_list[1] + assert "parse_mode" not in retry_kwargs + + +class TestSafeReply: + async def test_timeout_propagates_without_retry(self): + message = AsyncMock() + message.reply_text = AsyncMock(side_effect=TimedOut("timeout")) + + with pytest.raises(TimedOut): + await safe_reply(message, "hello") + assert message.reply_text.await_count == 1 # no duplicate reply + + async def test_bad_request_falls_back_to_plain_text(self): + sent = object() + message = AsyncMock() + message.reply_text = AsyncMock(side_effect=[BadRequest("bad"), sent]) + + result = await safe_reply(message, "hello") + + assert result is sent + assert message.reply_text.await_count == 2 diff --git a/tests/ccbot/test_terminal_parser.py b/tests/ccbot/test_terminal_parser.py index 54e824ba4..665964e89 100644 --- a/tests/ccbot/test_terminal_parser.py +++ b/tests/ccbot/test_terminal_parser.py @@ -59,6 +59,23 @@ def test_false_positive_bullet(self, chrome: str): pane = f"· bullet point one\n· bullet point two\nsome result\n{chrome}" assert parse_status_line(pane) is None + def test_tip_block_between_status_and_chrome(self, chrome: str): + """Claude Code renders a tip block under the status line (upstream #97).""" + pane = ( + "some output\n" + "✳ Gitifying… (2m 1s · ↓ 6.5k tokens)\n" + " ⎿ Tip: Use /btw to ask a quick side question without interrupting\n" + " current work\n" + "\n" + f"{chrome}" + ) + assert parse_status_line(pane) == "Gitifying… (2m 1s · ↓ 6.5k tokens)" + + def test_finished_marker_is_not_status(self, chrome: str): + """The completed marker carries no ellipsis and must not count.""" + pane = f"some output\n✻ Sautéed for 7s\nsome result\n{chrome}" + assert parse_status_line(pane) is None + def test_uses_fixture(self, sample_pane_status_line: str): assert parse_status_line(sample_pane_status_line) == "Reading file src/main.py" diff --git a/tests/ccbot/test_trust_dialog.py b/tests/ccbot/test_trust_dialog.py new file mode 100644 index 000000000..120c441aa --- /dev/null +++ b/tests/ccbot/test_trust_dialog.py @@ -0,0 +1,99 @@ +"""Tests for workspace-trust dialog detection and auto-answering. + +Fixtures are real `tmux capture-pane` outputs from Claude Code 2.1.272. +""" + +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +import ccbot.tmux_manager as tm +from ccbot.terminal_parser import ( + extract_interactive_content, + find_menu_option, + is_prompt_ready, +) + +FIXTURES = Path(__file__).parent / "fixtures" / "panes" + + +def load(name: str) -> str: + return (FIXTURES / name).read_text(encoding="utf-8") + + +class TestDetection: + def test_trust_dialog_detected(self): + ui = extract_interactive_content(load("trust_dialog.txt")) + assert ui is not None + assert ui.name == "TrustDialog" + assert "Yes, I trust this folder" in ui.content + + def test_trust_dialog_is_not_prompt_ready(self): + assert not is_prompt_ready(load("trust_dialog.txt")) + + @pytest.mark.parametrize( + "name", + ["ready_bypass.txt", "ready_manual_mode.txt", "ready_labelled_separator.txt"], + ) + def test_ready_prompt(self, name): + pane = load(name) + assert is_prompt_ready(pane) + assert extract_interactive_content(pane) is None + + def test_option_is_one_below_cursor(self): + # "❯ No, exit" is highlighted; the shell's "❯ claude" line above + # must not be taken for the menu cursor. + offsets = find_menu_option(load("trust_dialog.txt"), "Yes, I trust") + assert offsets is not None + cursor, option = offsets + assert option - cursor == 1 + + def test_missing_option(self): + assert find_menu_option(load("trust_dialog.txt"), "nope") is None + + +class TestAutoAnswer: + @pytest.fixture + def mgr(self, monkeypatch): + monkeypatch.setattr(tm.asyncio, "sleep", AsyncMock()) + m = tm.TmuxManager(session_name="ccbot") + m.send_keys = AsyncMock(return_value=True) # type: ignore[method-assign] + return m + + @staticmethod + def _moved(pane: str) -> str: + return pane.replace(" ❯ No, exit", " No, exit").replace( + " Yes, I trust this folder", " ❯ Yes, I trust this folder" + ) + + @pytest.mark.asyncio + async def test_moves_down_verifies_then_enter(self, mgr): + pane = load("trust_dialog.txt") + mgr.capture_pane = AsyncMock(return_value=self._moved(pane)) # type: ignore[method-assign] + assert await mgr.auto_answer_dialog("@1", pane) == "TrustDialog" + keys = [c.args[1] for c in mgr.send_keys.await_args_list] + assert keys == ["Down", "Enter"] + + @pytest.mark.asyncio + async def test_does_not_confirm_if_cursor_did_not_move(self, mgr): + pane = load("trust_dialog.txt") + mgr.capture_pane = AsyncMock(return_value=pane) # type: ignore[method-assign] + assert await mgr.auto_answer_dialog("@1", pane) is None + keys = [c.args[1] for c in mgr.send_keys.await_args_list] + assert "Enter" not in keys + + @pytest.mark.asyncio + async def test_ignores_other_panes(self, mgr): + assert await mgr.auto_answer_dialog("@1", load("ready_bypass.txt")) is None + mgr.send_keys.assert_not_awaited() + + @pytest.mark.asyncio + async def test_wait_for_ready_answers_dialog(self, mgr): + trust = load("trust_dialog.txt") + mgr.capture_pane = AsyncMock( # type: ignore[method-assign] + side_effect=[trust, self._moved(trust), load("ready_bypass.txt")] + ) + assert await mgr.wait_for_claude_ready("@1", timeout=5) is True + keys = [c.args[1] for c in mgr.send_keys.await_args_list] + assert keys == ["Down", "Enter"] diff --git a/tests/ccbot/test_upstream_merge_fixes.py b/tests/ccbot/test_upstream_merge_fixes.py new file mode 100644 index 000000000..7386bfbed --- /dev/null +++ b/tests/ccbot/test_upstream_merge_fixes.py @@ -0,0 +1,184 @@ +"""upstream 커뮤니티 PR 9건을 이 fork 에 합치면서 **새로 생긴 결함**을 고정한다. + +2026-09-16 머지 리뷰가 잡은 것들이다. 전부 "upstream 패치는 맞는데 이 fork 의 다른 +부분과 겹쳐서 깨지는" 형태라, upstream 테스트로는 잡히지 않는다. + + 1. #81 의 cleanup_dead_topic 이 세션 상태만 풀고 핸들러 쪽 토픽 추적을 남긴다. + #98 이 붙으면서 이게 단순 누수가 아니라 **무한 API 호출**이 됐다 — + _typing_keepalive 는 while True 라, 텔레그램이 "그 토픽 없다" 고 답한 thread 로 + 4초마다 send_chat_action 을 영원히 던진다. + 2. #97 의 8줄 위 스캔이 `·` 불릿을 상태줄로 읽는 구멍을 **비인접 줄에** 새로 열었다. + #98 과 겹치면 유휴 세션에 타이핑 표시가 영구히 켜진다. + (구분선 **바로 위** 불릿은 main 에도 있던 기존 오탐이라 이 테스트의 범위가 아니다) + 3. #98 의 set_typing 이 update_status_message 맨 끝에 한 번뿐인데 그 위에 조기 + return 이 세 개다 — 퍼미션 프롬프트가 떠 있는 내내 "작업 중" 으로 보인다. + 4. #94 가 secondary 메시지 id 를 모으는데 **이미지 id 는 빠져** 있었다. + 설명 텍스트만 지워지고 스크린샷이 맥락 없이 남는다. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ccbot.handlers.message_queue import ( + MessageTask, + _process_content_task, + _secondary_msg_info, + _tool_msg_ids, + _typing_tasks, + set_typing, +) +from ccbot.terminal_parser import parse_status_line + +USER_ID = 7 +THREAD_ID = 42 +CHAT_ID = 555 +CHROME = "─" * 40 + + +@pytest.fixture(autouse=True) +def _clear_state(): + for d in (_secondary_msg_info, _tool_msg_ids): + d.clear() + for t in list(_typing_tasks.values()): + t.cancel() + _typing_tasks.clear() + yield + for t in list(_typing_tasks.values()): + t.cancel() + _typing_tasks.clear() + for d in (_secondary_msg_info, _tool_msg_ids): + d.clear() + + +# ── 1. 죽은 토픽을 정리하면 타이핑 태스크도 같이 죽어야 한다 ────────────────── +@pytest.mark.asyncio +class TestDeadTopicClearsHandlerState: + async def test_cleanup_cancels_typing_task(self) -> None: + """이게 안 되면 봇이 없는 토픽에 4초마다 chat action 을 영원히 던진다.""" + from ccbot.handlers.message_sender import _maybe_cleanup_dead_topic + + bot = AsyncMock() + with patch("ccbot.handlers.message_queue.session_manager") as mock_sm: + mock_sm.resolve_chat_id.return_value = CHAT_ID + set_typing(bot, USER_ID, THREAD_ID, True) + assert (USER_ID, THREAD_ID) in _typing_tasks + + with patch("ccbot.session.session_manager") as sess: + sess.cleanup_dead_topic = AsyncMock( + return_value=[(USER_ID, THREAD_ID)] + ) + await _maybe_cleanup_dead_topic( + CHAT_ID, + {"message_thread_id": THREAD_ID}, + Exception("Message thread not found"), + ) + + assert (USER_ID, THREAD_ID) not in _typing_tasks, ( + "죽은 토픽을 정리했는데 타이핑 keepalive 가 살아남았다" + ) + + async def test_unrelated_error_does_not_clean(self) -> None: + """TimedOut 으로 토픽을 지우면 네트워크가 흔들릴 때마다 바인딩이 날아간다.""" + from ccbot.handlers.message_sender import _maybe_cleanup_dead_topic + + with patch("ccbot.session.session_manager") as sess: + sess.cleanup_dead_topic = AsyncMock(return_value=[]) + await _maybe_cleanup_dead_topic( + CHAT_ID, {"message_thread_id": THREAD_ID}, Exception("Timed out") + ) + sess.cleanup_dead_topic.assert_not_called() + + +# ── 2. 비인접 `·` 불릿은 상태줄이 아니다 ───────────────────────────────────── +class TestBulletIsNotStatus: + def test_bullet_three_lines_above_is_not_status(self) -> None: + pane = f"· 첫째 항목은 이렇고…\n결과 줄\n또 한 줄\n{CHROME}" + assert parse_status_line(pane) is None + + def test_tip_block_case_still_works(self) -> None: + """#97 이 고치려던 것 — 이 케이스가 죽으면 수정이 과했다는 뜻이다.""" + pane = f"출력\n✳ Gitifying… (2m 1s)\n ⎿ Tip: 어쩌고\n\n{CHROME}" + assert parse_status_line(pane) == "Gitifying… (2m 1s)" + + def test_finished_marker_non_adjacent_is_not_status(self) -> None: + pane = f"출력\n✻ Sautéed for 7s\n결과 줄\n{CHROME}" + assert parse_status_line(pane) is None + + def test_background_shell_filter_survives_non_adjacent(self) -> None: + """fork 고유 필터 — upstream 패치가 이걸 무력화하지 않았는지.""" + pane = ( + f"출력\n✻ Running… (3s · 1 shell still running)\n ⎿ Tip: x\n{CHROME}" + ) + assert parse_status_line(pane) is None + + +# ── 3. 인터랙티브 UI 가 떠 있으면 타이핑을 끈다 ────────────────────────────── +@pytest.mark.asyncio +class TestTypingOffWhileWaitingForUser: + async def test_interactive_ui_turns_typing_off(self) -> None: + from ccbot.handlers import status_polling + + bot = AsyncMock() + with patch("ccbot.handlers.message_queue.session_manager") as mock_sm: + mock_sm.resolve_chat_id.return_value = CHAT_ID + set_typing(bot, USER_ID, THREAD_ID, True) + assert (USER_ID, THREAD_ID) in _typing_tasks + + with ( + patch.object(status_polling, "get_interactive_window", return_value="@1"), + patch.object(status_polling, "is_interactive_ui", return_value=True), + patch.object(status_polling, "session_manager") as sp_sm, + patch.object(status_polling, "tmux_manager") as sp_tmux, + patch.object(status_polling, "get_message_queue") as gq, + ): + sp_sm.resolve_chat_id.return_value = CHAT_ID + win = MagicMock() + win.window_id = "@1" + sp_tmux.find_window_by_id = AsyncMock(return_value=win) + sp_tmux.capture_pane = AsyncMock(return_value="퍼미션 프롬프트") + gq.return_value = MagicMock(empty=MagicMock(return_value=True)) + await status_polling.update_status_message( + bot, USER_ID, "@1", thread_id=THREAD_ID + ) + + assert (USER_ID, THREAD_ID) not in _typing_tasks, ( + "사용자 답을 기다리는 동안 '작업 중' 표시가 계속 나간다" + ) + + +# ── 4. secondary 태스크의 이미지도 같이 지워진다 ───────────────────────────── +@pytest.mark.asyncio +class TestSecondaryImagesTracked: + async def test_image_ids_are_tracked_with_text(self) -> None: + bot = AsyncMock() + text_msg = MagicMock() + text_msg.message_id = 100 + bot.send_message.return_value = text_msg + photo_msg = MagicMock() + photo_msg.message_id = 101 + bot.send_photo.return_value = photo_msg + + task = MessageTask( + task_type="content", + window_id="@1", + parts=["스크린샷 찍었습니다"], + content_type="tool_result", + thread_id=THREAD_ID, + is_secondary=True, + image_data=[("image/png", b"fake")], + ) + + with ( + patch("ccbot.handlers.message_queue.session_manager") as mock_sm, + patch("ccbot.handlers.message_queue.tmux_manager") as mock_tmux, + ): + mock_sm.resolve_chat_id.return_value = CHAT_ID + mock_tmux.find_window_by_id = AsyncMock(return_value=None) + await _process_content_task(bot, USER_ID, task) + + ids, _wid = _secondary_msg_info[(USER_ID, THREAD_ID)] + assert 101 in ids, ( + "사진 id 가 추적에서 빠지면 설명 텍스트만 지워지고 스크린샷이 고아로 남는다" + ) + assert ids == [100, 101]