diff --git a/.agents/skills/ccbot-send-file/SKILL.md b/.agents/skills/ccbot-send-file/SKILL.md new file mode 100644 index 00000000..76a0c855 --- /dev/null +++ b/.agents/skills/ccbot-send-file/SKILL.md @@ -0,0 +1,85 @@ +--- +name: ccbot-send-file +description: Deliver a file produced or selected by Claude Code or Codex to the current user's Telegram chat through the repository's built-in `ccbot send-file` relay. Use when an agent needs to attach an image, document, archive, report, or other local file to the CCBot conversation, verify outbound delivery, or troubleshoot target-chat resolution without exposing credentials. +--- + +# Send a file through CCBot + +Use the built-in relay first. Do not ask for the bot token during normal +operation. Keep direct Telegram delivery as the reserve path. + +## Deliver + +1. Resolve the exact local file and verify that it is a regular file. +2. Run: + + ```bash + ccbot send-file "/absolute/path/to/file" --caption "Short description" + ``` + + Omit `--caption` when it adds no value. If `ccbot` is not on `PATH`, use the + repository environment, for example `.venv/bin/ccbot send-file ...` or + `uv run ccbot send-file ...`. +3. Treat exit code `0` and the emitted `sent ...: ok` line as delivery proof. + Report a non-zero exit and its sanitized error; do not claim success. + +`ccbot send-file` automatically switches from the filesystem relay to direct +Telegram delivery when the daemon relay is unavailable. Let that built-in +fallback finish before trying anything else. + +Image extensions `.png`, `.jpg`, `.jpeg`, `.webp`, and `.gif` are sent as +Telegram photos. Other files are sent as documents with their filename. + +## Resolve the target safely + +Target precedence is: + +1. Explicit `--chat-id ID` only when the user requested a specific allowed chat. +2. `CCBOT_CHAT_ID`, injected automatically into an owned Claude/Codex tmux + session. This is the normal path. +3. Every ID from `ALLOWED_USERS` when the session has no single owner. + +Do not print IDs unless troubleshooting requires identifying the target and the +user authorized it. The daemon rejects IDs outside `ALLOWED_USERS`. + +## Locate configuration without exposing it + +Configuration lookup order is repository `.env`, then +`${CCBOT_DIR:-~/.ccbot}/.env`. Relevant variable names are: + +- `CCBOT_CHAT_ID`: current session target; normally present in the process + environment, not stored manually. +- `ALLOWED_USERS`: allowed numeric Telegram user IDs. +- `TELEGRAM_BOT_TOKEN`: daemon credential originally obtained from BotFather. + +The running daemon already owns `TELEGRAM_BOT_TOKEN`; `ccbot send-file` normally +uses its filesystem relay and does not need the agent to read the token. Check +only whether a variable or config file exists. Never echo, log, paste, commit, +or include token/ID values in a command transcript, caption, filename, or answer. + +## Reserve direct channel + +Use a manual direct call only when the `ccbot send-file` entry point itself +cannot run, not merely while it is waiting for its relay result. Prefer the +project implementation over handwritten `curl`: load `ccbot.config.config`, +resolve the same target precedence with `ccbot.send_file.resolve_chat_ids`, and +call `ccbot.send_file._send_all(path, caption, chat_ids)` from the repository's +Python environment. Pass the path and caption as arguments or constants; never +embed token or chat-ID values in the script or command line. + +The direct channel still reads `TELEGRAM_BOT_TOKEN` and `ALLOWED_USERS` from the +normal configuration lookup. If configuration is absent, stop and report which +variable name is missing. Do not request or reveal its value in chat. + +## Guardrails + +- Send only the file the user requested or a clearly identified task artifact. +- Inspect filenames and intended contents for credentials, private keys, `.env` + data, access tokens, cookies, personal data, and unrelated workspace content. +- Ask before sending when the file's sensitivity or target is ambiguous. +- Prefer an absolute, explicitly quoted path; do not use broad globs. +- Do not switch to the direct channel or retry blindly after a timeout: + Telegram may have accepted the first delivery. Check the command result and + bot logs first. +- Keep generated artifacts in the task/repository scope; do not copy secrets + into a new file merely to send them. diff --git a/.agents/skills/ccbot-send-file/agents/openai.yaml b/.agents/skills/ccbot-send-file/agents/openai.yaml new file mode 100644 index 00000000..bdc25bef --- /dev/null +++ b/.agents/skills/ccbot-send-file/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CCBot Send File" + short_description: "Send generated files through the CCBot relay" + default_prompt: "Use $ccbot-send-file to deliver a generated file to my Telegram chat." diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index f942a4cb..b8c52b3c 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -73,7 +73,8 @@ Additional modules: screenshot.py ─ Terminal text → PNG rendering (ANSI color, font fallback) transcribe.py ─ Voice-to-text transcription via whisper.cpp / Apple Speech - i18n.py ─ Per-user UI strings (en / ru / zh) + i18n.py ─ Compatibility facade for per-user translations + i18n_locales/ ─ English / Russian / Chinese translation catalogs naming.py ─ lightweight-model-generated session names usage.py ─ Token usage aggregator + per-session token alerts main.py ─ CLI entry point (ccbot / ccbot hook / ccbot send-file) @@ -83,12 +84,19 @@ Additional modules: --chat-id > $CCBOT_CHAT_ID > all ALLOWED_USERS) utils.py ─ Shared utilities (ccbot_dir, atomic_write_json) session_models.py ─ Session / WindowState / ClaudeSession dataclasses + session_state.py ─ active routing, lifecycle, settings, persistence helpers + session_map.py ─ hook map reconciliation and transcript resolution session_recovery.py ─ Startup hygiene: reconcile w/ tmux + resolve stale window IDs session_claude_io.py─ Read-only Claude transcript discovery (encode_cwd, list, get) transcript_format.py─ Tool-summary + tool-result formatting (was inside TranscriptParser) + transcript_types.py ─ ParsedEntry / ParsedMessage / pending-tool DTOs + transcript_message.py / transcript_codex.py ─ backend-specific normalization + terminal_usage.py ─ /usage models and terminal-output parsing + tmux_process.py ─ orphan process cleanup + tmux_window.py ─ backend command assembly and tmux window creation logging_setup.py ─ Logging config (level via LOG_LEVEL, JSON via CCBOT_LOG_FORMAT) metrics.py ─ In-process counters → metrics.json - rich.py ─ Bot API 10.1 rich messages via raw Bot._post + rich.py ─ Bot API 10.2 rich messages via raw Bot._post (sendRichMessage / rich edit; to_rich_markdown escapes bare < and maps expandable-quote sentinels to
); safe_* try rich first, fall back to @@ -106,7 +114,9 @@ Additional modules: bot/ package (was bot.py before A1, split per CLAUDE.md size budget): __init__.py ─ Re-exports create_bot, forward_command_handler - app.py ─ create_bot, post_init/shutdown, handler registration + app.py ─ Compatibility facade + watchdog/error handling + _app_lifecycle.py ─ post_init/post_shutdown orchestration + _app_routes.py ─ Application construction and handler registration _common.py ─ is_user_allowed, active_window, resolve_ident, render_session_preview, set_view, open_more_in_place, is_window_busy, shorten_workdir, CC_COMMANDS @@ -116,8 +126,11 @@ bot/ package (was bot.py before A1, split per CLAUDE.md size budget): modal body; parser picks the LAST modal header in the buffer to ignore stale prior attempts) _session_create.py ─ create_and_activate_session (dir-browser → tmux flow) - messages.py ─ text/voice/photo/document handlers, forward_command_handler, - bash !cmd capture + messages.py ─ Compatibility facade for inbound message handlers + _messages_shared.py ─ delivery proof, card bracketing, prompt interception + _messages_text.py ─ text routing and bash capture + _messages_voice.py ─ voice checkpoint/transcription routing + _messages_media.py ─ photo/document/forwarded content handling session_events.py ─ handle_new_message — claude → TG dispatch commands/lifecycle.py ─ /new /kill /done /stop /menu /archive (+ archive_session shared helper) @@ -141,11 +154,11 @@ bot/ package (was bot.py before A1, split per CLAUDE.md size budget): Handler modules (handlers/): message_sender.py ─ safe_reply/safe_edit/safe_send + send_with_fallback - message_queue.py ─ Per-user queue + worker (merge, status dedup) status_polling.py ─ Background status line polling (1s interval) + auto-approve hook for interactive prompts + bg-window interactive-UI detection (suppress + stash) - notifications.py ─ Live card per session + push events + completion + + status_approval.py ─ pure auto-approval parsing/signature helpers + notifications.py ─ Compatibility facade for live-card orchestration + bg-status panel injection + refresh_panel + repost_card (always-repost behaviour: every user-msg replaces the card by a fresh one below) @@ -156,7 +169,9 @@ Handler modules (handlers/): Persisted in state.json (status/last_change/context_pct; pending UI re-detected after restart by terminal_parser). archive.py ─ /archive page rendering + restore + idle/purge sweeps - history.py ─ Paginated /history rendering (with optional extra rows) + archive_blurb.py ─ archive summary text cleanup and formatting + history.py ─ Live paginated /history cache and presentation + history_archive.py ─ archived transcript/card page rendering quota_alerts.py ─ Background /usage modal poll (default 10 min) → 5h/weekly band crossings 50/75/90 % inbox.py ─ photo/document inbox under /.ccbot-inbox/ @@ -166,21 +181,34 @@ Handler modules (handlers/): via notifications.enter_kb_mode on the claimed carrier. directory_browser.py─ Directory + session picker UI builders switcher.py ─ Inline session-switcher keyboard - menu.py ─ Footer / More / Settings keyboard composition; + menu.py ─ Footer / More keyboard composition and settings facade; [+ new] [≡ Menu] share the bottom row on screen="main" cleanup.py ─ Per-window state cleanup on archive callback_data.py ─ Callback data prefix constants tg_format.py ─ Table/code overflow → file attachment - card_model.py ─ Event/CardState dataclasses + render/paginate/seed - helpers (pure model layer split from notifications.py; - notifications.py re-exports its names as a facade) + card_model.py ─ Compatibility facade for card types/render/pagination + card_types.py ─ Event/CardState data only + card_events.py ─ monitor message → card event conversion + card_text.py ─ transcript text parsing and sanitization + card_budget.py ─ line/byte budgeting and chunking + card_event_render.py─ individual event rendering + card_pagination.py ─ page boundaries and user-specific page sizing + card_layout.py ─ complete card body composition + card_registry.py ─ mutable card ownership, locks, and message registry + card_seed.py ─ JSONL seeding + card_carrier.py ─ pause/transfer/restore carrier lifecycle + card_transport.py ─ Telegram send/edit/photo operations + card_rich_media.py ─ RUNNING-only inline pane placement and Rich Markdown + photo reuse (body → pane → context → bg panel; removed + on idle/final/clear; legacy photo/text fallback) + card_updates.py ─ event application/finalization/attachments + card_stall.py ─ silent-turn pane refresh + bg ⚠️ state + card_surface.py ─ timers, panel refresh, and receipt scheduling kb_mode.py ─ kb-mode keyboard builder + pane-capture-to-PNG helper typing.py ─ Per-user throttle in front of send_chat_action(TYPING) (status_polling + session_events share one timer) - response_builder.py ─ Paginated response builder (display truncation) - context_poll.py ─ Background /context poller — PRESENT but DISABLED; - JSONL math (usage.context_pct_for_session) is the live - path (see bot/app.py) + +Responsibility-level extension guide: `doc/refactor-architecture.md`. State files (~/.ccbot/ or $CCBOT_DIR/): state.json ─ window states + display names + read offsets + user diff --git a/.claude/skills/ccbot-send-file b/.claude/skills/ccbot-send-file new file mode 120000 index 00000000..e9449199 --- /dev/null +++ b/.claude/skills/ccbot-send-file @@ -0,0 +1 @@ +../../.agents/skills/ccbot-send-file \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4e4369c9..8daa36f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,6 +27,9 @@ jobs: - name: ruff format run: uv run ruff format --check src/ tests/ + - name: module size budgets + run: uv run python scripts/check_module_size.py + - name: pyright run: uv run pyright src/ccbot/ diff --git a/CLAUDE.md b/CLAUDE.md index dfdafe84..6e939cd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,10 @@ ccbot hook --install # Auto-install Claude Code SessionStart ho ## Code Conventions - Every `.py` file starts with a module-level docstring: purpose clear within 10 lines, one-sentence summary first line, then core responsibilities and key components. +- Hard module budgets: `src/ccbot/bot/**/*.py` is capped at 600 physical + lines; all other `src/ccbot/**/*.py` modules are capped at 800. Run + `python scripts/check_module_size.py`; split by reason to change rather than + creating generic helper dumps. - Telegram interaction: prefer inline keyboards over reply keyboards; use `edit_message_text` for in-place updates; keep callback data under 64 bytes; use `answer_callback_query` for instant feedback. ## Configuration diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66227bea..bcaca287 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,8 +68,10 @@ fix the offending content — never bypass with `--no-verify`. This is what the optional `CCBOT_LOG_FORMAT=json` mode uses to produce one JSON line per record. One-shot logs from rare paths can stay as plain strings. -- **600-LOC ceiling** under `src/ccbot/bot/`. Files in `src/ccbot/` may - go up to 800 LOC temporarily; over that, decompose in the same PR. +- **Hard module budgets.** Files under `src/ccbot/bot/` are capped at + 600 physical lines; every other Python module under `src/ccbot/` is capped + at 800. `python scripts/check_module_size.py` enforces both limits in CI. + Prefer splitting around one reason to change before a file reaches its cap. - **No comments explaining "what"** — the code says that. Only write comments when the *why* is non-obvious (a hidden constraint, a workaround, surprising behaviour). diff --git a/README.md b/README.md index 915c74c8..c74e1bea 100644 --- a/README.md +++ b/README.md @@ -330,9 +330,20 @@ Card knobs live under *Settings → 🃏 Card / view*: | ------- | ------- | ------ | | `Card history` | `20` | end-of-turn boundaries seeded into a fresh card from the JSONL (survives bot restarts) | | `Page size` | `20` lines | max lines per card page; longer bodies chunk across pages on paragraph/sentence boundaries | -| `Inline screenshots` | `off` | card becomes photo + caption — the photo is the live pane render (caption limit is 1024 chars, so shrink page size to compensate) | +| `Inline screenshots` | `off` | shows the live terminal pane inside the active card while a turn is running | | `Live lag` | `4s` | coalescing window for preview updates | +The inline pane exists only in the **RUNNING** state. Its order is +`body → gap → pane → gap → context → background panel`; it is removed on +**IDLE**, final answer, and `/clear`, then appears again when the next turn +starts. Rich-capable Bot API servers keep text and media in one Rich Markdown +message. Older servers use photo + caption. A failed rich send falls back to +legacy photo, then text-only; transient edits retry on the next update, and a +lost carrier is recreated without posting an immediate duplicate. +If an unfinished turn goes silent, the active card keeps its pane instead of +inserting a warning or sending a push. The same condition on a background +session is shown only as `⚠️` beside that session in the background panel. + Telegram's chat-header **`typing…` indicator** is driven by real claude events. As long as the active session keeps emitting (tool calls, thinking, text), `typing…` stays on; an idle session lets it @@ -437,20 +448,25 @@ half-rendered modal can't fire a phantom alert. ## Architecture -The full module map is `.claude/rules/architecture.md`. At a glance: +The full module map is `.claude/rules/architecture.md`; the responsibility-level +extension guide is `doc/refactor-architecture.md`. At a glance: ``` src/ccbot/ ├── main.py — CLI entry point (`ccbot`, `ccbot hook`, `ccbot send-file`) ├── config.py — env-var loader (singleton) -├── session.py — Session + SessionManager (state.json) +├── session.py — SessionManager compatibility facade / resume flow +├── session_state.py — routing, lifecycle, settings, state.json +├── session_map.py — hook/window binding reconciliation ├── session_monitor.py — JSONL polling, NewMessage callbacks ├── codex_session_io.py — Codex rollout JSONL discovery and reading ├── codex_auth.py — account/read + device-code login ├── codex_usage.py — Codex app-server rate limits ├── session_import.py — cross-agent restore handoff -├── transcript_parser.py — JSONL turn parsing +├── transcript_parser.py — JSONL parser compatibility facade +├── transcript_*.py — message/Codex/type-specific parsing ├── terminal_parser.py — interactive-UI + status-line detection +├── terminal_usage.py — /usage models and parsing ├── tmux_manager.py — libtmux wrapper ├── rich.py — Bot API 10.1 rich messages (native markdown) ├── markdown_v2.py — MD → Telegram MarkdownV2 (fallback path) @@ -460,16 +476,20 @@ src/ccbot/ ├── send_file.py — `ccbot send-file` outbound delivery ├── local_terminal.py — native-terminal attach helper ├── usage.py — token aggregator, context %, alert logic -├── i18n.py — en / ru / zh UI strings +├── i18n.py — translation service compatibility facade +├── i18n_locales/ — en / ru / zh UI catalogs ├── bot/ — Telegram-facing handlers (≤ 600 LOC each) -│ ├── app.py — Application bootstrap, post_init / post_shutdown -│ ├── messages.py — text / voice / photo / document / forward +│ ├── app.py — bootstrap/watchdog compatibility facade +│ ├── _app_*.py — lifecycle and handler registration +│ ├── messages.py — inbound compatibility facade +│ ├── _messages_*.py — text / voice / media / shared delivery │ ├── session_events.py — claude → TG dispatch │ ├── commands/ — slash command bodies │ └── callbacks/ — one file per CB_* prefix └── handlers/ - ├── notifications.py — live cards + push events - ├── card_model.py — card state / render / paginate model layer + ├── notifications.py — live-card compatibility facade + ├── card_model.py — card-model compatibility facade + ├── card_*.py — state / render / paginate / transport / lifecycle ├── bg_status.py — background-session status panel ├── archive.py — /archive page rendering + idle sweeps ├── quota_alerts.py — background /usage poll diff --git a/README_CN.md b/README_CN.md index 8e0a4427..6ee2684b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -284,9 +284,19 @@ Enter / Esc 键盘。 | ---- | ---- | ---- | | `卡片历史` | `20` | 从 JSONL 预加载进新卡片的 end-of-turn 边界数(机器人重启后仍在) | | `页面大小` | `20` 行 | 每页最多行数;长正文按段落/句子边界跨页切分 | -| `内联截图` | `off` | 卡片变为图片 + 说明文字,图片是实时面板渲染(说明限 1024 字符,需相应调小页面大小) | +| `内联截图` | `off` | 仅在 turn 运行时于活动卡片内显示终端 pane | | `实时延迟` | `4s` | 预览更新的合并窗口 | +Pane 仅在 **RUNNING** 状态存在,顺序为 +`正文 → 间距 → pane → 间距 → context → 后台面板`。进入 **IDLE**、 +收到最终回答或执行 `/clear` 时会移除;下一轮 turn 开始后再次出现。 +支持 Rich 的 Bot API 会把文本和媒体保留在同一条 Rich Markdown 消息 +中;旧版 API 使用图片 + 说明文字。Rich 发送失败时依次回退到 legacy +图片和纯文本;临时 edit 失败会在下次更新重试,carrier 丢失则重新创建, +不会立即发送重复卡片。 +如果未完成的 turn 长时间无活动,活动卡片会保留 pane,不会插入警告占位 +或发送单独通知。后台会话只在后台面板中的会话名称旁显示 `⚠️`。 + Telegram 聊天头部的 **`正在输入…`** 指示由真实的 claude 事件驱动。 只要活动会话仍在发出事件(tool 调用、思考、文本),`正在输入…` 就 持续显示;空闲会话会让它在 Telegram 的 ~5 秒窗口内自然消失。 diff --git a/README_RU.md b/README_RU.md index 9d778138..172cbc28 100644 --- a/README_RU.md +++ b/README_RU.md @@ -327,9 +327,21 @@ foreground-промпте. | --------- | ------ | ------ | | `История в карточке` | `20` | сколько end-of-turn-границ подгружается в свежую карточку из JSONL (переживает рестарт бота) | | `Размер страницы` | `20` строк | максимум строк на страницу; длинное тело режется по границам абзацев/предложений | -| `Скрины в карточке` | `off` | карточка становится фото + подпись, фото — рендер живой панели (лимит подписи 1024 символа, уменьшай размер страницы) | +| `Скрины в карточке` | `off` | показывает live-pane терминала внутри активной карточки, пока идёт turn | | `Лаг карточки` | `4s` | окно коалесцинга обновлений превью | +Pane живёт только в состоянии **RUNNING**. Порядок блоков: +`тело → отступ → pane → отступ → context → фоновые сессии`. В **IDLE**, +после финального ответа и `/clear` скрин удаляется, а на следующем turn +появляется снова. Rich Bot API держит текст и картинку в одной Rich +Markdown-карточке; старый API использует фото + подпись. Если rich-send не +прошёл, бот пробует legacy-photo, затем text-only. Временный сбой edit +повторяется на следующем обновлении, потерянный carrier пересоздаётся без +мгновенного дубля. +Если незавершённый turn подозрительно замолчал, активная карточка сохраняет +pane без заглушки и отдельного push. У фоновой сессии в этом случае появляется +только `⚠️` напротив её имени в блоке фоновых сессий. + Индикатор Telegram **`печатает…`** в шапке чата управляется реальными событиями claude. Пока активная сессия эмитит (tool-call, thinking, текст) — `печатает…` горит; idle-сессия даёт ему погаснуть diff --git a/doc/dm-multisession-spec.md b/doc/dm-multisession-spec.md index 5228637b..6adcbddd 100644 --- a/doc/dm-multisession-spec.md +++ b/doc/dm-multisession-spec.md @@ -211,6 +211,15 @@ Notifications come in two forms. Edits do not trigger Telegram push notifications, so this is rate-limit friendly. The card is replaced (a new card sent, the old one finalized in chat history) on session completion or error. +With `card_inline_screenshots` enabled, the terminal pane is present only while +the turn is **RUNNING**. The stable block order is `body → gap → pane → gap → +context → background panel`. **IDLE**, final-answer, and `/clear` transitions +remove it; the next turn adds it again. Rich-capable Bot API servers keep the +text and pane media in one Rich Markdown message. Older servers use the legacy +photo + caption carrier. Initial delivery falls back rich → legacy photo → +text-only. A transient edit failure is retried by the next update; a lost +carrier is recreated rather than immediately duplicated. + **Push notifications** are sent as separate `send_message` calls only on key events: - Task completion @@ -369,6 +378,7 @@ Transcript and quota data have separate sources: - On-demand, not polled: a session hands the user a file by running `ccbot send-file [--caption TEXT]` (`send_file.py`) directly — no drop directory, no delay. Image extensions go out via `send_photo`, everything else via `send_document`; the command prints a pass/fail line per target chat so the invoking tool call carries real feedback back to Claude. - Target chat resolution: `--chat-id` override > `$CCBOT_CHAT_ID` (exported by `tmux_manager.create_window` at spawn time from the Telegram user who created/owns the session — see `owner_user_id`) > broadcast to every `ALLOWED_USERS` entry (used for windows with no single owner, e.g. the internal usage-check window). +- A silent unfinished turn is never converted into a synthetic final warning. If it is active, its RUNNING card keeps refreshing the terminal pane; if it is background, only a `⚠️` status appears beside it in the background panel. No separate stall push is sent. - No MCP tool involved; Claude just needs to know the convention (documented for it via the container's `~/.claude/CLAUDE.md`, keyed off `CCBOT_INTERFACE=telegram` the same way output-format guidance is). --- diff --git a/doc/refactor-architecture.md b/doc/refactor-architecture.md new file mode 100644 index 00000000..caa8b324 --- /dev/null +++ b/doc/refactor-architecture.md @@ -0,0 +1,87 @@ +# Refactored module map + +This tree keeps the existing product behaviour and import paths, but separates +implementation by reason to change. Historical modules such as +`ccbot.session`, `ccbot.bot.messages`, `ccbot.handlers.card_model`, and +`ccbot.handlers.notifications` remain compatibility facades. + +## Where a change belongs + +| Change | Primary module | +|---|---| +| Add or edit UI copy | `i18n_locales/en.py`, `ru.py`, `zh.py` | +| Add a setting and its choices | `handlers/menu_settings_data.py` | +| Change a settings keyboard | `handlers/menu_settings.py` | +| Change footer/menu composition | `handlers/menu.py` | +| Change persisted session state | `session_state.py` | +| Change hook/window bindings | `session_map.py` | +| Change resume readiness or prompt delivery | `session.py` | +| Change tmux process cleanup | `tmux_process.py` | +| Change tmux window creation or backend command | `tmux_window.py` | +| Change terminal interactive/status parsing | `terminal_parser.py` | +| Change terminal `/usage` parsing | `terminal_usage.py` | +| Change transcript DTOs | `transcript_types.py` | +| Change Claude/Codex transcript normalization | `transcript_message.py`, `transcript_codex.py` | +| Change Telegram app startup/shutdown | `bot/_app_lifecycle.py` | +| Register a Telegram handler | `bot/_app_routes.py` | +| Change text routing | `bot/_messages_text.py` | +| Change voice routing | `bot/_messages_voice.py` | +| Change photo/document/forward handling | `bot/_messages_media.py` | +| Change shared inbound delivery rules | `bot/_messages_shared.py` | +| Add card state or an event field | `handlers/card_types.py` | +| Parse monitor output into a card event | `handlers/card_events.py` | +| Sanitize transcript text for cards | `handlers/card_text.py` | +| Change one event's visual form | `handlers/card_event_render.py` | +| Change line/byte budgets | `handlers/card_budget.py` | +| Change page boundaries or page selection | `handlers/card_pagination.py` | +| Change the complete card layout | `handlers/card_layout.py` | +| Change card ownership and lock state | `handlers/card_registry.py` | +| Seed a card from JSONL | `handlers/card_seed.py` | +| Move/pause/restore a card carrier | `handlers/card_carrier.py` | +| Send or edit a Telegram card | `handlers/card_transport.py` | +| Change RUNNING-only inline-pane placement or rich photo reuse | `handlers/card_rich_media.py` | +| Apply/finalize session events | `handlers/card_updates.py` | +| Keep silent turns observable / mark bg stalls | `handlers/card_stall.py` | +| Change card timer/panel scheduling | `handlers/card_surface.py` | +| Change archived-session history rendering | `handlers/history_archive.py` | +| Change live history cache/presentation | `handlers/history.py` | +| Change auto-approval parsing | `handlers/status_approval.py` | +| Change status polling orchestration | `handlers/status_polling.py` | +| Change archive list blurbs | `handlers/archive_blurb.py` | +| Change archive restore/sweep flow | `handlers/archive.py` | + +## Dependency direction + +Keep dependencies pointing from orchestration toward leaf modules: + +```text +types/data -> parsing and pure formatting -> state services + -> Telegram/tmux adapters -> lifecycle/composition roots +``` + +- Data and parsing modules must not import Telegram application assembly. +- `card_types.py` contains state only; it must not acquire I/O or persistence. +- Telegram transport belongs in `card_transport.py`, not rendering modules. +- Compatibility facades may synchronize monkeypatchable dependencies, but new + business logic belongs in the focused implementation module. +- Avoid generic `helpers.py` modules. Name a module after the responsibility + that will cause it to change. + +## Compatibility policy + +Old import paths remain stable until a separate breaking migration. Some +underscore-prefixed symbols are imported or monkeypatched by the current test +suite and therefore form a de-facto compatibility contract. When moving a +stateful function, preserve mutable object identity and ensure patches on the +facade still reach the implementation. + +## Size guard + +`python scripts/check_module_size.py` enforces the hard limits: + +- `src/ccbot/bot/**/*.py`: 600 physical lines; +- all other `src/ccbot/**/*.py`: 800 physical lines. + +Aim below 550 and 700 respectively so the next feature has room. A module near +the hard cap should be split around a responsibility boundary in the same +change that grows it. diff --git a/scripts/check_module_size.py b/scripts/check_module_size.py new file mode 100644 index 00000000..3ee9344d --- /dev/null +++ b/scripts/check_module_size.py @@ -0,0 +1,51 @@ +"""Enforce the repository's per-module line-count budgets. + +Telegram application modules under ``src/ccbot/bot`` are capped at 600 +physical lines; every other Python source module is capped at 800. The check +is intentionally simple and deterministic so it behaves the same locally and +in CI. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +BOT_LIMIT = 600 +DEFAULT_LIMIT = 800 + + +def module_limit(path: Path, source_root: Path) -> int: + """Return the applicable hard limit for one source module.""" + relative = path.relative_to(source_root) + return BOT_LIMIT if relative.parts[0] == "bot" else DEFAULT_LIMIT + + +def oversized_modules(source_root: Path) -> list[tuple[Path, int, int]]: + """Collect ``(path, actual_lines, limit)`` for every violation.""" + violations: list[tuple[Path, int, int]] = [] + for path in sorted(source_root.rglob("*.py")): + actual = len(path.read_text(encoding="utf-8").splitlines()) + limit = module_limit(path, source_root) + if actual > limit: + violations.append((path, actual, limit)) + return violations + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "source_root", + nargs="?", + type=Path, + default=Path("src/ccbot"), + ) + args = parser.parse_args() + violations = oversized_modules(args.source_root) + for path, actual, limit in violations: + print(f"{path}: {actual} lines exceeds {limit}") + return int(bool(violations)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ccbot/bot/_app_lifecycle.py b/src/ccbot/bot/_app_lifecycle.py new file mode 100644 index 00000000..38b66370 --- /dev/null +++ b/src/ccbot/bot/_app_lifecycle.py @@ -0,0 +1,388 @@ +"""Application startup and shutdown implementation. + +Public entry points remain in :mod:`ccbot.bot.app`. +""" + +from __future__ import annotations + +import asyncio +import threading +import time +from typing import Any, TYPE_CHECKING, cast + +from telegram import BotCommand +from telegram.ext import ( + Application, +) + +from ..inbound_queue import shutdown_inbound_queues + +from ..config import config +from ..handlers.quota_alerts import quota_alerts_loop +from ..handlers.notifications import card_timer_loop, shutdown_card_surface_tasks +from ..handlers.status_polling import status_poll_loop +from ..metrics import metrics_flush_loop +from ..session import session_manager +from ..session_monitor import NewMessage, SessionMonitor +from ._common import CC_COMMANDS +from .commands.auth import ( + ensure_codex_auth_on_start, + shutdown_auth_flows, +) +from .session_events import handle_new_message + +# Module-globals owned by the lifecycle hooks. + +if TYPE_CHECKING: + # Runtime-injected by the compatibility facade before each call. + LIVENESS_MAX_STALE_SECONDS = cast(Any, None) + _heartbeat_loop = cast(Any, None) + _liveness_watchdog_loop = cast(Any, None) + logger = cast(Any, None) + +session_monitor: SessionMonitor | None = None +# Set in ``post_init`` so ``_error_handler`` can reach the Application even +# when ``update`` is not an Update (Conflict updates carry no chat). +_conflict_app: "Application[Any, Any, Any, Any, Any, Any] | None" = None +_status_poll_task: asyncio.Task[None] | None = None +_card_timer_task: asyncio.Task[None] | None = None +_quota_alerts_task: asyncio.Task[None] | None = None +_metrics_flush_task: asyncio.Task[None] | None = None +_heartbeat_task: asyncio.Task[None] | None = None +_auth_preflight_task: asyncio.Task[None] | None = None +_usage_prewarm_task: asyncio.Task[None] | None = None +_send_file_relay_task: asyncio.Task[None] | None = None + + +async def post_init(application: "Application[Any, Any, Any, Any, Any, Any]") -> None: + """First task after Application is built. Publish menu, recover state, start monitors.""" + global \ + session_monitor, \ + _status_poll_task, \ + _card_timer_task, \ + _quota_alerts_task, \ + _metrics_flush_task, \ + _heartbeat_task, \ + _auth_preflight_task, \ + _usage_prewarm_task, \ + _send_file_relay_task, \ + _last_heartbeat, \ + _conflict_app + + # Reachable from ``_error_handler`` for the sustained-Conflict exit + # path (Conflict updates carry no chat, so ``update`` is not an Update). + _conflict_app = application + + # Agent sessions may have neither network nor cross-process socket access. + # Consume filesystem-relay requests and perform Telegram delivery here. + from ..send_file import send_file_relay_loop + + _send_file_relay_task = asyncio.create_task(send_file_relay_loop(application.bot)) + + # Warm the directory browser's recursive index off the startup path. The + # picker itself always paints from cache/shallow metadata and never waits + # for this scan. + from ..handlers.directory_browser import prewarm_directory_recency + + prewarm_directory_recency() + logger.info("Directory-recency cache pre-warm scheduled") + + # Cache bot username so ``tmux_manager.create_window`` can surface it + # to Claude via ``CCBOT_BOT_USERNAME``. ``application.bot.username`` + # triggers a ``getMe`` if not already populated; with ``initialize()`` + # already done by run_polling this is a cached property. + try: + config.bot_username = application.bot.username or "" + except Exception as e: + logger.debug("Could not resolve bot.username: %s", e) + + await application.bot.delete_my_commands() + + # Trimmed /-menu surface. New/Status/Shot/Settings/Archive all live + # behind the inline ≡ Menu; Stop/Kill/Clear in the live-card footer. + # ``/history`` is published — it's the canonical entry to the FULL + # JSONL transcript view (deep history); the live card itself only + # seeds the last CARD_SEED_TURNS end-of-turn boundaries. + # Hidden commands still work when typed. + bot_commands = [ + BotCommand("menu", "Open menu"), + BotCommand("help", "Quick guide / inline doc"), + BotCommand("history", "Full transcript of the active session"), + BotCommand("done", "Mark a session as done"), + ] + for cmd_name in ("model", "effort", "compact", "memory"): + if cmd_name in CC_COMMANDS: + bot_commands.append(BotCommand(cmd_name, CC_COMMANDS[cmd_name])) + + await application.bot.set_my_commands(bot_commands) + + # Re-resolve stale window IDs from persisted state against live tmux windows. + await session_manager.resolve_stale_ids() + # DM mode: cross-check Session records against live tmux. Sessions whose + # window vanished get state=lost and surface in the switcher with a + # Restore button. + await session_manager.reconcile_sessions_with_tmux() + + # A fresh Codex host should be operable from Telegram alone. Read auth + # state after the bot is online and automatically start the official + # device-code flow when no account is present. + _auth_preflight_task = asyncio.create_task( + ensure_codex_auth_on_start(application.bot) + ) + logger.info("Agent auth preflight scheduled") + + async def _prewarm_live_usage() -> None: + """Populate Status cache off the user interaction path after auth.""" + try: + if _auth_preflight_task is not None: + await _auth_preflight_task + from ._usage_window import fetch_live_usage + + info = await fetch_live_usage() + logger.info("Live usage cache pre-warmed ok=%s", info is not None) + except asyncio.CancelledError: + raise + except Exception as e: + logger.debug("Live usage cache pre-warm failed: %s", e) + + _usage_prewarm_task = asyncio.create_task(_prewarm_live_usage()) + logger.info("Live usage cache pre-warm scheduled") + + # Pre-fill global rate limiter bucket on restart. AsyncLimiter starts at + # _level=0 (full burst capacity), but Telegram's server-side counter + # persists across bot restarts. Force the bucket to start "full" so + # capacity drains in naturally (~1s). + rate_limiter = application.bot.rate_limiter + if rate_limiter and rate_limiter._base_limiter: + rate_limiter._base_limiter._level = rate_limiter._base_limiter.max_rate + logger.info("Pre-filled global rate limiter bucket") + + monitor = SessionMonitor() + + async def message_callback(msg: NewMessage) -> None: + await handle_new_message(msg, application.bot) + + monitor.set_message_callback(message_callback) + monitor.start() + session_monitor = monitor + logger.info("Session monitor started") + + _status_poll_task = asyncio.create_task(status_poll_loop(application.bot)) + logger.info("Status polling task started") + + _card_timer_task = asyncio.create_task(card_timer_loop(application.bot)) + logger.info("Card timer task started") + + _quota_alerts_task = asyncio.create_task(quota_alerts_loop(application.bot)) + logger.info("Quota alerts task started") + + # Per-session context % is computed from JSONL math + # (usage.context_pct_for_session) — NOT by polling /context into panes. + # Polling wrote the modal's markdown into each session's JSONL as a fake + # user-turn (polluting the live card + burning tokens), so that path was + # removed. See doc/dm-multisession-spec.md §4.6. + + _metrics_flush_task = asyncio.create_task(metrics_flush_loop()) + logger.info("Metrics flush task started") + + _last_heartbeat = time.monotonic() + _heartbeat_task = asyncio.create_task(_heartbeat_loop()) + threading.Thread( + target=_liveness_watchdog_loop, daemon=True, name="ccbot-liveness-watchdog" + ).start() + logger.info( + "Liveness watchdog started (stale>%.0fs triggers exit)", + LIVENESS_MAX_STALE_SECONDS, + ) + + # Pre-warm the history-page cache for every active/idle session so + # the user's first switcher tap after a restart doesn't pay the + # ~1 s parse cost of walking a multi-thousand-message JSONL. Runs + # off the boot path so it can't delay the bot coming online. + async def _prewarm_history_caches() -> None: + from ..handlers.history import prewarm_pages_cache + + for sess in list(session_manager.sessions.values()): + if sess.state not in ("active", "idle") or not sess.window_id: + continue + try: + await prewarm_pages_cache(sess.window_id) + except Exception as e: + logger.debug("prewarm failed for %s: %s", sess.window_id, e) + + asyncio.create_task(_prewarm_history_caches()) + logger.info("History cache pre-warm scheduled") + + # Seed bg_status for sessions that are still "working" so a + # restart-spanned in-progress session lands in the panel as soon + # as the bot comes up. ``finished`` sessions are NOT seeded — + # they're already-completed turns; if the user noticed them + # before the restart they don't need a repeat notification, and + # if they didn't they can switch into the session to see the + # answer. The fresh-end-of-turn notification path + # (session_events) still fires for sessions that actually + # finish AFTER the bot starts. + async def _seed_bg_statuses() -> None: + from ..handlers import bg_status + from ..handlers.notifications import refresh_panel + from ..usage import context_pct_for_session + + for user_id in config.allowed_users: + active = session_manager.get_active_session(user_id) + active_id = active.id if active is not None else None + changed = False + for sess in list(session_manager.sessions.values()): + if sess.state not in ("active", "idle"): + continue + if sess.id == active_id: + continue + try: + inferred = await bg_status.infer_status_from_jsonl(sess) + except Exception as e: + logger.debug("infer bg status failed for %s: %s", sess.id, e) + continue + if inferred != "working": + continue + if bg_status.update_status(user_id, sess.id, "working"): + changed = True + try: + pct = await context_pct_for_session(sess) + except Exception as e: + logger.debug("infer bg context failed for %s: %s", sess.id, e) + pct = None + if pct is not None: + bg_status.set_context_pct(user_id, sess.id, pct) + changed = True + if changed: + try: + await refresh_panel(application.bot, user_id) + except Exception as e: + logger.debug("refresh_panel after seed failed: %s", e) + + asyncio.create_task(_seed_bg_statuses()) + logger.info("Bg-status seed scheduled") + + # Repaint each user's persisted live card in place. ``_cards`` is + # in-memory only, so without this a restart orphans the card message + # in chat and a fresh one appears on the next event. ``restore_card`` + # rebuilds the CardState, seeds the recent transcript, and edits the + # original message so the live card resumes on the same message. + async def _restore_active_cards() -> None: + from ..handlers.notifications import restore_card + + for user_id in config.allowed_users: + card_msg_id = session_manager.get_card_msg(user_id) + if not card_msg_id: + continue + active = session_manager.get_active_session(user_id) + if active is None: + continue + try: + ok = await restore_card(application.bot, user_id, active, card_msg_id) + logger.info( + "Restored live card user=%d session=%s msg=%d ok=%s", + user_id, + active.id, + card_msg_id, + ok, + ) + except Exception as e: + logger.debug("restore_card failed for user %d: %s", user_id, e) + + asyncio.create_task(_restore_active_cards()) + logger.info("Active-card restore scheduled") + + +async def post_shutdown( + application: "Application[Any, Any, Any, Any, Any, Any]", +) -> None: + """Stop background tasks, flush queues, close HTTP clients.""" + global \ + _status_poll_task, \ + _card_timer_task, \ + _quota_alerts_task, \ + _metrics_flush_task, \ + _heartbeat_task, \ + _auth_preflight_task, \ + _usage_prewarm_task, \ + _send_file_relay_task + + if _usage_prewarm_task: + if not _usage_prewarm_task.done(): + _usage_prewarm_task.cancel() + await asyncio.gather(_usage_prewarm_task, return_exceptions=True) + _usage_prewarm_task = None + + if _auth_preflight_task: + if not _auth_preflight_task.done(): + _auth_preflight_task.cancel() + await asyncio.gather(_auth_preflight_task, return_exceptions=True) + _auth_preflight_task = None + await shutdown_auth_flows() + await shutdown_inbound_queues() + await shutdown_card_surface_tasks() + + if _send_file_relay_task: + _send_file_relay_task.cancel() + await asyncio.gather(_send_file_relay_task, return_exceptions=True) + _send_file_relay_task = None + logger.info("send-file filesystem relay stopped") + + if _status_poll_task: + _status_poll_task.cancel() + try: + await _status_poll_task + except asyncio.CancelledError: + pass + _status_poll_task = None + logger.info("Status polling stopped") + + if _card_timer_task: + _card_timer_task.cancel() + try: + await _card_timer_task + except asyncio.CancelledError: + pass + _card_timer_task = None + logger.info("Card timer stopped") + + if _quota_alerts_task: + _quota_alerts_task.cancel() + try: + await _quota_alerts_task + except asyncio.CancelledError: + pass + _quota_alerts_task = None + logger.info("Quota alerts stopped") + + if _metrics_flush_task: + _metrics_flush_task.cancel() + try: + await _metrics_flush_task + except asyncio.CancelledError: + pass + _metrics_flush_task = None + logger.info("Metrics flush stopped") + + if _heartbeat_task: + _heartbeat_task.cancel() + try: + await _heartbeat_task + except asyncio.CancelledError: + pass + _heartbeat_task = None + logger.info("Liveness heartbeat stopped") + + # Drain anything spawned by the handlers BEFORE we stop the + # session monitor — both helpers do real I/O (history JSONL reads, + # editMessageText calls) that we'd rather see finish or get + # cancelled cleanly instead of being abandoned with the loop. + from ..handlers.history import cancel_pending_prewarm + from ..handlers.notifications import cancel_pending_card_edits + + await cancel_pending_card_edits() + await cancel_pending_prewarm() + + if session_monitor: + await session_monitor.stop() + logger.info("Session monitor stopped") diff --git a/src/ccbot/bot/_app_routes.py b/src/ccbot/bot/_app_routes.py new file mode 100644 index 00000000..22265620 --- /dev/null +++ b/src/ccbot/bot/_app_routes.py @@ -0,0 +1,125 @@ +"""Telegram Application builder and handler registration implementation. + +The public entry point remains in :mod:`ccbot.bot.app`. +""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING, cast + +from telegram.ext import ( + AIORateLimiter, + Application, + CallbackQueryHandler, + CommandHandler, + MessageHandler, + filters, +) + +from ..startup_queue import capture_startup_message + +from ..config import config +from .callbacks import callback_handler +from .commands.auth import ( + login_command, +) +from .commands.info import ( + health_command, + help_command, + history_command, + screenshot_command, + usage_command, +) +from .commands.lifecycle import ( + archive_command, + done_command, + kill_command, + menu_command, + new_command, + stop_command, +) +from .inbound import ( + command_intake_handler, + document_intake_handler, + photo_intake_handler, + text_intake_handler, + unsupported_intake_handler, + voice_intake_handler, +) + + +if TYPE_CHECKING: + # Runtime-injected by the compatibility facade before each call. + _error_handler = cast(Any, None) + logger = cast(Any, None) + post_init = cast(Any, None) + post_shutdown = cast(Any, None) + + +def create_bot() -> "Application[Any, Any, Any, Any, Any, Any]": + """Build the Application, wire all handlers, return it ready to run_polling.""" + builder = ( + Application.builder() + .token(config.telegram_bot_token) + .rate_limiter(AIORateLimiter(max_retries=5)) + .post_init(post_init) + .post_shutdown(post_shutdown) + ) + if config.tg_proxy_url: + # Route both long-poll and Bot API calls through TG_PROXY_URL. + # Required when api.telegram.org is unreachable from the host. + from telegram.request import HTTPXRequest + + builder = builder.request( + HTTPXRequest(proxy=config.tg_proxy_url) + ).get_updates_request(HTTPXRequest(proxy=config.tg_proxy_url)) + logger.info("TG proxy enabled: %s", config.tg_proxy_url) + application = builder.build() + + # Group -1 runs before commands and content handlers. It is a no-op unless + # a new-session flow is open; while open it captures the update and stops + # it from leaking to the previously-active session. + application.add_handler( + MessageHandler( + filters.ALL & ~filters.StatusUpdate.ALL, capture_startup_message + ), + group=-1, + ) + + # Visible menu commands. + application.add_handler(CommandHandler("history", history_command)) + application.add_handler(CommandHandler("screenshot", screenshot_command)) + application.add_handler(CommandHandler("usage", usage_command)) + application.add_handler(CommandHandler("menu", menu_command)) + application.add_handler(CommandHandler("new", new_command)) + application.add_handler(CommandHandler("kill", kill_command)) + application.add_handler(CommandHandler("done", done_command)) + application.add_handler(CommandHandler("stop", stop_command)) + application.add_handler(CommandHandler("archive", archive_command)) + application.add_handler(CommandHandler("health", health_command)) + application.add_handler(CommandHandler("help", help_command)) + # /login stays out of setMyCommands: it is an emergency path surfaced by the + # "authorization expired" notice (text + 🔐 button), not day-to-day UI. + application.add_handler(CommandHandler("login", login_command)) + application.add_handler(CallbackQueryHandler(callback_handler)) + # Forward any other /command to Claude Code. + application.add_handler(MessageHandler(filters.COMMAND, command_intake_handler)) + application.add_handler( + MessageHandler(filters.TEXT & ~filters.COMMAND, text_intake_handler) + ) + application.add_handler(MessageHandler(filters.PHOTO, photo_intake_handler)) + application.add_handler( + MessageHandler(filters.Document.ALL, document_intake_handler) + ) + application.add_handler(MessageHandler(filters.VOICE, voice_intake_handler)) + # Catch-all: non-text content (stickers, video, etc.). + application.add_handler( + MessageHandler( + ~filters.COMMAND & ~filters.TEXT & ~filters.StatusUpdate.ALL, + unsupported_intake_handler, + ) + ) + + application.add_error_handler(_error_handler) + + return application diff --git a/src/ccbot/bot/_messages_media.py b/src/ccbot/bot/_messages_media.py new file mode 100644 index 00000000..5738d8ad --- /dev/null +++ b/src/ccbot/bot/_messages_media.py @@ -0,0 +1,363 @@ +"""Forwarded-content, photo and document handler implementation. + +Public imports remain in :mod:`ccbot.bot.messages`. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, TYPE_CHECKING, cast + +from telegram import Bot, Update +from telegram.error import BadRequest +from telegram.ext import ContextTypes + +from ..handlers.message_sender import ( + safe_reply, +) +from ..handlers.typing import fire_typing +from ..handlers.inbox import save_inbox_file +from ..session import session_manager +from ..tmux_manager import tmux_manager +from ..utils import ccbot_dir +from ._common import active_window, is_user_allowed + + +__all__ = [ + "_forward_attribution", + "_hidden_link_urls", + "unsupported_content_handler", + "_forward_inbox_file", + "photo_handler", + "document_handler", +] + +if TYPE_CHECKING: + # Runtime-injected by the compatibility facade before each call. + _FILE_TOO_BIG_MSG = cast(Any, None) + _await_prior_voice = cast(Any, None) + _card_repost_bracket = cast(Any, None) + _intercept_if_pending_ui = cast(Any, None) + _is_file_too_big = cast(Any, None) + _send_with_delivery_proof = cast(Any, None) + +logger = logging.getLogger(__name__) + + +def _forward_attribution(msg: Any) -> str: + """Return ``[forwarded from @name]\n`` prefix when the message looks + like a Telegram forward. Best-effort across PTB versions: + ``forward_origin`` (PTB ≥ 21) and the legacy ``forward_from_chat`` / + ``forward_from`` fields. Empty string when the message isn't a + forward at all.""" + fo = getattr(msg, "forward_origin", None) + if fo is not None: + chat = getattr(fo, "chat", None) or getattr(fo, "sender_chat", None) + if chat is not None: + handle = ( + getattr(chat, "username", None) + or getattr(chat, "title", None) + or "channel" + ) + return f"[forwarded from @{handle}]\n" + usr = getattr(fo, "sender_user", None) + if usr is not None: + handle = ( + getattr(usr, "username", None) + or getattr(usr, "first_name", None) + or "user" + ) + return f"[forwarded from @{handle}]\n" + name = getattr(fo, "sender_user_name", None) + if name: + return f"[forwarded from {name}]\n" + return "[forwarded]\n" + chat = getattr(msg, "forward_from_chat", None) + if chat is not None: + handle = ( + getattr(chat, "username", None) or getattr(chat, "title", None) or "channel" + ) + return f"[forwarded from @{handle}]\n" + usr = getattr(msg, "forward_from", None) + if usr is not None: + handle = ( + getattr(usr, "username", None) or getattr(usr, "first_name", None) or "user" + ) + return f"[forwarded from @{handle}]\n" + return "" + + +def _hidden_link_urls(msg: Any) -> list[str]: + """Pull URLs out of ``text_link`` entities (anchor-text links whose + actual URL isn't in the visible body). Plain-text URLs are already + in the caption text so we don't duplicate them. Operates on both + ``entities`` (text messages) and ``caption_entities`` (media).""" + out: list[str] = [] + seen: set[str] = set() + sources = [] + if getattr(msg, "caption_entities", None): + sources.append(msg.caption_entities) + if getattr(msg, "entities", None): + sources.append(msg.entities) + for ents in sources: + for ent in ents: + etype = getattr(ent, "type", "") + url = getattr(ent, "url", "") or "" + if etype == "text_link" and url and url not in seen: + out.append(url) + seen.add(url) + return out + + +async def unsupported_content_handler( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + *, + pinned_wid: str | None = None, +) -> bool: + """Catch-all for messages without a dedicated handler. + + When the message carries a caption (typical for forwarded channel + posts that bundle a video + body text), extract the caption + any + hidden ``text_link`` URLs and forward the resulting text to the + active session — the media itself is dropped on the floor since + Claude can't consume it directly, but the body keeps the context. + + Falls back to the legacy "unsupported" reply when there's no + caption to salvage. + """ + if not update.message: + return False + user = update.effective_user + if not user or not is_user_allowed(user.id): + return False + msg = update.message + wid_for_queue = pinned_wid or active_window(user.id) + if wid_for_queue is not None: + if pinned_wid is None and not await _await_prior_voice(user.id, wid_for_queue): + return False + + caption = (msg.caption or "").strip() + if caption: + wid = pinned_wid or active_window(user.id) + if wid is None: + await safe_reply( + msg, + "❌ No active session. Send a text message first or use /new.", + ) + return False + w = await tmux_manager.find_window_by_id(wid) + if not w: + display = session_manager.get_display_name(wid) + await safe_reply( + msg, + f"❌ Window '{display}' no longer exists.\n" + "Send a message to start a new session.", + ) + return False + + prefix = _forward_attribution(msg) + hidden_urls = _hidden_link_urls(msg) + body_parts = [prefix + caption] if prefix else [caption] + if hidden_urls: + body_parts.append("Links:") + body_parts.extend(hidden_urls) + text_to_send = "\n".join(body_parts) + + await fire_typing(context.bot, user.id, "caption_forward", window_id=wid) + if await _intercept_if_pending_ui(context.bot, user.id, wid, msg): + return False + sess = session_manager.find_session_by_window(wid) + async with _card_repost_bracket(context.bot, user.id, sess) as repost: + success, message = await _send_with_delivery_proof(wid, text_to_send, sess) + if not success: + await safe_reply(msg, f"❌ {message}") + return False + if sess is not None: + session_manager.touch_session(sess.id) + repost.commit() + # No success reply — the user just sent the message; they know + # they sent it. Errors above still surface. + return True + + logger.debug("Unsupported content from user %d", user.id) + await safe_reply( + msg, + "⚠ Only text, photo, and voice messages are supported. " + "Stickers, video, and other media cannot be forwarded to Claude Code.", + ) + return True + + +# --- inbox file plumbing (photo + document share this) --- + + +async def _forward_inbox_file( + user_id: int, + wid: str, + chat_id: int, + file_path: Path, + caption: str, + label: str, + bot: Bot, +) -> tuple[bool, str]: + """Route an inbound file to the active session. + + Pane payload is shaped as ``\\n\\n.ccbot-inbox/`` so + claude both (a) knows the file exists and where to read it and + (b) sees whatever instructions the user attached. With no caption + it's just the relative path on its own line. This is a minimal + successor to the old verbose ``(image attached: /full/path)`` + synthetic line — short enough not to feel like "the bot speaking + for the user", complete enough that claude doesn't go blind on a + silent drop. + """ + sess = session_manager.find_session_by_window(wid) + workdir = sess.workdir if sess else "" + if workdir: + rel_path = f".ccbot-inbox/{file_path.name}" + else: + rel_path = str(file_path) + text_to_send = f"{caption}\n\n{rel_path}" if caption.strip() else rel_path + await fire_typing(bot, user_id, "inbox_file_forward", window_id=wid, label=label) + return await _send_with_delivery_proof(wid, text_to_send, sess) + + +async def photo_handler( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + *, + pinned_wid: str | None = None, +) -> bool: + """Drop the user's photo into the active session's inbox + notify Claude.""" + user = update.effective_user + if not user or not is_user_allowed(user.id): + # Drop the message silently — no reply, no callback ack. The + # allowlist is private; unauthorized senders should see the bot + # as inert (no "not authorized" copy that signals "you found the + # right bot, just not the right user"). + return False + + if not update.message or not update.message.photo: + return False + + wid = pinned_wid or active_window(user.id) + if wid is None: + await safe_reply( + update.message, + "❌ No active session. Send a text message first or use /new.", + ) + return False + if pinned_wid is None and not await _await_prior_voice(user.id, wid): + return False + + w = await tmux_manager.find_window_by_id(wid) + if not w: + display = session_manager.get_display_name(wid) + await safe_reply( + update.message, + f"❌ Window '{display}' no longer exists.\n" + "Send a message to start a new session.", + ) + return False + + sess = session_manager.find_session_by_window(wid) + workdir = sess.workdir if sess and sess.workdir else str(ccbot_dir() / "images") + + photo = update.message.photo[-1] + try: + tg_file = await photo.get_file() + except BadRequest as e: + if _is_file_too_big(e): + await safe_reply(update.message, _FILE_TOO_BIG_MSG) + return False + raise + filename = f"{photo.file_unique_id}.jpg" + + async def _fetch(target: Path) -> None: + await tg_file.download_to_drive(target) + + file_path = await save_inbox_file(workdir, filename, _fetch) + + caption = update.message.caption or "" + if await _intercept_if_pending_ui(context.bot, user.id, wid, update.message): + return False + async with _card_repost_bracket(context.bot, user.id, sess) as repost: + success, message = await _forward_inbox_file( + user.id, wid, user.id, file_path, caption, "image", context.bot + ) + if not success: + await safe_reply(update.message, f"❌ {message}") + return False + repost.commit() + return True + + +async def document_handler( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + *, + pinned_wid: str | None = None, +) -> bool: + """Drop the user's document into the active session's inbox + notify Claude.""" + user = update.effective_user + if not user or not is_user_allowed(user.id): + # Drop the message silently — no reply, no callback ack. The + # allowlist is private; unauthorized senders should see the bot + # as inert (no "not authorized" copy that signals "you found the + # right bot, just not the right user"). + return False + + if not update.message or not update.message.document: + return False + + wid = pinned_wid or active_window(user.id) + if wid is None: + await safe_reply( + update.message, + "❌ No active session. Send a text message first or use /new.", + ) + return False + if pinned_wid is None and not await _await_prior_voice(user.id, wid): + return False + + w = await tmux_manager.find_window_by_id(wid) + if not w: + display = session_manager.get_display_name(wid) + await safe_reply( + update.message, + f"❌ Window '{display}' no longer exists.\n" + "Send a message to start a new session.", + ) + return False + + doc = update.message.document + sess = session_manager.find_session_by_window(wid) + workdir = sess.workdir if sess and sess.workdir else str(ccbot_dir() / "images") + filename = doc.file_name or f"{doc.file_unique_id}.bin" + try: + tg_file = await doc.get_file() + except BadRequest as e: + if _is_file_too_big(e): + await safe_reply(update.message, _FILE_TOO_BIG_MSG) + return False + raise + + async def _fetch(target: Path) -> None: + await tg_file.download_to_drive(target) + + file_path = await save_inbox_file(workdir, filename, _fetch) + + caption = update.message.caption or "" + if await _intercept_if_pending_ui(context.bot, user.id, wid, update.message): + return False + async with _card_repost_bracket(context.bot, user.id, sess) as repost: + success, message = await _forward_inbox_file( + user.id, wid, user.id, file_path, caption, "document", context.bot + ) + if not success: + await safe_reply(update.message, f"❌ {message}") + return False + repost.commit() + return True diff --git a/src/ccbot/bot/_messages_shared.py b/src/ccbot/bot/_messages_shared.py new file mode 100644 index 00000000..c31cd9fb --- /dev/null +++ b/src/ccbot/bot/_messages_shared.py @@ -0,0 +1,553 @@ +"""Shared inbound delivery, ordering and pending-UI implementation. +Imported through the monkeypatch-compatible :mod:`ccbot.bot.messages` facade.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from telegram import Bot, Update +from telegram.error import BadRequest, NetworkError +from telegram.ext import ContextTypes + +from ..handlers.interactive_ui import ( + handle_interactive_ui, +) +from ..handlers.message_sender import ( + safe_reply, +) +from ..handlers.notifications import ( + begin_repost_intent, + clear_card, + end_repost_intent, + enter_kb_mode, + get_card_state, + is_active_for_user, + repost_card, + resume_card_view, +) +from ..handlers.card_types import TurnPhase +from ..handlers.typing import fire_typing +from ..i18n import t +from ..session_models import Session, WindowState +from ..session import session_manager +from ..terminal_parser import ( + extract_interactive_content, + is_interactive_ui, +) +from ..tmux_manager import tmux_manager +from ._common import active_window, is_user_allowed + +__all__ = [ + "_voice_barriers", + "_voice_waiters", + "_VOICE_DOWNLOAD_ATTEMPTS", + "_VOICE_DOWNLOAD_RETRY_DELAYS", + "_VOICE_TRANSCRIPT_CONFIRM_TIMEOUT", + "_VOICE_TRANSCRIPT_CONFIRM_POLL", + "_VoiceTranscriptCheckpoint", + "_voice_transcript_checkpoint", + "_transcript_contains_voice_text", + "_wait_for_voice_transcript", + "_send_with_delivery_proof", + "_enqueue_voice", + "_wait_for_voice", + "_await_prior_voice", + "_release_voice", + "_append_dropped_queue_notice", + "_download_voice_bytes", + "_FILE_TOO_BIG_MSG", + "_is_file_too_big", + "_RepostHandle", + "_card_repost_bracket", + "_pane_has_interactive_ui", + "_intercept_if_pending_ui", + "forward_command_handler", +] + +logger = logging.getLogger(__name__) + +# The tail of the voice-message chain for each session window. Voice +# transcription runs in a non-blocking PTB handler, so later updates can enter +# their handlers while Whisper is still working. Those handlers wait on the +# tail that existed when they arrived, preserving Telegram message order. +_voice_barriers: dict[tuple[int, str], asyncio.Future[bool]] = {} +_voice_waiters: dict[asyncio.Future[bool], int] = {} + +# A voice update holds the per-session ordering barrier while these attempts +# run. Retrying here is important: once Telegram has delivered the update, +# dropping a transient getFile/download failure would permanently lose that +# turn and let later messages overtake it. +_VOICE_DOWNLOAD_ATTEMPTS = 3 +_VOICE_DOWNLOAD_RETRY_DELAYS = (1.0, 2.0) +_VOICE_TRANSCRIPT_CONFIRM_TIMEOUT = 15.0 +_VOICE_TRANSCRIPT_CONFIRM_POLL = 0.5 + + +@dataclass(frozen=True) +class _VoiceTranscriptCheckpoint: + path: Path + offset: int + backend: str + + +def _voice_transcript_checkpoint(wid: str) -> _VoiceTranscriptCheckpoint | None: + """Snapshot the authoritative transcript position before a voice send.""" + state = session_manager.window_states.get(wid) + if not isinstance(state, WindowState) or not state.session_id: + return None + path: Path | None = Path(state.transcript_path) if state.transcript_path else None + if path is None or not path.is_file(): + if state.backend == "codex": + from ..codex_session_io import build_session_file_path + else: + from ..session_claude_io import build_session_file_path + + path = build_session_file_path(state.session_id, state.cwd) + if path is None or not path.is_file(): + return None + try: + offset = path.stat().st_size + except OSError: + return None + return _VoiceTranscriptCheckpoint(path=path, offset=offset, backend=state.backend) + + +def _transcript_contains_voice_text( + checkpoint: _VoiceTranscriptCheckpoint, text: str +) -> bool: + """Check only rows appended after ``checkpoint`` for the exact user text.""" + try: + size = checkpoint.path.stat().st_size + start = checkpoint.offset if size >= checkpoint.offset else 0 + with checkpoint.path.open("rb") as stream: + stream.seek(start) + raw = stream.read() + except OSError: + return False + expected = text.strip() + for line in raw.splitlines(): + try: + row = json.loads(line) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + if not isinstance(row, dict): + continue + candidate = "" + if checkpoint.backend == "codex": + payload = row.get("payload") + if isinstance(payload, dict): + if ( + row.get("type") == "event_msg" + and payload.get("type") == "user_message" + ): + candidate = str(payload.get("message") or "") + elif ( + row.get("type") == "response_item" + and payload.get("type") == "message" + and payload.get("role") == "user" + ): + content = payload.get("content", "") + if isinstance(content, list): + candidate = "\n".join( + str(item.get("text") or "") + for item in content + if isinstance(item, dict) + and item.get("type") in ("input_text", "text") + ) + elif isinstance(content, str): + candidate = content + elif row.get("type") == "user": + message = row.get("message") + if isinstance(message, dict): + content = message.get("content", "") + if isinstance(content, list): + from ..transcript_parser import TranscriptParser + + candidate = TranscriptParser.extract_text_only(content) + elif isinstance(content, str): + candidate = content + if candidate.strip() == expected: + return True + return False + + +async def _wait_for_voice_transcript( + checkpoint: _VoiceTranscriptCheckpoint | None, + text: str, + *, + wid: str | None = None, +) -> bool | None: + """Wait for exact delivery proof in the target session transcript. + + A fresh Codex session has no rollout/session_map binding before its first + accepted prompt. In that case keep polling the binding and scan the new + transcript from byte zero instead of treating "no checkpoint" as success. + """ + if checkpoint is None and wid is None: + return None + if checkpoint is None and wid is not None: + provisional = session_manager.window_states.get(wid) + if not isinstance(provisional, WindowState): + # A real fresh-session flow always publishes provisional state + # before exposing the card. Missing state means this is a legacy + # caller (or a focused unit-test double), so transcript proof is + # not available on this path. + return None + loop = asyncio.get_running_loop() + deadline = loop.time() + _VOICE_TRANSCRIPT_CONFIRM_TIMEOUT + while True: + if checkpoint is None and wid is not None: + await session_manager.load_session_map() + state = session_manager.window_states.get(wid) + if isinstance(state, WindowState) and state.session_id: + path = Path(state.transcript_path) if state.transcript_path else None + if path is None or not path.is_file(): + if state.backend == "codex": + from ..codex_session_io import build_session_file_path + else: + from ..session_claude_io import build_session_file_path + path = build_session_file_path(state.session_id, state.cwd) + if path is not None and path.is_file(): + checkpoint = _VoiceTranscriptCheckpoint( + path=path, offset=0, backend=state.backend + ) + if checkpoint is not None and await asyncio.to_thread( + _transcript_contains_voice_text, checkpoint, text + ): + return True + remaining = deadline - loop.time() + if remaining <= 0: + return False + await asyncio.sleep(min(_VOICE_TRANSCRIPT_CONFIRM_POLL, remaining)) + + +async def _send_with_delivery_proof( + wid: str, text: str, sess: Session | None +) -> tuple[bool, str]: + """Send one prompt and require an exact Codex transcript acknowledgement.""" + transcript_checkpoint = _voice_transcript_checkpoint(wid) + message = "" + for attempt in range(1, 3): + success, message = await session_manager.send_to_window(wid, text) + if not success: + continue + if message.startswith("Queued for "): + return True, message + if sess is None or sess.backend != "codex": + return True, message + if not await tmux_manager.ensure_codex_prompt_submitted(wid, text): + message = "Codex kept the text in its input field" + continue + # TUI slash commands do not become ordinary user_message rows. + if text.lstrip().startswith("/"): + return True, message + confirmed = await _wait_for_voice_transcript( + transcript_checkpoint, text, wid=wid + ) + if confirmed is True or confirmed is None: + return True, message + logger.warning( + "Codex delivery absent from transcript; retrying exact prompt " + "window=%s attempt=%d/2 text_len=%d", + wid, + attempt, + len(text), + ) + message = "Prompt did not appear in the Codex transcript" + return False, message or "Delivery was not acknowledged" + + +def _enqueue_voice( + user_id: int, wid: str +) -> tuple[asyncio.Future[bool] | None, asyncio.Future[bool]]: + key = (user_id, wid) + previous = _voice_barriers.get(key) + current = asyncio.get_running_loop().create_future() + _voice_barriers[key] = current + return previous, current + + +async def _wait_for_voice(barrier: asyncio.Future[bool]) -> bool: + _voice_waiters[barrier] = _voice_waiters.get(barrier, 0) + 1 + try: + return await asyncio.shield(barrier) + finally: + remaining = _voice_waiters.get(barrier, 1) - 1 + if remaining > 0: + _voice_waiters[barrier] = remaining + else: + _voice_waiters.pop(barrier, None) + + +async def _await_prior_voice(user_id: int, wid: str) -> bool: + barrier = _voice_barriers.get((user_id, wid)) + if barrier is None: + return True + return await _wait_for_voice(barrier) + + +def _release_voice( + user_id: int, wid: str, barrier: asyncio.Future[bool], *, delivered: bool +) -> None: + key = (user_id, wid) + if not barrier.done(): + barrier.set_result(delivered) + if _voice_barriers.get(key) is barrier: + _voice_barriers.pop(key, None) + + +def _append_dropped_queue_notice( + user_id: int, text: str, barrier: asyncio.Future[bool] | None +) -> str: + if barrier is None or _voice_waiters.get(barrier, 0) == 0: + return text + return f"{text}\n\n{t(user_id, 'voice.queued_dropped')}" + + +async def _download_voice_bytes(voice: Any, *, user_id: int, wid: str) -> bytes: + """Fetch a Telegram voice payload, retrying transient network failures.""" + for attempt in range(1, _VOICE_DOWNLOAD_ATTEMPTS + 1): + stage = "get_file" + try: + voice_file = await voice.get_file() + stage = "download" + return bytes(await voice_file.download_as_bytearray()) + except NetworkError as e: + logger.warning( + "Voice download network failure user=%d window=%s " + "stage=%s attempt=%d/%d: %s", + user_id, + wid, + stage, + attempt, + _VOICE_DOWNLOAD_ATTEMPTS, + e, + ) + if attempt >= _VOICE_DOWNLOAD_ATTEMPTS: + raise + await asyncio.sleep(_VOICE_DOWNLOAD_RETRY_DELAYS[attempt - 1]) + + raise RuntimeError("unreachable") + + +# Telegram's Bot API caps file *downloads* (getFile) at 20 MB. A larger +# upload surfaces here as BadRequest("file is too big") on .get_file(); +# turn that into actionable copy instead of a silent ERROR in the logs. +_FILE_TOO_BIG_MSG = ( + "❌ Telegram won't let me download this file — it's over 20 MB.\n\n" + "This is a Telegram **Bot API** limit (bots can only fetch files up to " + "20 MB via getFile), not a ccbot setting. Ways around it:\n" + "• gzip or split the file under 20 MB and resend\n" + "• drop it straight into the session's `.ccbot-inbox/` folder — no " + "Telegram round-trip, no limit\n" + "• bypass with your own Telegram **user session** (MTProto / user-api, " + "e.g. Telethon or Pyrogram): a user account downloads up to 2 GB (4 GB " + "with Premium). That needs a user-api fetch path wired into ccbot." +) + + +def _is_file_too_big(err: BadRequest) -> bool: + """True when a getFile call hit Telegram's 20 MB Bot-API download cap.""" + return "too big" in str(err).lower() + + +class _RepostHandle: + """Mutable flag used with :func:`_card_repost_bracket`. Call + :meth:`commit` after the pane send succeeded; the bracket then + reposts the live card on exit. + """ + + __slots__ = ("do_repost",) + + def __init__(self) -> None: + self.do_repost = False + + def commit(self) -> None: + self.do_repost = True + + +@asynccontextmanager +async def _card_repost_bracket( + bot: Bot, user_id: int, sess: Session | None +) -> AsyncGenerator[_RepostHandle, None]: + """Bracket a send-to-pane operation with the live-card repost machinery. + + Entry: drop any Menu/sub-screen pause + arm ``repost_intent`` so a + concurrent ``update_session_card`` buffers events instead of spawning + a second card above the user's message. + Exit (only when caller invoked ``handle.commit()``): repost the card + below the user's message and drain buffered events into it. + Always: clear ``repost_intent`` so the live card unblocks for the + next turn. + + No-op when ``sess`` is None (orphan window / no Session record). + """ + handle = _RepostHandle() + if sess is None or not is_active_for_user(user_id, sess): + yield handle + return + await resume_card_view(bot, user_id, sess) + begin_repost_intent(user_id, sess.id) + try: + yield handle + finally: + if handle.do_repost and is_active_for_user(user_id, sess): + try: + get_card_state(user_id, sess).turn_phase = TurnPhase.RUNNING + await repost_card(bot, user_id, sess) + except Exception as e: + logger.debug("repost_card failed: %s", e) + end_repost_intent(user_id, sess.id) + + +async def _pane_has_interactive_ui(wid: str) -> bool: + """True iff the window's pane is currently showing an interactive prompt. + + Cheap capture-and-classify used by the voice path to verify delivery — + a transcription typed into a pane that is showing a Yes/No prompt gets + consumed as menu navigation and lost, so the caller needs to know. + """ + w = await tmux_manager.find_window_by_id(wid) + if not w: + return False + pane_text = await tmux_manager.capture_pane(w.window_id) + return bool(pane_text) and is_interactive_ui(pane_text) + + +async def _intercept_if_pending_ui( + bot: Bot, + user_id: int, + wid: str, + reply_to: Any, + wasnt_sent_notice: str | None = None, +) -> bool: + """If the pane has a pending interactive UI, surface it and intercept. + + Returns True iff the caller MUST NOT call ``send_to_window``: the + AskUserQuestion / ExitPlanMode / Permission prompt on the pane would + otherwise consume the user's text as menu keystrokes (digits select + options, Enter submits). Caller should ``return`` on True. + + ``wasnt_sent_notice`` overrides the "your message wasn't sent" reply — + the voice path passes a resend-oriented line since a transcription, unlike + typed text, can't just be retyped. + + Surface preference: + - Active session (sess matches ``get_active_session``) → kb-mode + card via ``enter_kb_mode``. Idempotent: a no-op if the card is + already in kb-mode for the same prompt. + - Orphan window or bg session → legacy floating msg via + ``handle_interactive_ui``. + """ + w = await tmux_manager.find_window_by_id(wid) + if not w: + return False + pane_text = await tmux_manager.capture_pane(w.window_id) + if not pane_text or not is_interactive_ui(pane_text): + return False + sess = session_manager.find_session_by_window(wid) + active = session_manager.get_active_session(user_id) + is_active = sess is not None and active is not None and active.id == sess.id + surfaced = False + if is_active and sess is not None: + content_obj = extract_interactive_content(pane_text) + if content_obj is not None: + await enter_kb_mode( + bot, user_id, sess, content_obj.content, content_obj.name + ) + surfaced = True + if not surfaced: + await handle_interactive_ui(bot, user_id, wid) + logger.info( + "intercepted_user_msg_pending_ui user=%d wid=%s", + user_id, + wid, + extra={ + "event": "intercepted_user_msg_pending_ui", + "user_id": user_id, + "window_id": wid, + }, + ) + try: + await safe_reply( + reply_to, + wasnt_sent_notice + or ( + "⏳ Pending prompt above — answer it via the keyboard first. " + "Your message wasn't sent." + ), + ) + except Exception: + pass + return True + + +# --- forward_command — any /command that has no dedicated handler goes here --- + + +async def forward_command_handler( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + *, + pinned_wid: str | None = None, +) -> bool: + """Forward an unhandled /command as a slash to the active Claude session.""" + user = update.effective_user + if not user or not is_user_allowed(user.id): + return False + if not update.message: + return False + + cmd_text = update.message.text or "" + cc_slash = cmd_text.split("@")[0] # strip bot mention + wid = pinned_wid or active_window(user.id) + if not wid: + await safe_reply( + update.message, "❌ No active session. Use /new to create one." + ) + return False + if pinned_wid is None and not await _await_prior_voice(user.id, wid): + return False + + w = await tmux_manager.find_window_by_id(wid) + if not w: + display = session_manager.get_display_name(wid) + await safe_reply(update.message, f"❌ Window '{display}' no longer exists.") + return False + + display = session_manager.get_display_name(wid) + logger.info( + "Forwarding command %s to window %s (user=%d)", cc_slash, display, user.id + ) + await fire_typing(context.bot, user.id, "forward_command", window_id=wid) + if await _intercept_if_pending_ui(context.bot, user.id, wid, update.message): + return False + sess = session_manager.find_session_by_window(wid) + async with _card_repost_bracket(context.bot, user.id, sess) as repost: + success, message = await _send_with_delivery_proof(wid, cc_slash, sess) + if success: + # /clear: drop the session association so we re-detect once a + # new session id is written by the next user message. + if cc_slash.strip().lower() == "/clear": + logger.info("Clearing session for window %s after /clear", display) + session_manager.clear_window_session(wid) + if sess is not None: + await clear_card(context.bot, user.id, sess) + await resume_card_view(context.bot, user.id, sess) + await safe_reply( + update.message, + "🧹 Context cleared. Next message starts a fresh Claude session.", + ) + else: + repost.commit() + else: + await safe_reply(update.message, f"❌ {message}") + return False + return True diff --git a/src/ccbot/bot/_messages_text.py b/src/ccbot/bot/_messages_text.py new file mode 100644 index 00000000..e6d5a0da --- /dev/null +++ b/src/ccbot/bot/_messages_text.py @@ -0,0 +1,489 @@ +"""Text routing and background bash-output capture implementation. + +Public imports remain in :mod:`ccbot.bot.messages`. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +from telegram import Bot, Update +from telegram.ext import ContextTypes + +from ..handlers.cleanup import clear_session_state +from ..handlers.directory_browser import ( + BROWSE_DIRS_KEY, + BROWSE_PAGE_KEY, + BROWSE_PATH_KEY, + STATE_BROWSING_DIRECTORY, + STATE_KEY, + STATE_SELECTING_SESSION, + STATE_SELECTING_WINDOW, + build_directory_browser, +) +from ..handlers.interactive_ui import ( + get_interactive_window, + handle_interactive_ui, +) +from ..handlers.message_sender import ( + NO_LINK_PREVIEW, + safe_reply, + send_with_fallback, + try_rich_edit, +) +from ..handlers.notifications import ( + begin_repost_intent, + card_is_below, + end_repost_intent, + get_card_state, + is_active_for_user, + lookup_session_for_message, + refresh_panel, + repost_card, + resume_card_view, +) +from ..handlers.card_types import TurnPhase +from ..handlers.typing import fire_typing +from ..markdown_v2 import convert_markdown +from ..naming import maybe_auto_name +from ..session import session_manager +from ..terminal_parser import ( + extract_bash_output, +) +from ..tmux_manager import tmux_manager +from ._common import active_window, is_user_allowed +from .commands.auth import maybe_consume_code + +from typing import Any, TYPE_CHECKING, cast + +__all__ = [ + "_bash_capture_tasks", + "cancel_bash_capture", + "_capture_bash_output", + "_route_reply_quote", + "_resolve_active_window", + "_maybe_start_bash_capture", + "_dispatch_text_to_active", + "text_handler", +] + +if TYPE_CHECKING: + # Runtime-injected by the compatibility facade before each call. + _await_prior_voice = cast(Any, None) + _intercept_if_pending_ui = cast(Any, None) + _send_with_delivery_proof = cast(Any, None) + +logger = logging.getLogger(__name__) +# --- text + bash !cmd capture --- + + +# Active bash capture tasks: (user_id, window_id) → asyncio.Task +_bash_capture_tasks: dict[tuple[int, str], asyncio.Task[None]] = {} + + +def cancel_bash_capture(user_id: int, window_id: str) -> None: + """Cancel any running bash capture for this (user, window) pair.""" + key = (user_id, window_id) + task = _bash_capture_tasks.pop(key, None) + if task and not task.done(): + task.cancel() + + +async def _capture_bash_output( + bot: Bot, user_id: int, window_id: str, command: str +) -> None: + """Background task: capture ``!cmd`` output from the pane and surface it. + + Sends the first non-empty capture as a new message, then edits in place + as more output appears. Stops after 30 ticks (~30 s) or on cancel. + """ + try: + await asyncio.sleep(2.0) + chat_id = user_id + msg_id: int | None = None + last_output: str = "" + + for _ in range(30): + raw = await tmux_manager.capture_pane(window_id) + if raw is None: + return + + output = extract_bash_output(raw, command) + if not output: + await asyncio.sleep(1.0) + continue + if output == last_output: + await asyncio.sleep(1.0) + continue + last_output = output + + if len(output) > 3800: + output = "… " + output[-3800:] + + if msg_id is None: + sent = await send_with_fallback(bot, chat_id, output) + if sent: + msg_id = sent.message_id + # Rich-first so in-place edits keep the same rendering as the + # initial send (which goes rich via send_with_fallback). + elif not await try_rich_edit(bot, chat_id, msg_id, output): + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=msg_id, + text=convert_markdown(output), + parse_mode="MarkdownV2", + link_preview_options=NO_LINK_PREVIEW, + ) + except Exception: + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=msg_id, + text=output, + link_preview_options=NO_LINK_PREVIEW, + ) + except Exception: + pass + + await asyncio.sleep(1.0) + except asyncio.CancelledError: + return + finally: + _bash_capture_tasks.pop((user_id, window_id), None) + + +async def _route_reply_quote(update: Update, user_id: int, text: str) -> bool: + """Reply-quote routing: if the user replied to a bot message that + belongs to a non-active session, send this single message there + without changing the active session pointer. + + Returns True iff the message was fully handled and ``text_handler`` + must ``return`` (sent to the quoted session, send error, or quoted + message has no session). Returns False to fall through to the + active-session dispatch — both when there is no reply-quote at all + and when the quoted session is dead (a warning is emitted first). + """ + assert update.message is not None + reply = update.message.reply_to_message + if reply is None: + return False + target_sid = lookup_session_for_message(user_id, reply.message_id) + if not target_sid: + return False + target = session_manager.get_session(target_sid) + active_sess = session_manager.get_active_session(user_id) + same_as_active = active_sess is not None and active_sess.id == target_sid + if ( + target is not None + and target.window_id + and target.state in ("active", "idle") + and not same_as_active + ): + tw = await tmux_manager.find_window_by_id(target.window_id) + if tw: + ok, sm = await session_manager.send_to_window(target.window_id, text) + if ok: + session_manager.touch_session(target.id) + get_card_state(user_id, target).turn_phase = TurnPhase.RUNNING + # Explicit feedback so the user can see which + # session received the reply-quote — bg session + # would otherwise stay silent until the next + # carrier interaction. + await safe_reply( + update.message, + f"↩ \\[{target.name or target.id}\\]", + ) + return True + await safe_reply(update.message, f"❌ {sm}") + return True + elif target is not None and target.state not in ("active", "idle"): + # User aimed at a dead session (archived/lost/completed). + # Silent fallback would route to active with no signal — + # tell them so the routing surprise is visible. Falls + # through to the active-session dispatch below. + await safe_reply( + update.message, + f"⚠ \\[{target.name or target.id}\\] is {target.state} — " + "routing to the active session instead.", + ) + return False + + +async def _resolve_active_window( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user_id: int, + text: str, + *, + pinned_wid: str | None = None, +) -> str | None: + """Resolve the active session's tmux window for the inbound text. + + Returns the window id when there is a live active session window. + Returns None when ``text_handler`` must ``return`` instead — either + because there is no active session (a directory browser is opened + with the message queued) or because the active session's + window is gone (it's marked lost, state cleared, and the user told). + """ + assert update.message is not None + wid = pinned_wid or active_window(user_id) + if wid is None: + # No active session — start a directory browser to create one. + from ..startup_queue import begin_startup_queue, enqueue_startup_message + + begin_startup_queue(user_id) + enqueue_startup_message(update, context) + logger.info("No active session: showing directory browser (user=%d)", user_id) + start_path = str(Path.home()) + msg_text, keyboard, subdirs = await build_directory_browser( + start_path, user_id=user_id + ) + if context.user_data is not None: + context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY + context.user_data[BROWSE_PATH_KEY] = start_path + context.user_data[BROWSE_PAGE_KEY] = 0 + context.user_data[BROWSE_DIRS_KEY] = subdirs + await safe_reply(update.message, msg_text, reply_markup=keyboard) + return None + + w = await tmux_manager.find_window_by_id(wid) + if not w: + display = session_manager.get_display_name(wid) + logger.info("Stale active session: window %s gone (user=%d)", display, user_id) + sess = session_manager.find_session_by_window(wid) + if sess is not None: + session_manager.mark_session_lost(sess.id) + if active_window(user_id) == wid: + await clear_session_state(user_id, wid, context.bot) + await safe_reply( + update.message, + f"❌ Window '{display}' no longer exists.\n" + "Send a message to start a new session.", + ) + return None + + return wid + + +def _maybe_start_bash_capture(bot: Bot, user_id: int, wid: str, text: str) -> None: + """Spawn the background ``!cmd`` pane-capture task for a ``!`` prefixed + message. No-op for normal text. Records the task so a follow-up message + can cancel it via :func:`cancel_bash_capture`.""" + if text.startswith("!") and len(text) > 1: + bash_cmd = text[1:] + task = asyncio.create_task(_capture_bash_output(bot, user_id, wid, bash_cmd)) + _bash_capture_tasks[(user_id, wid)] = task + + +async def _dispatch_text_to_active( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user_id: int, + wid: str, + text: str, +) -> bool: + """Send the user's text to ``wid``'s pane and run the post-send + bookkeeping under the repost-intent bracket. + + Card handling is gated on the target session still being the user's + ACTIVE one. A voice message pins its window at receipt, so by the + time whisper returns the user may well have switched elsewhere — the + text still goes to the pinned pane (that is the entire point of + pinning), but the session is a *background* one now, and background + sessions never post their own chat messages. Doing otherwise dropped + a bg session's card as the newest message in the chat and handed it + the live switcher, which is what made a later switcher tap appear to + edit "the previous message". + + Active path mirrors the original flow: resume the card view + arm + repost-intent (so concurrent ``update_session_card`` events buffer + rather than spawning a second card), send the keystrokes, fire the + early typing indicator, touch + auto-name the session, spawn any + ``!cmd`` capture, drive a pending interactive UI, and finally put the + live card below the user's message. The try/finally always clears the + repost-intent flag even on an early return. + """ + assert update.message is not None + import time as _time + + from .. import metrics + from ..handlers import bg_status + + # If the user typed while looking at a Menu / sub-screen on this + # session's card, drop the pause so incoming events render again. + sess = session_manager.find_session_by_window(wid) + owns_card = sess is not None and is_active_for_user(user_id, sess) + if owns_card and sess is not None: + await resume_card_view(context.bot, user_id, sess) + # Lock spawning out from under us before sending keystrokes — + # claude can emit the first event of its reply within + # milliseconds of send_to_window returning, and + # ``update_session_card`` would otherwise grab the card lock + # first, see ``state.msg_id is None`` (from the previous turn's + # ``finalize_task``) and spawn a fresh card just for that event. + # ``repost_card`` would then spawn a SECOND card and try to + # delete the first — succeeded delete loses claude's content, + # failed delete leaves both visible (user-reported "2 от бота + # после моего сообщения"). The buffer guarantees a single spawn. + begin_repost_intent(user_id, sess.id) + + # Run the rest of the dispatch under a try/finally that always + # clears the repost-intent flag — without this, an early return + # below leaves the flag set forever and the live card stays silent + # for that session until the bot restarts. + intent_sess_id = sess.id if (owns_card and sess is not None) else None + try: + _t0 = _time.time() + success, message = await _send_with_delivery_proof(wid, text, sess) + metrics.observe("tg_to_claude_latency_ms", (_time.time() - _t0) * 1000.0) + metrics.inc("tg_messages_in") + if not success: + metrics.inc("tg_send_failures") + await safe_reply(update.message, f"❌ Delivery not confirmed: {message}") + return False + + # Immediate typing-indicator so the user sees feedback within + # ~500 ms of sending — claude can take 5-30 s before emitting + # its first event (long tool prelude / thinking) and + # ``status_polling`` won't fire typing until the pane enters + # the busy-spinner state. Without this early fire the chat + # looks frozen. fire_typing throttles to one call per ~4 s + # per user — if text_handler already fired Typing a moment + # ago, this is a silent no-op (the indicator is still on). + if owns_card: + await fire_typing( + context.bot, user_id, "text_handler.post_send", window_id=wid + ) + + sess = session_manager.find_session_by_window(wid) + # ``send_to_window`` and Codex's submit verification can take long + # enough for the user to switch sessions. The ``owns_card`` value + # captured before those awaits is no longer authoritative: using it + # below would let the old session resume/repost the carrier that the + # switcher has already handed to the new active session. + owns_card = sess is not None and is_active_for_user(user_id, sess) + if sess is not None: + get_card_state(user_id, sess).turn_phase = TurnPhase.RUNNING + session_manager.touch_session(sess.id) + # ``maybe_auto_name`` honours the user's ``haiku_naming`` + # setting and the directory-basename guard internally — we + # only need to gate the call on a non-trivial seed (Haiku + # can't summarise "hi" / "ok" into anything useful). + if len(text) >= 20: + asyncio.create_task(maybe_auto_name(sess.id, text, user_id)) + + _maybe_start_bash_capture(context.bot, user_id, wid, text) + + if owns_card: + interactive_window = get_interactive_window(user_id) + if interactive_window and interactive_window == wid: + await asyncio.sleep(0.2) + await handle_interactive_ui(context.bot, user_id, wid) + + if sess is None: + return True + + # Re-check immediately before the card mutation as well. Auto-name, + # interactive-UI handling, and other post-send work above may await. + owns_card = is_active_for_user(user_id, sess) + if not owns_card: + # Background session (voice pinned here, user moved on). + # Its only chat surface is a row in the active card's + # bg-status panel — no card, no push, no switcher steal. + if bg_status.update_status(user_id, sess.id, "working"): + try: + await refresh_panel(context.bot, user_id) + except Exception as e: + logger.debug("refresh_panel after bg dispatch failed: %s", e) + return True + + # Put the live card below the user's message (the card_position + # setting was ripped out — always-in-front is the single + # canonical behaviour). Any events claude emitted between + # send_to_window and here were buffered into state.events by + # update_session_card (it saw the repost-intent flag and held + # off rendering); they drain into the card on the next render. + if card_is_below(user_id, sess.id, update.message.message_id): + # The card is already in front of this message — the voice + # flow reposted it at receipt. Repost again and the user + # gets two cards' worth of churn for one voice; an in-place + # edit is enough to drain the buffer and drop the pending row. + try: + await resume_card_view(context.bot, user_id, sess) + except Exception as e: + logger.debug("card repaint failed: %s", e) + else: + try: + await repost_card(context.bot, user_id, sess) + except Exception as e: + logger.debug("repost_card failed: %s", e) + return True + finally: + if intent_sess_id is not None: + end_repost_intent(user_id, intent_sess_id) + + +async def text_handler( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + *, + pinned_wid: str | None = None, +) -> bool: + user = update.effective_user + if not user or not is_user_allowed(user.id): + # Drop the message silently — no reply, no callback ack. The + # allowlist is private; unauthorized senders should see the bot + # as inert (no "not authorized" copy that signals "you found the + # right bot, just not the right user"). + return False + + if not update.message or not update.message.text: + return False + + text = update.message.text + queued_wid = pinned_wid or active_window(user.id) + if queued_wid is not None: + if pinned_wid is None and not await _await_prior_voice(user.id, queued_wid): + return False + + # A pending /login flow owns the next message: it's the OAuth code, not a + # prompt. Must run before session routing — the code would otherwise be + # typed into a pane (and echoed into that session's transcript). + if await maybe_consume_code(update, context): + return True + + # Ignore text while a picker UI is mid-flight. + state = context.user_data.get(STATE_KEY) if context.user_data else None + if state in ( + STATE_SELECTING_WINDOW, + STATE_BROWSING_DIRECTORY, + STATE_SELECTING_SESSION, + ): + await safe_reply(update.message, "Please use the picker above, or tap Cancel.") + return False + + if await _route_reply_quote(update, user.id, text): + return True + + wid = await _resolve_active_window( + update, context, user.id, text, pinned_wid=pinned_wid + ) + if wid is None: + return False + + await fire_typing(context.bot, user.id, "text_handler", window_id=wid) + + # New message pushes pane content down — kill any in-flight bash capture. + cancel_bash_capture(user.id, wid) + + # Pending AskUserQuestion / ExitPlanMode / Permission on the pane + # would consume our keystrokes as menu navigation (digits select, + # Enter submits). Surface the prompt to the user and bail before + # send_to_window — the user must answer via the keyboard. + if await _intercept_if_pending_ui(context.bot, user.id, wid, update.message): + return False + + return await _dispatch_text_to_active(update, context, user.id, wid, text) diff --git a/src/ccbot/bot/_messages_voice.py b/src/ccbot/bot/_messages_voice.py new file mode 100644 index 00000000..89882563 --- /dev/null +++ b/src/ccbot/bot/_messages_voice.py @@ -0,0 +1,326 @@ +"""Voice-message intake and transcription implementation. + +Public imports remain in :mod:`ccbot.bot.messages`. +""" + +from __future__ import annotations + +import asyncio +import logging + +from telegram import Bot, Update +from telegram.error import NetworkError +from telegram.ext import ContextTypes + +from ..handlers.message_sender import ( + safe_reply, +) +from ..handlers.notifications import ( + get_card_state, + is_active_for_user, + repost_card, + resume_card_view, +) +from ..handlers.typing import fire_typing +from ..i18n import t +from ..session_models import Session +from ..session import session_manager +from ..tmux_manager import tmux_manager +from ..transcribe import resolve_voice_backend, transcribe_voice +from ._common import active_window, is_user_allowed + +from typing import Any, TYPE_CHECKING, cast + +__all__ = [ + "_clear_voice_pending_marker", + "voice_handler", + "_process_voice", +] + +if TYPE_CHECKING: + # Runtime-injected by the compatibility facade before each call. + _VOICE_DOWNLOAD_ATTEMPTS = cast(Any, None) + _append_dropped_queue_notice = cast(Any, None) + _dispatch_text_to_active = cast(Any, None) + _download_voice_bytes = cast(Any, None) + _enqueue_voice = cast(Any, None) + _intercept_if_pending_ui = cast(Any, None) + _pane_has_interactive_ui = cast(Any, None) + _release_voice = cast(Any, None) + _voice_transcript_checkpoint = cast(Any, None) + _wait_for_voice = cast(Any, None) + _wait_for_voice_transcript = cast(Any, None) + cancel_bash_capture = cast(Any, None) + +logger = logging.getLogger(__name__) +# --- voice --- + + +async def _clear_voice_pending_marker(bot: Bot, user_id: int, sess: Session) -> None: + """Repaint the card without the temporary voice_pending user row. + + Only needed on the transcription-failure paths — the success path's + ``_dispatch_text_to_active`` already reposts unconditionally, which + naturally drops the marker once ``voice_pending`` is cleared. + """ + try: + await resume_card_view(bot, user_id, sess) + except Exception as e: + logger.debug("voice-pending marker clear failed: %s", e) + + +async def voice_handler( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + *, + pinned_wid: str | None = None, + ordered: bool = False, + surface_pending: bool = True, +) -> bool: + """Queue a voice turn, then transcribe it without letting later messages pass.""" + user = update.effective_user + if ( + not user + or not is_user_allowed(user.id) + or not update.message + or not update.message.voice + or resolve_voice_backend(user.id) == "off" + ): + return await _process_voice(update, context) + + wid = pinned_wid or active_window(user.id) + if wid is None: + return await _process_voice(update, context) + + if ordered: + return await _process_voice( + update, + context, + pinned_wid=wid, + surface_pending=surface_pending, + ) + + previous, barrier = _enqueue_voice(user.id, wid) + delivered = False + try: + if previous is not None and not await _wait_for_voice(previous): + return False + delivered = await _process_voice( + update, + context, + pinned_wid=wid, + queue_barrier=barrier, + surface_pending=surface_pending, + ) + finally: + _release_voice(user.id, wid, barrier, delivered=delivered) + return delivered + + +async def _process_voice( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + *, + pinned_wid: str | None = None, + queue_barrier: asyncio.Future[bool] | None = None, + surface_pending: bool = True, +) -> bool: + """Transcribe the voice and forward as text to the active session.""" + user = update.effective_user + if not user or not is_user_allowed(user.id): + # Drop the message silently — no reply, no callback ack. The + # allowlist is private; unauthorized senders should see the bot + # as inert (no "not authorized" copy that signals "you found the + # right bot, just not the right user"). + return False + + if not update.message or not update.message.voice: + return False + + if resolve_voice_backend(user.id) == "off": + await safe_reply(update.message, "⚠ Voice is disabled (voice backend = off).") + return False + wid = pinned_wid or active_window(user.id) + if wid is None: + await safe_reply( + update.message, + "❌ No active session. Send a text message first or use /new.", + ) + return False + + w = await tmux_manager.find_window_by_id(wid) + if not w: + display = session_manager.get_display_name(wid) + await safe_reply( + update.message, + f"❌ Window '{display}' no longer exists.\n" + "Send a message to start a new session.", + ) + return False + + # wid is pinned NOW, before the slow download/transcribe steps — a + # switch afterwards can't redirect this voice message. + await fire_typing(context.bot, user.id, "voice_handler.received", window_id=wid) + + # Same immediate reaction a typed message gets: the live card is + # REPOSTED as a fresh message right now, below the voice the user + # just sent — not edited in place. An in-place edit lands on a card + # that sits ABOVE the voice message, which the user never sees; the + # symptom was 35-50 s of apparent dead air while whisper ran (they + # re-recorded, switched sessions, assumed it was broken). + # ``voice_pending`` adds a synthetic trailing user row so the reposted + # card says "voice received, already bound here" exactly where a typed + # prompt would appear. The header remains stable. + # + # The cross-session repost race that made an earlier revision back + # this out is handled properly now: ``_send_card`` serializes spawns + # per user and strips every other card's keyboard, so two reposts + # can no longer desync which message carries the live switcher. + # Skipped for an orphan window (no Session record). + sess = session_manager.find_session_by_window(wid) + card_state = get_card_state(user.id, sess) if sess is not None else None + if sess is not None and card_state is not None: + card_state.voice_pending = True + # A pagination tap may have left the card on an older page. A new + # voice message is a new user turn, so focus the latest page just as + # the normal prompt flow does before showing the pending row. + card_state.current_page_idx = None + if is_active_for_user(user.id, sess): + try: + if surface_pending: + await repost_card(context.bot, user.id, sess) + else: + # Fast intake already moved the card below the voice. Edit + # that carrier to add the pending marker; reposting here + # would create a second acknowledgement card. + await resume_card_view(context.bot, user.id, sess) + except Exception as e: + logger.debug("voice-pending card surface failed: %s", e) + + try: + ogg_data = await _download_voice_bytes( + update.message.voice, user_id=user.id, wid=wid + ) + except NetworkError as e: + if sess is not None and card_state is not None: + card_state.voice_pending = False + await _clear_voice_pending_marker(context.bot, user.id, sess) + logger.error( + "Voice did not reach transcription after %d download attempts " + "user=%d window=%s: %s", + _VOICE_DOWNLOAD_ATTEMPTS, + user.id, + wid, + e, + ) + try: + await safe_reply( + update.message, + _append_dropped_queue_notice( + user.id, + t( + user.id, + "voice.download_failed", + attempts=_VOICE_DOWNLOAD_ATTEMPTS, + ), + queue_barrier, + ), + ) + except Exception as notify_error: + logger.warning( + "Voice download failure notification failed user=%d window=%s: %s", + user.id, + wid, + notify_error, + ) + return False + + try: + text = await transcribe_voice(ogg_data, user_id=user.id) + except ValueError: + if sess is not None and card_state is not None: + card_state.voice_pending = False + await _clear_voice_pending_marker(context.bot, user.id, sess) + await safe_reply( + update.message, + _append_dropped_queue_notice( + user.id, t(user.id, "voice.transcription_failed"), queue_barrier + ), + ) + return False + except Exception as e: + if sess is not None and card_state is not None: + card_state.voice_pending = False + await _clear_voice_pending_marker(context.bot, user.id, sess) + logger.error("Voice transcription failed: %s", e) + await safe_reply( + update.message, + _append_dropped_queue_notice( + user.id, t(user.id, "voice.transcription_failed"), queue_barrier + ), + ) + return False + + if card_state is not None: + card_state.voice_pending = False + + # Typing is a chat-level indicator, so it only makes sense while the + # pinned session is still the one the user is looking at. If they + # switched away during transcription, the text still goes to the + # pinned pane but must stay invisible in chat. + if sess is not None and is_active_for_user(user.id, sess): + await fire_typing( + context.bot, user.id, "voice_handler.transcribed", window_id=wid + ) + cancel_bash_capture(user.id, wid) + + # A transcription is expensive and unrecoverable — unlike typed text the + # user can't just retype 90 seconds of speech. If the pane is showing an + # interactive prompt, the text would be consumed as menu keystrokes and + # silently lost, so tell the user to resend rather than swallowing it. + _voice_lost_notice = _append_dropped_queue_notice( + user.id, t(user.id, "voice.not_delivered"), queue_barrier + ) + if await _intercept_if_pending_ui( + context.bot, user.id, wid, update.message, _voice_lost_notice + ): + return False + + # Same dispatch path text uses — identical reaction (send, auto-name, + # bash-capture, interactive-UI check, card repost) once the text is + # known. No voice-specific reply; the transcribed text just becomes + # this message's text, same as if the user had typed it. + transcript_checkpoint = _voice_transcript_checkpoint(wid) + dispatched = await _dispatch_text_to_active(update, context, user.id, wid, text) + if dispatched is False: + return False + + # A prompt appearing after send is not proof that the voice was eaten: it + # can be an approval raised by the successfully delivered turn, especially + # for the second voice in a queue. Prefer the authoritative transcript and + # only use the pane heuristic when no matching user row appears. + transcript_confirmed = await _wait_for_voice_transcript( + transcript_checkpoint, text, wid=wid + ) + if transcript_confirmed is True: + logger.info( + "Voice delivery confirmed by transcript user=%d window=%s", + user.id, + wid, + ) + return True + if transcript_confirmed is None: + await asyncio.sleep(1.5) + if await _pane_has_interactive_ui(wid): + logger.warning( + "Voice delivery unconfirmed while interactive UI is visible " + "user=%d window=%s", + user.id, + wid, + ) + try: + await safe_reply(update.message, _voice_lost_notice) + except Exception: + pass + return False + return True diff --git a/src/ccbot/bot/app.py b/src/ccbot/bot/app.py index d26076af..d8447faf 100644 --- a/src/ccbot/bot/app.py +++ b/src/ccbot/bot/app.py @@ -1,7 +1,9 @@ -"""Application lifecycle: bootstrap, ``setMyCommands``, recovery hooks, -status-polling startup, and handler registration. +# ruff: noqa: F401 +# pyright: reportUnusedImport=false, reportUnusedFunction=false +"""Application facade plus polling-failure and liveness safeguards. -Public entry point: ``create_bot()`` — called by ``ccbot.main``. +Lifecycle startup/shutdown and handler registration live in private sibling +modules. Their public entry points and mutable compatibility surface stay here. """ from __future__ import annotations @@ -11,7 +13,11 @@ import os import threading import time -from typing import Any +from types import ModuleType as _ModuleType +from typing import Any, TYPE_CHECKING, cast + +from . import _app_lifecycle as _lifecycle_impl +from . import _app_routes as _routes_impl from telegram import BotCommand, Update from telegram.error import Conflict, NetworkError, RetryAfter, TimedOut @@ -67,6 +73,11 @@ ) from .session_events import handle_new_message + +if TYPE_CHECKING: + # Runtime-injected by the compatibility facade before each call. + _conflict_app = cast(Any, None) + logger = logging.getLogger(__name__) @@ -152,424 +163,6 @@ def _liveness_watchdog_loop() -> None: _liveness_watchdog_tick() -# Module-globals owned by the lifecycle hooks. -session_monitor: SessionMonitor | None = None -# Set in ``post_init`` so ``_error_handler`` can reach the Application even -# when ``update`` is not an Update (Conflict updates carry no chat). -_conflict_app: "Application[Any, Any, Any, Any, Any, Any] | None" = None -_status_poll_task: asyncio.Task[None] | None = None -_card_timer_task: asyncio.Task[None] | None = None -_quota_alerts_task: asyncio.Task[None] | None = None -_metrics_flush_task: asyncio.Task[None] | None = None -_heartbeat_task: asyncio.Task[None] | None = None -_auth_preflight_task: asyncio.Task[None] | None = None -_usage_prewarm_task: asyncio.Task[None] | None = None -_send_file_relay_task: asyncio.Task[None] | None = None - - -async def post_init(application: "Application[Any, Any, Any, Any, Any, Any]") -> None: - """First task after Application is built. Publish menu, recover state, start monitors.""" - global \ - session_monitor, \ - _status_poll_task, \ - _card_timer_task, \ - _quota_alerts_task, \ - _metrics_flush_task, \ - _heartbeat_task, \ - _auth_preflight_task, \ - _usage_prewarm_task, \ - _send_file_relay_task, \ - _last_heartbeat, \ - _conflict_app - - # Reachable from ``_error_handler`` for the sustained-Conflict exit - # path (Conflict updates carry no chat, so ``update`` is not an Update). - _conflict_app = application - - # Agent sessions may have neither network nor cross-process socket access. - # Consume filesystem-relay requests and perform Telegram delivery here. - from ..send_file import send_file_relay_loop - - _send_file_relay_task = asyncio.create_task(send_file_relay_loop(application.bot)) - - # Warm the directory browser's recursive index off the startup path. The - # picker itself always paints from cache/shallow metadata and never waits - # for this scan. - from ..handlers.directory_browser import prewarm_directory_recency - - prewarm_directory_recency() - logger.info("Directory-recency cache pre-warm scheduled") - - # Cache bot username so ``tmux_manager.create_window`` can surface it - # to Claude via ``CCBOT_BOT_USERNAME``. ``application.bot.username`` - # triggers a ``getMe`` if not already populated; with ``initialize()`` - # already done by run_polling this is a cached property. - try: - config.bot_username = application.bot.username or "" - except Exception as e: - logger.debug("Could not resolve bot.username: %s", e) - - await application.bot.delete_my_commands() - - # Trimmed /-menu surface. New/Status/Shot/Settings/Archive all live - # behind the inline ≡ Menu; Stop/Kill/Clear in the live-card footer. - # ``/history`` is published — it's the canonical entry to the FULL - # JSONL transcript view (deep history); the live card itself only - # seeds the last CARD_SEED_TURNS end-of-turn boundaries. - # Hidden commands still work when typed. - bot_commands = [ - BotCommand("menu", "Open menu"), - BotCommand("help", "Quick guide / inline doc"), - BotCommand("history", "Full transcript of the active session"), - BotCommand("done", "Mark a session as done"), - ] - for cmd_name in ("model", "effort", "compact", "memory"): - if cmd_name in CC_COMMANDS: - bot_commands.append(BotCommand(cmd_name, CC_COMMANDS[cmd_name])) - - await application.bot.set_my_commands(bot_commands) - - # Re-resolve stale window IDs from persisted state against live tmux windows. - await session_manager.resolve_stale_ids() - # DM mode: cross-check Session records against live tmux. Sessions whose - # window vanished get state=lost and surface in the switcher with a - # Restore button. - await session_manager.reconcile_sessions_with_tmux() - - # A fresh Codex host should be operable from Telegram alone. Read auth - # state after the bot is online and automatically start the official - # device-code flow when no account is present. - _auth_preflight_task = asyncio.create_task( - ensure_codex_auth_on_start(application.bot) - ) - logger.info("Agent auth preflight scheduled") - - async def _prewarm_live_usage() -> None: - """Populate Status cache off the user interaction path after auth.""" - try: - if _auth_preflight_task is not None: - await _auth_preflight_task - from ._usage_window import fetch_live_usage - - info = await fetch_live_usage() - logger.info("Live usage cache pre-warmed ok=%s", info is not None) - except asyncio.CancelledError: - raise - except Exception as e: - logger.debug("Live usage cache pre-warm failed: %s", e) - - _usage_prewarm_task = asyncio.create_task(_prewarm_live_usage()) - logger.info("Live usage cache pre-warm scheduled") - - # Pre-fill global rate limiter bucket on restart. AsyncLimiter starts at - # _level=0 (full burst capacity), but Telegram's server-side counter - # persists across bot restarts. Force the bucket to start "full" so - # capacity drains in naturally (~1s). - rate_limiter = application.bot.rate_limiter - if rate_limiter and rate_limiter._base_limiter: - rate_limiter._base_limiter._level = rate_limiter._base_limiter.max_rate - logger.info("Pre-filled global rate limiter bucket") - - monitor = SessionMonitor() - - async def message_callback(msg: NewMessage) -> None: - await handle_new_message(msg, application.bot) - - monitor.set_message_callback(message_callback) - monitor.start() - session_monitor = monitor - logger.info("Session monitor started") - - _status_poll_task = asyncio.create_task(status_poll_loop(application.bot)) - logger.info("Status polling task started") - - _card_timer_task = asyncio.create_task(card_timer_loop(application.bot)) - logger.info("Card timer task started") - - _quota_alerts_task = asyncio.create_task(quota_alerts_loop(application.bot)) - logger.info("Quota alerts task started") - - # Per-session context % is computed from JSONL math - # (usage.context_pct_for_session) — NOT by polling /context into panes. - # Polling wrote the modal's markdown into each session's JSONL as a fake - # user-turn (polluting the live card + burning tokens), so that path was - # removed. See doc/dm-multisession-spec.md §4.6. - - _metrics_flush_task = asyncio.create_task(metrics_flush_loop()) - logger.info("Metrics flush task started") - - _last_heartbeat = time.monotonic() - _heartbeat_task = asyncio.create_task(_heartbeat_loop()) - threading.Thread( - target=_liveness_watchdog_loop, daemon=True, name="ccbot-liveness-watchdog" - ).start() - logger.info( - "Liveness watchdog started (stale>%.0fs triggers exit)", - LIVENESS_MAX_STALE_SECONDS, - ) - - # Pre-warm the history-page cache for every active/idle session so - # the user's first switcher tap after a restart doesn't pay the - # ~1 s parse cost of walking a multi-thousand-message JSONL. Runs - # off the boot path so it can't delay the bot coming online. - async def _prewarm_history_caches() -> None: - from ..handlers.history import prewarm_pages_cache - - for sess in list(session_manager.sessions.values()): - if sess.state not in ("active", "idle") or not sess.window_id: - continue - try: - await prewarm_pages_cache(sess.window_id) - except Exception as e: - logger.debug("prewarm failed for %s: %s", sess.window_id, e) - - asyncio.create_task(_prewarm_history_caches()) - logger.info("History cache pre-warm scheduled") - - # Seed bg_status for sessions that are still "working" so a - # restart-spanned in-progress session lands in the panel as soon - # as the bot comes up. ``finished`` sessions are NOT seeded — - # they're already-completed turns; if the user noticed them - # before the restart they don't need a repeat notification, and - # if they didn't they can switch into the session to see the - # answer. The fresh-end-of-turn notification path - # (session_events) still fires for sessions that actually - # finish AFTER the bot starts. - async def _seed_bg_statuses() -> None: - from ..handlers import bg_status - from ..handlers.notifications import refresh_panel - from ..usage import context_pct_for_session - - for user_id in config.allowed_users: - active = session_manager.get_active_session(user_id) - active_id = active.id if active is not None else None - changed = False - for sess in list(session_manager.sessions.values()): - if sess.state not in ("active", "idle"): - continue - if sess.id == active_id: - continue - try: - inferred = await bg_status.infer_status_from_jsonl(sess) - except Exception as e: - logger.debug("infer bg status failed for %s: %s", sess.id, e) - continue - if inferred != "working": - continue - if bg_status.update_status(user_id, sess.id, "working"): - changed = True - try: - pct = await context_pct_for_session(sess) - except Exception as e: - logger.debug("infer bg context failed for %s: %s", sess.id, e) - pct = None - if pct is not None: - bg_status.set_context_pct(user_id, sess.id, pct) - changed = True - if changed: - try: - await refresh_panel(application.bot, user_id) - except Exception as e: - logger.debug("refresh_panel after seed failed: %s", e) - - asyncio.create_task(_seed_bg_statuses()) - logger.info("Bg-status seed scheduled") - - # Repaint each user's persisted live card in place. ``_cards`` is - # in-memory only, so without this a restart orphans the card message - # in chat and a fresh one appears on the next event. ``restore_card`` - # rebuilds the CardState, seeds the recent transcript, and edits the - # original message so the live card resumes on the same message. - async def _restore_active_cards() -> None: - from ..handlers.notifications import restore_card - - for user_id in config.allowed_users: - card_msg_id = session_manager.get_card_msg(user_id) - if not card_msg_id: - continue - active = session_manager.get_active_session(user_id) - if active is None: - continue - try: - ok = await restore_card(application.bot, user_id, active, card_msg_id) - logger.info( - "Restored live card user=%d session=%s msg=%d ok=%s", - user_id, - active.id, - card_msg_id, - ok, - ) - except Exception as e: - logger.debug("restore_card failed for user %d: %s", user_id, e) - - asyncio.create_task(_restore_active_cards()) - logger.info("Active-card restore scheduled") - - -async def post_shutdown( - application: "Application[Any, Any, Any, Any, Any, Any]", -) -> None: - """Stop background tasks, flush queues, close HTTP clients.""" - global \ - _status_poll_task, \ - _card_timer_task, \ - _quota_alerts_task, \ - _metrics_flush_task, \ - _heartbeat_task, \ - _auth_preflight_task, \ - _usage_prewarm_task, \ - _send_file_relay_task - - if _usage_prewarm_task: - if not _usage_prewarm_task.done(): - _usage_prewarm_task.cancel() - await asyncio.gather(_usage_prewarm_task, return_exceptions=True) - _usage_prewarm_task = None - - if _auth_preflight_task: - if not _auth_preflight_task.done(): - _auth_preflight_task.cancel() - await asyncio.gather(_auth_preflight_task, return_exceptions=True) - _auth_preflight_task = None - await shutdown_auth_flows() - await shutdown_inbound_queues() - await shutdown_card_surface_tasks() - - if _send_file_relay_task: - _send_file_relay_task.cancel() - await asyncio.gather(_send_file_relay_task, return_exceptions=True) - _send_file_relay_task = None - logger.info("send-file filesystem relay stopped") - - if _status_poll_task: - _status_poll_task.cancel() - try: - await _status_poll_task - except asyncio.CancelledError: - pass - _status_poll_task = None - logger.info("Status polling stopped") - - if _card_timer_task: - _card_timer_task.cancel() - try: - await _card_timer_task - except asyncio.CancelledError: - pass - _card_timer_task = None - logger.info("Card timer stopped") - - if _quota_alerts_task: - _quota_alerts_task.cancel() - try: - await _quota_alerts_task - except asyncio.CancelledError: - pass - _quota_alerts_task = None - logger.info("Quota alerts stopped") - - if _metrics_flush_task: - _metrics_flush_task.cancel() - try: - await _metrics_flush_task - except asyncio.CancelledError: - pass - _metrics_flush_task = None - logger.info("Metrics flush stopped") - - if _heartbeat_task: - _heartbeat_task.cancel() - try: - await _heartbeat_task - except asyncio.CancelledError: - pass - _heartbeat_task = None - logger.info("Liveness heartbeat stopped") - - # Drain anything spawned by the handlers BEFORE we stop the - # session monitor — both helpers do real I/O (history JSONL reads, - # editMessageText calls) that we'd rather see finish or get - # cancelled cleanly instead of being abandoned with the loop. - from ..handlers.history import cancel_pending_prewarm - from ..handlers.notifications import cancel_pending_card_edits - - await cancel_pending_card_edits() - await cancel_pending_prewarm() - - if session_monitor: - await session_monitor.stop() - logger.info("Session monitor stopped") - - -def create_bot() -> "Application[Any, Any, Any, Any, Any, Any]": - """Build the Application, wire all handlers, return it ready to run_polling.""" - builder = ( - Application.builder() - .token(config.telegram_bot_token) - .rate_limiter(AIORateLimiter(max_retries=5)) - .post_init(post_init) - .post_shutdown(post_shutdown) - ) - if config.tg_proxy_url: - # Route both long-poll and Bot API calls through TG_PROXY_URL. - # Required when api.telegram.org is unreachable from the host. - from telegram.request import HTTPXRequest - - builder = builder.request( - HTTPXRequest(proxy=config.tg_proxy_url) - ).get_updates_request(HTTPXRequest(proxy=config.tg_proxy_url)) - logger.info("TG proxy enabled: %s", config.tg_proxy_url) - application = builder.build() - - # Group -1 runs before commands and content handlers. It is a no-op unless - # a new-session flow is open; while open it captures the update and stops - # it from leaking to the previously-active session. - application.add_handler( - MessageHandler( - filters.ALL & ~filters.StatusUpdate.ALL, capture_startup_message - ), - group=-1, - ) - - # Visible menu commands. - application.add_handler(CommandHandler("history", history_command)) - application.add_handler(CommandHandler("screenshot", screenshot_command)) - application.add_handler(CommandHandler("usage", usage_command)) - application.add_handler(CommandHandler("menu", menu_command)) - application.add_handler(CommandHandler("new", new_command)) - application.add_handler(CommandHandler("kill", kill_command)) - application.add_handler(CommandHandler("done", done_command)) - application.add_handler(CommandHandler("stop", stop_command)) - application.add_handler(CommandHandler("archive", archive_command)) - application.add_handler(CommandHandler("health", health_command)) - application.add_handler(CommandHandler("help", help_command)) - # /login stays out of setMyCommands: it is an emergency path surfaced by the - # "authorization expired" notice (text + 🔐 button), not day-to-day UI. - application.add_handler(CommandHandler("login", login_command)) - application.add_handler(CallbackQueryHandler(callback_handler)) - # Forward any other /command to Claude Code. - application.add_handler(MessageHandler(filters.COMMAND, command_intake_handler)) - application.add_handler( - MessageHandler(filters.TEXT & ~filters.COMMAND, text_intake_handler) - ) - application.add_handler(MessageHandler(filters.PHOTO, photo_intake_handler)) - application.add_handler( - MessageHandler(filters.Document.ALL, document_intake_handler) - ) - application.add_handler(MessageHandler(filters.VOICE, voice_intake_handler)) - # Catch-all: non-text content (stickers, video, etc.). - application.add_handler( - MessageHandler( - ~filters.COMMAND & ~filters.TEXT & ~filters.StatusUpdate.ALL, - unsupported_intake_handler, - ) - ) - - application.add_error_handler(_error_handler) - - return application - - def _terminate_for_sustained_conflict() -> None: """End this process so the supervisor restarts one clean instance. @@ -687,3 +280,81 @@ async def _error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> err, exc_info=err, ) + + +# Compatibility facade for extracted startup/shutdown and route registration. +_LIFECYCLE_STATE_NAMES = ( + "session_monitor", + "_conflict_app", + "_status_poll_task", + "_card_timer_task", + "_quota_alerts_task", + "_metrics_flush_task", + "_heartbeat_task", + "_auth_preflight_task", + "_usage_prewarm_task", + "_send_file_relay_task", + "_last_heartbeat", +) + +for _state_name in _LIFECYCLE_STATE_NAMES: + if hasattr(_lifecycle_impl, _state_name): + globals()[_state_name] = getattr(_lifecycle_impl, _state_name) + +_ORIGINAL_POST_INIT = _lifecycle_impl.post_init +_ORIGINAL_POST_SHUTDOWN = _lifecycle_impl.post_shutdown +_ORIGINAL_CREATE_BOT = _routes_impl.create_bot + +_APP_FACADE_INTERNALS = { + "_ModuleType", + "_lifecycle_impl", + "_routes_impl", + "_LIFECYCLE_STATE_NAMES", + "_ORIGINAL_POST_INIT", + "_ORIGINAL_POST_SHUTDOWN", + "_ORIGINAL_CREATE_BOT", + "_APP_FACADE_INTERNALS", + "_sync_app_implementation", + "_pull_lifecycle_state", + "_state_name", +} + + +def _sync_app_implementation(module: _ModuleType) -> None: + """Push current, possibly monkeypatched facade names downstream.""" + facade_names = { + name: value + for name, value in globals().items() + if not name.startswith("__") and name not in _APP_FACADE_INTERNALS + } + vars(module).update(facade_names) + + +def _pull_lifecycle_state() -> None: + """Reflect lifecycle assignments back onto the canonical facade.""" + for name in _LIFECYCLE_STATE_NAMES: + if hasattr(_lifecycle_impl, name): + globals()[name] = getattr(_lifecycle_impl, name) + + +async def post_init(application: "Application[Any, Any, Any, Any, Any, Any]") -> None: + _sync_app_implementation(_lifecycle_impl) + try: + await _ORIGINAL_POST_INIT(application) + finally: + _pull_lifecycle_state() + + +async def post_shutdown( + application: "Application[Any, Any, Any, Any, Any, Any]", +) -> None: + _sync_app_implementation(_lifecycle_impl) + try: + await _ORIGINAL_POST_SHUTDOWN(application) + finally: + _pull_lifecycle_state() + + +def create_bot() -> "Application[Any, Any, Any, Any, Any, Any]": + _sync_app_implementation(_routes_impl) + return _ORIGINAL_CREATE_BOT() diff --git a/src/ccbot/bot/callbacks/footer.py b/src/ccbot/bot/callbacks/footer.py index 1196e3b1..4d26cea1 100644 --- a/src/ccbot/bot/callbacks/footer.py +++ b/src/ccbot/bot/callbacks/footer.py @@ -139,8 +139,15 @@ async def handle( if sess is None: await query.answer(t(user.id, "toast.no_session"), show_alert=False) return True + # Stop Telegram's button spinner before rendering or making the edit + # request. An expired answer must not prevent the actual page paint. + try: + await query.answer() + except Exception as e: + logger.debug("pagination callback answer failed: %s", e) state = get_card_state(user.id, sess) idx, total = card_page_info(state, user.id) + old_page_idx = state.current_page_idx if data == CB_PG_JUMP: # Jump to default-focus (= latest page when no answer-anchor # was set explicitly). ``None`` means "stick to latest". @@ -152,11 +159,14 @@ async def handle( new_idx = min(total - 1, idx + 1) # Reaching the last page sticks the user to "auto-follow latest". state.current_page_idx = new_idx if new_idx < total - 1 else None + desired_page_idx = state.current_page_idx + refreshed = False try: - await refresh_panel(context.bot, user.id) + refreshed = await refresh_panel(context.bot, user.id, immediate=True) except Exception as e: logger.debug("pagination refresh failed: %s", e) - await query.answer() + if not refreshed and state.current_page_idx == desired_page_idx: + state.current_page_idx = old_page_idx return True if data == CB_KB_BACK: diff --git a/src/ccbot/bot/callbacks/settings.py b/src/ccbot/bot/callbacks/settings.py index d874d4ee..60ea64b5 100644 --- a/src/ccbot/bot/callbacks/settings.py +++ b/src/ccbot/bot/callbacks/settings.py @@ -395,9 +395,8 @@ async def handle( session_manager.update_user_setting( user.id, "card_inline_screenshots", new_val ) - # Soft reset: nuke msg_id for all user's cards so the next - # event creates a fresh msg of the correct type (photo+caption - # vs text). Old artefacts stay in chat as frozen. + # Soft reset: the next event creates a fresh carrier with the + # requested rich-media layout. Old artefacts stay frozen. from ...handlers.notifications import ( reset_card_msg_id_for_user, ) diff --git a/src/ccbot/bot/callbacks/switcher.py b/src/ccbot/bot/callbacks/switcher.py index 69be4771..160ad057 100644 --- a/src/ccbot/bot/callbacks/switcher.py +++ b/src/ccbot/bot/callbacks/switcher.py @@ -10,7 +10,9 @@ from telegram.ext import ContextTypes from ...handlers import bg_status +from ...handlers.card_binding import bind_carrier from ...handlers.callback_data import CB_SW_NEW, CB_SW_NOOP, CB_SW_USE +from ...handlers.card_types import CarrierKind from ...handlers.directory_browser import ( BROWSE_DIRS_KEY, BROWSE_PAGE_KEY, @@ -173,7 +175,11 @@ async def _seed_bg_status(old_sess: _Session) -> None: # sets msg_id; enter_kb_mode then edits in place. try: state = get_card_state(user.id, sess) - state.msg_id = query.message.message_id + bind_carrier( + state, + query.message.message_id, + CarrierKind.TEXT, + ) state.in_menu_view = False await enter_kb_mode( context.bot, diff --git a/src/ccbot/bot/messages.py b/src/ccbot/bot/messages.py index 8b766af9..d20fe5c7 100644 --- a/src/ccbot/bot/messages.py +++ b/src/ccbot/bot/messages.py @@ -1,1569 +1,149 @@ -"""Inbound message handlers — text, voice, photo, document, and the -forward-as-slash-command catch-all. - -Also home to: - - ``create_and_activate_session``: tmux window creation flow shared by - the directory browser and session picker callback paths. - - background ``_capture_bash_output`` task driving ``!cmd`` echo from - the active pane back into chat. - - the ``forward_command_handler`` that pipes any unhandled /command - straight into the active session's tmux input. +"""Compatibility facade for inbound Telegram message handlers. + +The implementation is split by responsibility across ``_messages_shared``, +``_messages_media``, ``_messages_voice`` and ``_messages_text``. This module +intentionally remains the canonical import and monkeypatch surface: before a +delegated function runs, its implementation module receives the current +attributes from this facade. Existing tests and integrations that replace +``ccbot.bot.messages.session_manager`` or a private helper therefore retain +the same lookup semantics they had when all functions lived in this file. """ from __future__ import annotations -import asyncio -import json +import inspect import logging -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from telegram import Bot, Update -from telegram.error import BadRequest, NetworkError -from telegram.ext import ContextTypes - -from ..handlers.cleanup import clear_session_state -from ..handlers.directory_browser import ( - BROWSE_DIRS_KEY, - BROWSE_PAGE_KEY, - BROWSE_PATH_KEY, - STATE_BROWSING_DIRECTORY, - STATE_KEY, - STATE_SELECTING_SESSION, - STATE_SELECTING_WINDOW, - build_directory_browser, -) -from ..handlers.interactive_ui import ( - get_interactive_window, - handle_interactive_ui, -) -from ..handlers.message_sender import ( - NO_LINK_PREVIEW, - safe_reply, - send_with_fallback, - try_rich_edit, -) -from ..handlers.notifications import ( - begin_repost_intent, - card_is_below, - clear_card, - end_repost_intent, - enter_kb_mode, - get_card_state, - is_active_for_user, - lookup_session_for_message, - refresh_panel, - repost_card, - resume_card_view, +from functools import wraps +from types import ModuleType +from typing import Any, Callable, cast + +from . import _messages_media, _messages_shared, _messages_text, _messages_voice +from ._session_create import create_and_activate_session + +_IMPLEMENTATION_MODULES: tuple[ModuleType, ...] = ( + _messages_shared, + _messages_media, + _messages_voice, + _messages_text, ) -from ..handlers.typing import fire_typing -from ..i18n import t -from ..session_models import Session, WindowState -from ..handlers.inbox import save_inbox_file -from ..markdown_v2 import convert_markdown -from ..naming import maybe_auto_name -from ..session import session_manager -from ..terminal_parser import ( - extract_bash_output, - extract_interactive_content, - is_interactive_ui, -) -from ..tmux_manager import tmux_manager -from ..transcribe import resolve_voice_backend, transcribe_voice -from ..utils import ccbot_dir -from ._common import active_window, is_user_allowed -from .commands.auth import maybe_consume_code - -logger = logging.getLogger(__name__) - -# The tail of the voice-message chain for each session window. Voice -# transcription runs in a non-blocking PTB handler, so later updates can enter -# their handlers while Whisper is still working. Those handlers wait on the -# tail that existed when they arrived, preserving Telegram message order. -_voice_barriers: dict[tuple[int, str], asyncio.Future[bool]] = {} -_voice_waiters: dict[asyncio.Future[bool], int] = {} - -# A voice update holds the per-session ordering barrier while these attempts -# run. Retrying here is important: once Telegram has delivered the update, -# dropping a transient getFile/download failure would permanently lose that -# turn and let later messages overtake it. -_VOICE_DOWNLOAD_ATTEMPTS = 3 -_VOICE_DOWNLOAD_RETRY_DELAYS = (1.0, 2.0) -_VOICE_TRANSCRIPT_CONFIRM_TIMEOUT = 15.0 -_VOICE_TRANSCRIPT_CONFIRM_POLL = 0.5 - - -@dataclass(frozen=True) -class _VoiceTranscriptCheckpoint: - path: Path - offset: int - backend: str - - -def _voice_transcript_checkpoint(wid: str) -> _VoiceTranscriptCheckpoint | None: - """Snapshot the authoritative transcript position before a voice send.""" - state = session_manager.window_states.get(wid) - if not isinstance(state, WindowState) or not state.session_id: - return None - path: Path | None = Path(state.transcript_path) if state.transcript_path else None - if path is None or not path.is_file(): - if state.backend == "codex": - from ..codex_session_io import build_session_file_path - else: - from ..session_claude_io import build_session_file_path - - path = build_session_file_path(state.session_id, state.cwd) - if path is None or not path.is_file(): - return None - try: - offset = path.stat().st_size - except OSError: - return None - return _VoiceTranscriptCheckpoint(path=path, offset=offset, backend=state.backend) - - -def _transcript_contains_voice_text( - checkpoint: _VoiceTranscriptCheckpoint, text: str -) -> bool: - """Check only rows appended after ``checkpoint`` for the exact user text.""" - try: - size = checkpoint.path.stat().st_size - start = checkpoint.offset if size >= checkpoint.offset else 0 - with checkpoint.path.open("rb") as stream: - stream.seek(start) - raw = stream.read() - except OSError: - return False - expected = text.strip() - for line in raw.splitlines(): - try: - row = json.loads(line) - except (json.JSONDecodeError, UnicodeDecodeError): - continue - if not isinstance(row, dict): - continue - candidate = "" - if checkpoint.backend == "codex": - payload = row.get("payload") - if isinstance(payload, dict): - if ( - row.get("type") == "event_msg" - and payload.get("type") == "user_message" - ): - candidate = str(payload.get("message") or "") - elif ( - row.get("type") == "response_item" - and payload.get("type") == "message" - and payload.get("role") == "user" - ): - content = payload.get("content", "") - if isinstance(content, list): - candidate = "\n".join( - str(item.get("text") or "") - for item in content - if isinstance(item, dict) - and item.get("type") in ("input_text", "text") - ) - elif isinstance(content, str): - candidate = content - elif row.get("type") == "user": - message = row.get("message") - if isinstance(message, dict): - content = message.get("content", "") - if isinstance(content, list): - from ..transcript_parser import TranscriptParser - - candidate = TranscriptParser.extract_text_only(content) - elif isinstance(content, str): - candidate = content - if candidate.strip() == expected: - return True - return False - - -async def _wait_for_voice_transcript( - checkpoint: _VoiceTranscriptCheckpoint | None, - text: str, - *, - wid: str | None = None, -) -> bool | None: - """Wait for exact delivery proof in the target session transcript. - - A fresh Codex session has no rollout/session_map binding before its first - accepted prompt. In that case keep polling the binding and scan the new - transcript from byte zero instead of treating "no checkpoint" as success. - """ - if checkpoint is None and wid is None: - return None - if checkpoint is None and wid is not None: - provisional = session_manager.window_states.get(wid) - if not isinstance(provisional, WindowState): - # A real fresh-session flow always publishes provisional state - # before exposing the card. Missing state means this is a legacy - # caller (or a focused unit-test double), so transcript proof is - # not available on this path. - return None - loop = asyncio.get_running_loop() - deadline = loop.time() + _VOICE_TRANSCRIPT_CONFIRM_TIMEOUT - while True: - if checkpoint is None and wid is not None: - await session_manager.load_session_map() - state = session_manager.window_states.get(wid) - if isinstance(state, WindowState) and state.session_id: - path = Path(state.transcript_path) if state.transcript_path else None - if path is None or not path.is_file(): - if state.backend == "codex": - from ..codex_session_io import build_session_file_path - else: - from ..session_claude_io import build_session_file_path - path = build_session_file_path(state.session_id, state.cwd) - if path is not None and path.is_file(): - checkpoint = _VoiceTranscriptCheckpoint( - path=path, offset=0, backend=state.backend - ) - if checkpoint is not None and await asyncio.to_thread( - _transcript_contains_voice_text, checkpoint, text - ): - return True - remaining = deadline - loop.time() - if remaining <= 0: - return False - await asyncio.sleep(min(_VOICE_TRANSCRIPT_CONFIRM_POLL, remaining)) -async def _send_with_delivery_proof( - wid: str, text: str, sess: Session | None -) -> tuple[bool, str]: - """Send one prompt and require an exact Codex transcript acknowledgement.""" - transcript_checkpoint = _voice_transcript_checkpoint(wid) - message = "" - for attempt in range(1, 3): - success, message = await session_manager.send_to_window(wid, text) - if not success: - continue - if message.startswith("Queued for "): - return True, message - if sess is None or sess.backend != "codex": - return True, message - if not await tmux_manager.ensure_codex_prompt_submitted(wid, text): - message = "Codex kept the text in its input field" - continue - # TUI slash commands do not become ordinary user_message rows. - if text.lstrip().startswith("/"): - return True, message - confirmed = await _wait_for_voice_transcript( - transcript_checkpoint, text, wid=wid - ) - if confirmed is True or confirmed is None: - return True, message - logger.warning( - "Codex delivery absent from transcript; retrying exact prompt " - "window=%s attempt=%d/2 text_len=%d", - wid, - attempt, - len(text), - ) - message = "Prompt did not appear in the Codex transcript" - return False, message or "Delivery was not acknowledged" +def _publish_implementation_names(module: ModuleType) -> None: + """Expose constants, dependencies, state objects and classes unchanged.""" + for name, value in vars(module).items(): + if not name.startswith("__"): + globals()[name] = value -def _enqueue_voice( - user_id: int, wid: str -) -> tuple[asyncio.Future[bool] | None, asyncio.Future[bool]]: - key = (user_id, wid) - previous = _voice_barriers.get(key) - current = asyncio.get_running_loop().create_future() - _voice_barriers[key] = current - return previous, current - - -async def _wait_for_voice(barrier: asyncio.Future[bool]) -> bool: - _voice_waiters[barrier] = _voice_waiters.get(barrier, 0) + 1 - try: - return await asyncio.shield(barrier) - finally: - remaining = _voice_waiters.get(barrier, 1) - 1 - if remaining > 0: - _voice_waiters[barrier] = remaining - else: - _voice_waiters.pop(barrier, None) - - -async def _await_prior_voice(user_id: int, wid: str) -> bool: - barrier = _voice_barriers.get((user_id, wid)) - if barrier is None: - return True - return await _wait_for_voice(barrier) - - -def _release_voice( - user_id: int, wid: str, barrier: asyncio.Future[bool], *, delivered: bool -) -> None: - key = (user_id, wid) - if not barrier.done(): - barrier.set_result(delivered) - if _voice_barriers.get(key) is barrier: - _voice_barriers.pop(key, None) - - -def _append_dropped_queue_notice( - user_id: int, text: str, barrier: asyncio.Future[bool] | None -) -> str: - if barrier is None or _voice_waiters.get(barrier, 0) == 0: - return text - return f"{text}\n\n{t(user_id, 'voice.queued_dropped')}" - - -async def _download_voice_bytes(voice: Any, *, user_id: int, wid: str) -> bytes: - """Fetch a Telegram voice payload, retrying transient network failures.""" - for attempt in range(1, _VOICE_DOWNLOAD_ATTEMPTS + 1): - stage = "get_file" - try: - voice_file = await voice.get_file() - stage = "download" - return bytes(await voice_file.download_as_bytearray()) - except NetworkError as e: - logger.warning( - "Voice download network failure user=%d window=%s " - "stage=%s attempt=%d/%d: %s", - user_id, - wid, - stage, - attempt, - _VOICE_DOWNLOAD_ATTEMPTS, - e, - ) - if attempt >= _VOICE_DOWNLOAD_ATTEMPTS: - raise - await asyncio.sleep(_VOICE_DOWNLOAD_RETRY_DELAYS[attempt - 1]) - - raise RuntimeError("unreachable") +for _module in _IMPLEMENTATION_MODULES: + _publish_implementation_names(_module) +# Keep the historical logger category even though implementations now live in +# private sibling modules. It is synchronized into every implementation at +# each facade call. +logger = logging.getLogger(__name__) -# Telegram's Bot API caps file *downloads* (getFile) at 20 MB. A larger -# upload surfaces here as BadRequest("file is too big") on .get_file(); -# turn that into actionable copy instead of a silent ERROR in the logs. -_FILE_TOO_BIG_MSG = ( - "❌ Telegram won't let me download this file — it's over 20 MB.\n\n" - "This is a Telegram **Bot API** limit (bots can only fetch files up to " - "20 MB via getFile), not a ccbot setting. Ways around it:\n" - "• gzip or split the file under 20 MB and resend\n" - "• drop it straight into the session's `.ccbot-inbox/` folder — no " - "Telegram round-trip, no limit\n" - "• bypass with your own Telegram **user session** (MTProto / user-api, " - "e.g. Telethon or Pyrogram): a user account downloads up to 2 GB (4 GB " - "with Premium). That needs a user-api fetch path wired into ccbot." +_FUNCTION_OWNERS: dict[str, ModuleType] = { + # Shared ordering, delivery, live-card bracket and pending-UI handling. + "_voice_transcript_checkpoint": _messages_shared, + "_transcript_contains_voice_text": _messages_shared, + "_wait_for_voice_transcript": _messages_shared, + "_send_with_delivery_proof": _messages_shared, + "_enqueue_voice": _messages_shared, + "_wait_for_voice": _messages_shared, + "_await_prior_voice": _messages_shared, + "_release_voice": _messages_shared, + "_append_dropped_queue_notice": _messages_shared, + "_download_voice_bytes": _messages_shared, + "_is_file_too_big": _messages_shared, + "_card_repost_bracket": _messages_shared, + "_pane_has_interactive_ui": _messages_shared, + "_intercept_if_pending_ui": _messages_shared, + "forward_command_handler": _messages_shared, + # Forwarded content and Telegram file intake. + "_forward_attribution": _messages_media, + "_hidden_link_urls": _messages_media, + "unsupported_content_handler": _messages_media, + "_forward_inbox_file": _messages_media, + "photo_handler": _messages_media, + "document_handler": _messages_media, + # Voice intake and transcription. + "_clear_voice_pending_marker": _messages_voice, + "voice_handler": _messages_voice, + "_process_voice": _messages_voice, + # Text routing and bash-output capture. + "cancel_bash_capture": _messages_text, + "_capture_bash_output": _messages_text, + "_route_reply_quote": _messages_text, + "_resolve_active_window": _messages_text, + "_maybe_start_bash_capture": _messages_text, + "_dispatch_text_to_active": _messages_text, + "text_handler": _messages_text, +} + +# Save function objects before synchronization replaces implementation-module +# globals with facade proxies. Proxies always call these stable originals. +_ORIGINAL_FUNCTIONS: dict[str, Callable[..., Any]] = { + name: getattr(owner, name) for name, owner in _FUNCTION_OWNERS.items() +} + +_FACADE_INTERNALS = { + "_IMPLEMENTATION_MODULES", + "_FUNCTION_OWNERS", + "_ORIGINAL_FUNCTIONS", + "_FACADE_INTERNALS", + "_publish_implementation_names", + "_sync_implementation_names", + "_make_proxy", + "_module", +} + + +def _sync_implementation_names() -> None: + """Push the facade's current (possibly monkeypatched) names downstream.""" + facade_names = { + name: value + for name, value in globals().items() + if not name.startswith("__") and name not in _FACADE_INTERNALS + } + for module in _IMPLEMENTATION_MODULES: + vars(module).update(facade_names) + + +def _make_proxy(name: str) -> Callable[..., Any]: + original = _ORIGINAL_FUNCTIONS[name] + if inspect.iscoroutinefunction(original): + + @wraps(original) + async def async_proxy(*args: Any, **kwargs: Any) -> Any: + _sync_implementation_names() + return await original(*args, **kwargs) + + return async_proxy + + @wraps(original) + def sync_proxy(*args: Any, **kwargs: Any) -> Any: + _sync_implementation_names() + return original(*args, **kwargs) + + return sync_proxy + + +for _name in _FUNCTION_OWNERS: + globals()[_name] = _make_proxy(_name) + +# Explicit aliases make the facade's stable handler surface visible to static +# tooling; each value is the proxy installed above, not the implementation +# function itself. +forward_command_handler = cast(Callable[..., Any], globals()["forward_command_handler"]) +unsupported_content_handler = cast( + Callable[..., Any], globals()["unsupported_content_handler"] ) +photo_handler = cast(Callable[..., Any], globals()["photo_handler"]) +document_handler = cast(Callable[..., Any], globals()["document_handler"]) +voice_handler = cast(Callable[..., Any], globals()["voice_handler"]) +text_handler = cast(Callable[..., Any], globals()["text_handler"]) - -def _is_file_too_big(err: BadRequest) -> bool: - """True when a getFile call hit Telegram's 20 MB Bot-API download cap.""" - return "too big" in str(err).lower() - - -class _RepostHandle: - """Mutable flag used with :func:`_card_repost_bracket`. Call - :meth:`commit` after the pane send succeeded; the bracket then - reposts the live card on exit. - """ - - __slots__ = ("do_repost",) - - def __init__(self) -> None: - self.do_repost = False - - def commit(self) -> None: - self.do_repost = True - - -@asynccontextmanager -async def _card_repost_bracket( - bot: Bot, user_id: int, sess: Session | None -) -> AsyncGenerator[_RepostHandle, None]: - """Bracket a send-to-pane operation with the live-card repost machinery. - - Entry: drop any Menu/sub-screen pause + arm ``repost_intent`` so a - concurrent ``update_session_card`` buffers events instead of spawning - a second card above the user's message. - Exit (only when caller invoked ``handle.commit()``): repost the card - below the user's message and drain buffered events into it. - Always: clear ``repost_intent`` so the live card unblocks for the - next turn. - - No-op when ``sess`` is None (orphan window / no Session record). - """ - handle = _RepostHandle() - if sess is None or not is_active_for_user(user_id, sess): - yield handle - return - await resume_card_view(bot, user_id, sess) - begin_repost_intent(user_id, sess.id) - try: - yield handle - finally: - if handle.do_repost and is_active_for_user(user_id, sess): - try: - await repost_card(bot, user_id, sess) - except Exception as e: - logger.debug("repost_card failed: %s", e) - end_repost_intent(user_id, sess.id) - - -async def _pane_has_interactive_ui(wid: str) -> bool: - """True iff the window's pane is currently showing an interactive prompt. - - Cheap capture-and-classify used by the voice path to verify delivery — - a transcription typed into a pane that is showing a Yes/No prompt gets - consumed as menu navigation and lost, so the caller needs to know. - """ - w = await tmux_manager.find_window_by_id(wid) - if not w: - return False - pane_text = await tmux_manager.capture_pane(w.window_id) - return bool(pane_text) and is_interactive_ui(pane_text) - - -async def _intercept_if_pending_ui( - bot: Bot, - user_id: int, - wid: str, - reply_to: Any, - wasnt_sent_notice: str | None = None, -) -> bool: - """If the pane has a pending interactive UI, surface it and intercept. - - Returns True iff the caller MUST NOT call ``send_to_window``: the - AskUserQuestion / ExitPlanMode / Permission prompt on the pane would - otherwise consume the user's text as menu keystrokes (digits select - options, Enter submits). Caller should ``return`` on True. - - ``wasnt_sent_notice`` overrides the "your message wasn't sent" reply — - the voice path passes a resend-oriented line since a transcription, unlike - typed text, can't just be retyped. - - Surface preference: - - Active session (sess matches ``get_active_session``) → kb-mode - card via ``enter_kb_mode``. Idempotent: a no-op if the card is - already in kb-mode for the same prompt. - - Orphan window or bg session → legacy floating msg via - ``handle_interactive_ui``. - """ - w = await tmux_manager.find_window_by_id(wid) - if not w: - return False - pane_text = await tmux_manager.capture_pane(w.window_id) - if not pane_text or not is_interactive_ui(pane_text): - return False - sess = session_manager.find_session_by_window(wid) - active = session_manager.get_active_session(user_id) - is_active = sess is not None and active is not None and active.id == sess.id - surfaced = False - if is_active and sess is not None: - content_obj = extract_interactive_content(pane_text) - if content_obj is not None: - await enter_kb_mode( - bot, user_id, sess, content_obj.content, content_obj.name - ) - surfaced = True - if not surfaced: - await handle_interactive_ui(bot, user_id, wid) - logger.info( - "intercepted_user_msg_pending_ui user=%d wid=%s", - user_id, - wid, - extra={ - "event": "intercepted_user_msg_pending_ui", - "user_id": user_id, - "window_id": wid, - }, - ) - try: - await safe_reply( - reply_to, - wasnt_sent_notice - or ( - "⏳ Pending prompt above — answer it via the keyboard first. " - "Your message wasn't sent." - ), - ) - except Exception: - pass - return True - - -# --- forward_command — any /command that has no dedicated handler goes here --- - - -async def forward_command_handler( - update: Update, - context: ContextTypes.DEFAULT_TYPE, - *, - pinned_wid: str | None = None, -) -> bool: - """Forward an unhandled /command as a slash to the active Claude session.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - return False - if not update.message: - return False - - cmd_text = update.message.text or "" - cc_slash = cmd_text.split("@")[0] # strip bot mention - wid = pinned_wid or active_window(user.id) - if not wid: - await safe_reply( - update.message, "❌ No active session. Use /new to create one." - ) - return False - if pinned_wid is None and not await _await_prior_voice(user.id, wid): - return False - - w = await tmux_manager.find_window_by_id(wid) - if not w: - display = session_manager.get_display_name(wid) - await safe_reply(update.message, f"❌ Window '{display}' no longer exists.") - return False - - display = session_manager.get_display_name(wid) - logger.info( - "Forwarding command %s to window %s (user=%d)", cc_slash, display, user.id - ) - await fire_typing(context.bot, user.id, "forward_command", window_id=wid) - if await _intercept_if_pending_ui(context.bot, user.id, wid, update.message): - return False - sess = session_manager.find_session_by_window(wid) - async with _card_repost_bracket(context.bot, user.id, sess) as repost: - success, message = await _send_with_delivery_proof(wid, cc_slash, sess) - if success: - # /clear: drop the session association so we re-detect once a - # new session id is written by the next user message. - if cc_slash.strip().lower() == "/clear": - logger.info("Clearing session for window %s after /clear", display) - session_manager.clear_window_session(wid) - if sess is not None: - await clear_card(context.bot, user.id, sess) - await resume_card_view(context.bot, user.id, sess) - await safe_reply( - update.message, - "🧹 Context cleared. Next message starts a fresh Claude session.", - ) - else: - repost.commit() - else: - await safe_reply(update.message, f"❌ {message}") - return False - return True - - -# --- non-text catch-all --- - - -def _forward_attribution(msg: Any) -> str: - """Return ``[forwarded from @name]\n`` prefix when the message looks - like a Telegram forward. Best-effort across PTB versions: - ``forward_origin`` (PTB ≥ 21) and the legacy ``forward_from_chat`` / - ``forward_from`` fields. Empty string when the message isn't a - forward at all.""" - fo = getattr(msg, "forward_origin", None) - if fo is not None: - chat = getattr(fo, "chat", None) or getattr(fo, "sender_chat", None) - if chat is not None: - handle = ( - getattr(chat, "username", None) - or getattr(chat, "title", None) - or "channel" - ) - return f"[forwarded from @{handle}]\n" - usr = getattr(fo, "sender_user", None) - if usr is not None: - handle = ( - getattr(usr, "username", None) - or getattr(usr, "first_name", None) - or "user" - ) - return f"[forwarded from @{handle}]\n" - name = getattr(fo, "sender_user_name", None) - if name: - return f"[forwarded from {name}]\n" - return "[forwarded]\n" - chat = getattr(msg, "forward_from_chat", None) - if chat is not None: - handle = ( - getattr(chat, "username", None) or getattr(chat, "title", None) or "channel" - ) - return f"[forwarded from @{handle}]\n" - usr = getattr(msg, "forward_from", None) - if usr is not None: - handle = ( - getattr(usr, "username", None) or getattr(usr, "first_name", None) or "user" - ) - return f"[forwarded from @{handle}]\n" - return "" - - -def _hidden_link_urls(msg: Any) -> list[str]: - """Pull URLs out of ``text_link`` entities (anchor-text links whose - actual URL isn't in the visible body). Plain-text URLs are already - in the caption text so we don't duplicate them. Operates on both - ``entities`` (text messages) and ``caption_entities`` (media).""" - out: list[str] = [] - seen: set[str] = set() - sources = [] - if getattr(msg, "caption_entities", None): - sources.append(msg.caption_entities) - if getattr(msg, "entities", None): - sources.append(msg.entities) - for ents in sources: - for ent in ents: - etype = getattr(ent, "type", "") - url = getattr(ent, "url", "") or "" - if etype == "text_link" and url and url not in seen: - out.append(url) - seen.add(url) - return out - - -async def unsupported_content_handler( - update: Update, - context: ContextTypes.DEFAULT_TYPE, - *, - pinned_wid: str | None = None, -) -> bool: - """Catch-all for messages without a dedicated handler. - - When the message carries a caption (typical for forwarded channel - posts that bundle a video + body text), extract the caption + any - hidden ``text_link`` URLs and forward the resulting text to the - active session — the media itself is dropped on the floor since - Claude can't consume it directly, but the body keeps the context. - - Falls back to the legacy "unsupported" reply when there's no - caption to salvage. - """ - if not update.message: - return False - user = update.effective_user - if not user or not is_user_allowed(user.id): - return False - msg = update.message - wid_for_queue = pinned_wid or active_window(user.id) - if wid_for_queue is not None: - if pinned_wid is None and not await _await_prior_voice(user.id, wid_for_queue): - return False - - caption = (msg.caption or "").strip() - if caption: - wid = pinned_wid or active_window(user.id) - if wid is None: - await safe_reply( - msg, - "❌ No active session. Send a text message first or use /new.", - ) - return False - w = await tmux_manager.find_window_by_id(wid) - if not w: - display = session_manager.get_display_name(wid) - await safe_reply( - msg, - f"❌ Window '{display}' no longer exists.\n" - "Send a message to start a new session.", - ) - return False - - prefix = _forward_attribution(msg) - hidden_urls = _hidden_link_urls(msg) - body_parts = [prefix + caption] if prefix else [caption] - if hidden_urls: - body_parts.append("Links:") - body_parts.extend(hidden_urls) - text_to_send = "\n".join(body_parts) - - await fire_typing(context.bot, user.id, "caption_forward", window_id=wid) - if await _intercept_if_pending_ui(context.bot, user.id, wid, msg): - return False - sess = session_manager.find_session_by_window(wid) - async with _card_repost_bracket(context.bot, user.id, sess) as repost: - success, message = await _send_with_delivery_proof(wid, text_to_send, sess) - if not success: - await safe_reply(msg, f"❌ {message}") - return False - if sess is not None: - session_manager.touch_session(sess.id) - repost.commit() - # No success reply — the user just sent the message; they know - # they sent it. Errors above still surface. - return True - - logger.debug("Unsupported content from user %d", user.id) - await safe_reply( - msg, - "⚠ Only text, photo, and voice messages are supported. " - "Stickers, video, and other media cannot be forwarded to Claude Code.", - ) - return True - - -# --- inbox file plumbing (photo + document share this) --- - - -async def _forward_inbox_file( - user_id: int, - wid: str, - chat_id: int, - file_path: Path, - caption: str, - label: str, - bot: Bot, -) -> tuple[bool, str]: - """Route an inbound file to the active session. - - Pane payload is shaped as ``\\n\\n.ccbot-inbox/`` so - claude both (a) knows the file exists and where to read it and - (b) sees whatever instructions the user attached. With no caption - it's just the relative path on its own line. This is a minimal - successor to the old verbose ``(image attached: /full/path)`` - synthetic line — short enough not to feel like "the bot speaking - for the user", complete enough that claude doesn't go blind on a - silent drop. - """ - sess = session_manager.find_session_by_window(wid) - workdir = sess.workdir if sess else "" - if workdir: - rel_path = f".ccbot-inbox/{file_path.name}" - else: - rel_path = str(file_path) - text_to_send = f"{caption}\n\n{rel_path}" if caption.strip() else rel_path - await fire_typing(bot, user_id, "inbox_file_forward", window_id=wid, label=label) - return await _send_with_delivery_proof(wid, text_to_send, sess) - - -async def photo_handler( - update: Update, - context: ContextTypes.DEFAULT_TYPE, - *, - pinned_wid: str | None = None, -) -> bool: - """Drop the user's photo into the active session's inbox + notify Claude.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - # Drop the message silently — no reply, no callback ack. The - # allowlist is private; unauthorized senders should see the bot - # as inert (no "not authorized" copy that signals "you found the - # right bot, just not the right user"). - return False - - if not update.message or not update.message.photo: - return False - - wid = pinned_wid or active_window(user.id) - if wid is None: - await safe_reply( - update.message, - "❌ No active session. Send a text message first or use /new.", - ) - return False - if pinned_wid is None and not await _await_prior_voice(user.id, wid): - return False - - w = await tmux_manager.find_window_by_id(wid) - if not w: - display = session_manager.get_display_name(wid) - await safe_reply( - update.message, - f"❌ Window '{display}' no longer exists.\n" - "Send a message to start a new session.", - ) - return False - - sess = session_manager.find_session_by_window(wid) - workdir = sess.workdir if sess and sess.workdir else str(ccbot_dir() / "images") - - photo = update.message.photo[-1] - try: - tg_file = await photo.get_file() - except BadRequest as e: - if _is_file_too_big(e): - await safe_reply(update.message, _FILE_TOO_BIG_MSG) - return False - raise - filename = f"{photo.file_unique_id}.jpg" - - async def _fetch(target: Path) -> None: - await tg_file.download_to_drive(target) - - file_path = await save_inbox_file(workdir, filename, _fetch) - - caption = update.message.caption or "" - if await _intercept_if_pending_ui(context.bot, user.id, wid, update.message): - return False - async with _card_repost_bracket(context.bot, user.id, sess) as repost: - success, message = await _forward_inbox_file( - user.id, wid, user.id, file_path, caption, "image", context.bot - ) - if not success: - await safe_reply(update.message, f"❌ {message}") - return False - repost.commit() - return True - - -async def document_handler( - update: Update, - context: ContextTypes.DEFAULT_TYPE, - *, - pinned_wid: str | None = None, -) -> bool: - """Drop the user's document into the active session's inbox + notify Claude.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - # Drop the message silently — no reply, no callback ack. The - # allowlist is private; unauthorized senders should see the bot - # as inert (no "not authorized" copy that signals "you found the - # right bot, just not the right user"). - return False - - if not update.message or not update.message.document: - return False - - wid = pinned_wid or active_window(user.id) - if wid is None: - await safe_reply( - update.message, - "❌ No active session. Send a text message first or use /new.", - ) - return False - if pinned_wid is None and not await _await_prior_voice(user.id, wid): - return False - - w = await tmux_manager.find_window_by_id(wid) - if not w: - display = session_manager.get_display_name(wid) - await safe_reply( - update.message, - f"❌ Window '{display}' no longer exists.\n" - "Send a message to start a new session.", - ) - return False - - doc = update.message.document - sess = session_manager.find_session_by_window(wid) - workdir = sess.workdir if sess and sess.workdir else str(ccbot_dir() / "images") - filename = doc.file_name or f"{doc.file_unique_id}.bin" - try: - tg_file = await doc.get_file() - except BadRequest as e: - if _is_file_too_big(e): - await safe_reply(update.message, _FILE_TOO_BIG_MSG) - return False - raise - - async def _fetch(target: Path) -> None: - await tg_file.download_to_drive(target) - - file_path = await save_inbox_file(workdir, filename, _fetch) - - caption = update.message.caption or "" - if await _intercept_if_pending_ui(context.bot, user.id, wid, update.message): - return False - async with _card_repost_bracket(context.bot, user.id, sess) as repost: - success, message = await _forward_inbox_file( - user.id, wid, user.id, file_path, caption, "document", context.bot - ) - if not success: - await safe_reply(update.message, f"❌ {message}") - return False - repost.commit() - return True - - -# --- voice --- - - -async def _clear_voice_pending_marker(bot: Bot, user_id: int, sess: Session) -> None: - """Repaint the card without the temporary voice_pending user row. - - Only needed on the transcription-failure paths — the success path's - ``_dispatch_text_to_active`` already reposts unconditionally, which - naturally drops the marker once ``voice_pending`` is cleared. - """ - try: - await resume_card_view(bot, user_id, sess) - except Exception as e: - logger.debug("voice-pending marker clear failed: %s", e) - - -async def voice_handler( - update: Update, - context: ContextTypes.DEFAULT_TYPE, - *, - pinned_wid: str | None = None, - ordered: bool = False, - surface_pending: bool = True, -) -> bool: - """Queue a voice turn, then transcribe it without letting later messages pass.""" - user = update.effective_user - if ( - not user - or not is_user_allowed(user.id) - or not update.message - or not update.message.voice - or resolve_voice_backend(user.id) == "off" - ): - return await _process_voice(update, context) - - wid = pinned_wid or active_window(user.id) - if wid is None: - return await _process_voice(update, context) - - if ordered: - return await _process_voice( - update, - context, - pinned_wid=wid, - surface_pending=surface_pending, - ) - - previous, barrier = _enqueue_voice(user.id, wid) - delivered = False - try: - if previous is not None and not await _wait_for_voice(previous): - return False - delivered = await _process_voice( - update, - context, - pinned_wid=wid, - queue_barrier=barrier, - surface_pending=surface_pending, - ) - finally: - _release_voice(user.id, wid, barrier, delivered=delivered) - return delivered - - -async def _process_voice( - update: Update, - context: ContextTypes.DEFAULT_TYPE, - *, - pinned_wid: str | None = None, - queue_barrier: asyncio.Future[bool] | None = None, - surface_pending: bool = True, -) -> bool: - """Transcribe the voice and forward as text to the active session.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - # Drop the message silently — no reply, no callback ack. The - # allowlist is private; unauthorized senders should see the bot - # as inert (no "not authorized" copy that signals "you found the - # right bot, just not the right user"). - return False - - if not update.message or not update.message.voice: - return False - - if resolve_voice_backend(user.id) == "off": - await safe_reply(update.message, "⚠ Voice is disabled (voice backend = off).") - return False - wid = pinned_wid or active_window(user.id) - if wid is None: - await safe_reply( - update.message, - "❌ No active session. Send a text message first or use /new.", - ) - return False - - w = await tmux_manager.find_window_by_id(wid) - if not w: - display = session_manager.get_display_name(wid) - await safe_reply( - update.message, - f"❌ Window '{display}' no longer exists.\n" - "Send a message to start a new session.", - ) - return False - - # wid is pinned NOW, before the slow download/transcribe steps — a - # switch afterwards can't redirect this voice message. - await fire_typing(context.bot, user.id, "voice_handler.received", window_id=wid) - - # Same immediate reaction a typed message gets: the live card is - # REPOSTED as a fresh message right now, below the voice the user - # just sent — not edited in place. An in-place edit lands on a card - # that sits ABOVE the voice message, which the user never sees; the - # symptom was 35-50 s of apparent dead air while whisper ran (they - # re-recorded, switched sessions, assumed it was broken). - # ``voice_pending`` adds a synthetic trailing user row so the reposted - # card says "voice received, already bound here" exactly where a typed - # prompt would appear. The header remains stable. - # - # The cross-session repost race that made an earlier revision back - # this out is handled properly now: ``_send_card`` serializes spawns - # per user and strips every other card's keyboard, so two reposts - # can no longer desync which message carries the live switcher. - # Skipped for an orphan window (no Session record). - sess = session_manager.find_session_by_window(wid) - card_state = get_card_state(user.id, sess) if sess is not None else None - if sess is not None and card_state is not None: - card_state.voice_pending = True - # A pagination tap may have left the card on an older page. A new - # voice message is a new user turn, so focus the latest page just as - # the normal prompt flow does before showing the pending row. - card_state.current_page_idx = None - if is_active_for_user(user.id, sess): - try: - if surface_pending: - await repost_card(context.bot, user.id, sess) - else: - # Fast intake already moved the card below the voice. Edit - # that carrier to add the pending marker; reposting here - # would create a second acknowledgement card. - await resume_card_view(context.bot, user.id, sess) - except Exception as e: - logger.debug("voice-pending card surface failed: %s", e) - - try: - ogg_data = await _download_voice_bytes( - update.message.voice, user_id=user.id, wid=wid - ) - except NetworkError as e: - if sess is not None and card_state is not None: - card_state.voice_pending = False - await _clear_voice_pending_marker(context.bot, user.id, sess) - logger.error( - "Voice did not reach transcription after %d download attempts " - "user=%d window=%s: %s", - _VOICE_DOWNLOAD_ATTEMPTS, - user.id, - wid, - e, - ) - try: - await safe_reply( - update.message, - _append_dropped_queue_notice( - user.id, - t( - user.id, - "voice.download_failed", - attempts=_VOICE_DOWNLOAD_ATTEMPTS, - ), - queue_barrier, - ), - ) - except Exception as notify_error: - logger.warning( - "Voice download failure notification failed user=%d window=%s: %s", - user.id, - wid, - notify_error, - ) - return False - - try: - text = await transcribe_voice(ogg_data, user_id=user.id) - except ValueError: - if sess is not None and card_state is not None: - card_state.voice_pending = False - await _clear_voice_pending_marker(context.bot, user.id, sess) - await safe_reply( - update.message, - _append_dropped_queue_notice( - user.id, t(user.id, "voice.transcription_failed"), queue_barrier - ), - ) - return False - except Exception as e: - if sess is not None and card_state is not None: - card_state.voice_pending = False - await _clear_voice_pending_marker(context.bot, user.id, sess) - logger.error("Voice transcription failed: %s", e) - await safe_reply( - update.message, - _append_dropped_queue_notice( - user.id, t(user.id, "voice.transcription_failed"), queue_barrier - ), - ) - return False - - if card_state is not None: - card_state.voice_pending = False - - # Typing is a chat-level indicator, so it only makes sense while the - # pinned session is still the one the user is looking at. If they - # switched away during transcription, the text still goes to the - # pinned pane but must stay invisible in chat. - if sess is not None and is_active_for_user(user.id, sess): - await fire_typing( - context.bot, user.id, "voice_handler.transcribed", window_id=wid - ) - cancel_bash_capture(user.id, wid) - - # A transcription is expensive and unrecoverable — unlike typed text the - # user can't just retype 90 seconds of speech. If the pane is showing an - # interactive prompt, the text would be consumed as menu keystrokes and - # silently lost, so tell the user to resend rather than swallowing it. - _voice_lost_notice = _append_dropped_queue_notice( - user.id, t(user.id, "voice.not_delivered"), queue_barrier - ) - if await _intercept_if_pending_ui( - context.bot, user.id, wid, update.message, _voice_lost_notice - ): - return False - - # Same dispatch path text uses — identical reaction (send, auto-name, - # bash-capture, interactive-UI check, card repost) once the text is - # known. No voice-specific reply; the transcribed text just becomes - # this message's text, same as if the user had typed it. - transcript_checkpoint = _voice_transcript_checkpoint(wid) - dispatched = await _dispatch_text_to_active(update, context, user.id, wid, text) - if dispatched is False: - return False - - # A prompt appearing after send is not proof that the voice was eaten: it - # can be an approval raised by the successfully delivered turn, especially - # for the second voice in a queue. Prefer the authoritative transcript and - # only use the pane heuristic when no matching user row appears. - transcript_confirmed = await _wait_for_voice_transcript( - transcript_checkpoint, text, wid=wid - ) - if transcript_confirmed is True: - logger.info( - "Voice delivery confirmed by transcript user=%d window=%s", - user.id, - wid, - ) - return True - if transcript_confirmed is None: - await asyncio.sleep(1.5) - if await _pane_has_interactive_ui(wid): - logger.warning( - "Voice delivery unconfirmed while interactive UI is visible " - "user=%d window=%s", - user.id, - wid, - ) - try: - await safe_reply(update.message, _voice_lost_notice) - except Exception: - pass - return False - return True - - -# --- text + bash !cmd capture --- - - -# Active bash capture tasks: (user_id, window_id) → asyncio.Task -_bash_capture_tasks: dict[tuple[int, str], asyncio.Task[None]] = {} - - -def cancel_bash_capture(user_id: int, window_id: str) -> None: - """Cancel any running bash capture for this (user, window) pair.""" - key = (user_id, window_id) - task = _bash_capture_tasks.pop(key, None) - if task and not task.done(): - task.cancel() - - -async def _capture_bash_output( - bot: Bot, user_id: int, window_id: str, command: str -) -> None: - """Background task: capture ``!cmd`` output from the pane and surface it. - - Sends the first non-empty capture as a new message, then edits in place - as more output appears. Stops after 30 ticks (~30 s) or on cancel. - """ - try: - await asyncio.sleep(2.0) - chat_id = user_id - msg_id: int | None = None - last_output: str = "" - - for _ in range(30): - raw = await tmux_manager.capture_pane(window_id) - if raw is None: - return - - output = extract_bash_output(raw, command) - if not output: - await asyncio.sleep(1.0) - continue - if output == last_output: - await asyncio.sleep(1.0) - continue - last_output = output - - if len(output) > 3800: - output = "… " + output[-3800:] - - if msg_id is None: - sent = await send_with_fallback(bot, chat_id, output) - if sent: - msg_id = sent.message_id - # Rich-first so in-place edits keep the same rendering as the - # initial send (which goes rich via send_with_fallback). - elif not await try_rich_edit(bot, chat_id, msg_id, output): - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=msg_id, - text=convert_markdown(output), - parse_mode="MarkdownV2", - link_preview_options=NO_LINK_PREVIEW, - ) - except Exception: - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=msg_id, - text=output, - link_preview_options=NO_LINK_PREVIEW, - ) - except Exception: - pass - - await asyncio.sleep(1.0) - except asyncio.CancelledError: - return - finally: - _bash_capture_tasks.pop((user_id, window_id), None) - - -async def _route_reply_quote(update: Update, user_id: int, text: str) -> bool: - """Reply-quote routing: if the user replied to a bot message that - belongs to a non-active session, send this single message there - without changing the active session pointer. - - Returns True iff the message was fully handled and ``text_handler`` - must ``return`` (sent to the quoted session, send error, or quoted - message has no session). Returns False to fall through to the - active-session dispatch — both when there is no reply-quote at all - and when the quoted session is dead (a warning is emitted first). - """ - assert update.message is not None - reply = update.message.reply_to_message - if reply is None: - return False - target_sid = lookup_session_for_message(user_id, reply.message_id) - if not target_sid: - return False - target = session_manager.get_session(target_sid) - active_sess = session_manager.get_active_session(user_id) - same_as_active = active_sess is not None and active_sess.id == target_sid - if ( - target is not None - and target.window_id - and target.state in ("active", "idle") - and not same_as_active - ): - tw = await tmux_manager.find_window_by_id(target.window_id) - if tw: - ok, sm = await session_manager.send_to_window(target.window_id, text) - if ok: - session_manager.touch_session(target.id) - # Explicit feedback so the user can see which - # session received the reply-quote — bg session - # would otherwise stay silent until the next - # carrier interaction. - await safe_reply( - update.message, - f"↩ \\[{target.name or target.id}\\]", - ) - return True - await safe_reply(update.message, f"❌ {sm}") - return True - elif target is not None and target.state not in ("active", "idle"): - # User aimed at a dead session (archived/lost/completed). - # Silent fallback would route to active with no signal — - # tell them so the routing surprise is visible. Falls - # through to the active-session dispatch below. - await safe_reply( - update.message, - f"⚠ \\[{target.name or target.id}\\] is {target.state} — " - "routing to the active session instead.", - ) - return False - - -async def _resolve_active_window( - update: Update, - context: ContextTypes.DEFAULT_TYPE, - user_id: int, - text: str, - *, - pinned_wid: str | None = None, -) -> str | None: - """Resolve the active session's tmux window for the inbound text. - - Returns the window id when there is a live active session window. - Returns None when ``text_handler`` must ``return`` instead — either - because there is no active session (a directory browser is opened - with the message queued) or because the active session's - window is gone (it's marked lost, state cleared, and the user told). - """ - assert update.message is not None - wid = pinned_wid or active_window(user_id) - if wid is None: - # No active session — start a directory browser to create one. - from ..startup_queue import begin_startup_queue, enqueue_startup_message - - begin_startup_queue(user_id) - enqueue_startup_message(update, context) - logger.info("No active session: showing directory browser (user=%d)", user_id) - start_path = str(Path.home()) - msg_text, keyboard, subdirs = await build_directory_browser( - start_path, user_id=user_id - ) - if context.user_data is not None: - context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY - context.user_data[BROWSE_PATH_KEY] = start_path - context.user_data[BROWSE_PAGE_KEY] = 0 - context.user_data[BROWSE_DIRS_KEY] = subdirs - await safe_reply(update.message, msg_text, reply_markup=keyboard) - return None - - w = await tmux_manager.find_window_by_id(wid) - if not w: - display = session_manager.get_display_name(wid) - logger.info("Stale active session: window %s gone (user=%d)", display, user_id) - sess = session_manager.find_session_by_window(wid) - if sess is not None: - session_manager.mark_session_lost(sess.id) - if active_window(user_id) == wid: - await clear_session_state(user_id, wid, context.bot) - await safe_reply( - update.message, - f"❌ Window '{display}' no longer exists.\n" - "Send a message to start a new session.", - ) - return None - - return wid - - -def _maybe_start_bash_capture(bot: Bot, user_id: int, wid: str, text: str) -> None: - """Spawn the background ``!cmd`` pane-capture task for a ``!`` prefixed - message. No-op for normal text. Records the task so a follow-up message - can cancel it via :func:`cancel_bash_capture`.""" - if text.startswith("!") and len(text) > 1: - bash_cmd = text[1:] - task = asyncio.create_task(_capture_bash_output(bot, user_id, wid, bash_cmd)) - _bash_capture_tasks[(user_id, wid)] = task - - -async def _dispatch_text_to_active( - update: Update, - context: ContextTypes.DEFAULT_TYPE, - user_id: int, - wid: str, - text: str, -) -> bool: - """Send the user's text to ``wid``'s pane and run the post-send - bookkeeping under the repost-intent bracket. - - Card handling is gated on the target session still being the user's - ACTIVE one. A voice message pins its window at receipt, so by the - time whisper returns the user may well have switched elsewhere — the - text still goes to the pinned pane (that is the entire point of - pinning), but the session is a *background* one now, and background - sessions never post their own chat messages. Doing otherwise dropped - a bg session's card as the newest message in the chat and handed it - the live switcher, which is what made a later switcher tap appear to - edit "the previous message". - - Active path mirrors the original flow: resume the card view + arm - repost-intent (so concurrent ``update_session_card`` events buffer - rather than spawning a second card), send the keystrokes, fire the - early typing indicator, touch + auto-name the session, spawn any - ``!cmd`` capture, drive a pending interactive UI, and finally put the - live card below the user's message. The try/finally always clears the - repost-intent flag even on an early return. - """ - assert update.message is not None - import time as _time - - from .. import metrics - from ..handlers import bg_status - - # If the user typed while looking at a Menu / sub-screen on this - # session's card, drop the pause so incoming events render again. - sess = session_manager.find_session_by_window(wid) - owns_card = sess is not None and is_active_for_user(user_id, sess) - if owns_card and sess is not None: - await resume_card_view(context.bot, user_id, sess) - # Lock spawning out from under us before sending keystrokes — - # claude can emit the first event of its reply within - # milliseconds of send_to_window returning, and - # ``update_session_card`` would otherwise grab the card lock - # first, see ``state.msg_id is None`` (from the previous turn's - # ``finalize_task``) and spawn a fresh card just for that event. - # ``repost_card`` would then spawn a SECOND card and try to - # delete the first — succeeded delete loses claude's content, - # failed delete leaves both visible (user-reported "2 от бота - # после моего сообщения"). The buffer guarantees a single spawn. - begin_repost_intent(user_id, sess.id) - - # Run the rest of the dispatch under a try/finally that always - # clears the repost-intent flag — without this, an early return - # below leaves the flag set forever and the live card stays silent - # for that session until the bot restarts. - intent_sess_id = sess.id if (owns_card and sess is not None) else None - try: - _t0 = _time.time() - success, message = await _send_with_delivery_proof(wid, text, sess) - metrics.observe("tg_to_claude_latency_ms", (_time.time() - _t0) * 1000.0) - metrics.inc("tg_messages_in") - if not success: - metrics.inc("tg_send_failures") - await safe_reply(update.message, f"❌ Delivery not confirmed: {message}") - return False - - # Immediate typing-indicator so the user sees feedback within - # ~500 ms of sending — claude can take 5-30 s before emitting - # its first event (long tool prelude / thinking) and - # ``status_polling`` won't fire typing until the pane enters - # the busy-spinner state. Without this early fire the chat - # looks frozen. fire_typing throttles to one call per ~4 s - # per user — if text_handler already fired Typing a moment - # ago, this is a silent no-op (the indicator is still on). - if owns_card: - await fire_typing( - context.bot, user_id, "text_handler.post_send", window_id=wid - ) - - sess = session_manager.find_session_by_window(wid) - # ``send_to_window`` and Codex's submit verification can take long - # enough for the user to switch sessions. The ``owns_card`` value - # captured before those awaits is no longer authoritative: using it - # below would let the old session resume/repost the carrier that the - # switcher has already handed to the new active session. - owns_card = sess is not None and is_active_for_user(user_id, sess) - if sess is not None: - session_manager.touch_session(sess.id) - # ``maybe_auto_name`` honours the user's ``haiku_naming`` - # setting and the directory-basename guard internally — we - # only need to gate the call on a non-trivial seed (Haiku - # can't summarise "hi" / "ok" into anything useful). - if len(text) >= 20: - asyncio.create_task(maybe_auto_name(sess.id, text, user_id)) - - _maybe_start_bash_capture(context.bot, user_id, wid, text) - - if owns_card: - interactive_window = get_interactive_window(user_id) - if interactive_window and interactive_window == wid: - await asyncio.sleep(0.2) - await handle_interactive_ui(context.bot, user_id, wid) - - if sess is None: - return True - - # Re-check immediately before the card mutation as well. Auto-name, - # interactive-UI handling, and other post-send work above may await. - owns_card = is_active_for_user(user_id, sess) - if not owns_card: - # Background session (voice pinned here, user moved on). - # Its only chat surface is a row in the active card's - # bg-status panel — no card, no push, no switcher steal. - if bg_status.update_status(user_id, sess.id, "working"): - try: - await refresh_panel(context.bot, user_id) - except Exception as e: - logger.debug("refresh_panel after bg dispatch failed: %s", e) - return True - - # Put the live card below the user's message (the card_position - # setting was ripped out — always-in-front is the single - # canonical behaviour). Any events claude emitted between - # send_to_window and here were buffered into state.events by - # update_session_card (it saw the repost-intent flag and held - # off rendering); they drain into the card on the next render. - if card_is_below(user_id, sess.id, update.message.message_id): - # The card is already in front of this message — the voice - # flow reposted it at receipt. Repost again and the user - # gets two cards' worth of churn for one voice; an in-place - # edit is enough to drain the buffer and drop the pending row. - try: - await resume_card_view(context.bot, user_id, sess) - except Exception as e: - logger.debug("card repaint failed: %s", e) - else: - try: - await repost_card(context.bot, user_id, sess) - except Exception as e: - logger.debug("repost_card failed: %s", e) - return True - finally: - if intent_sess_id is not None: - end_repost_intent(user_id, intent_sess_id) - - -async def text_handler( - update: Update, - context: ContextTypes.DEFAULT_TYPE, - *, - pinned_wid: str | None = None, -) -> bool: - user = update.effective_user - if not user or not is_user_allowed(user.id): - # Drop the message silently — no reply, no callback ack. The - # allowlist is private; unauthorized senders should see the bot - # as inert (no "not authorized" copy that signals "you found the - # right bot, just not the right user"). - return False - - if not update.message or not update.message.text: - return False - - text = update.message.text - queued_wid = pinned_wid or active_window(user.id) - if queued_wid is not None: - if pinned_wid is None and not await _await_prior_voice(user.id, queued_wid): - return False - - # A pending /login flow owns the next message: it's the OAuth code, not a - # prompt. Must run before session routing — the code would otherwise be - # typed into a pane (and echoed into that session's transcript). - if await maybe_consume_code(update, context): - return True - - # Ignore text while a picker UI is mid-flight. - state = context.user_data.get(STATE_KEY) if context.user_data else None - if state in ( - STATE_SELECTING_WINDOW, - STATE_BROWSING_DIRECTORY, - STATE_SELECTING_SESSION, - ): - await safe_reply(update.message, "Please use the picker above, or tap Cancel.") - return False - - if await _route_reply_quote(update, user.id, text): - return True - - wid = await _resolve_active_window( - update, context, user.id, text, pinned_wid=pinned_wid - ) - if wid is None: - return False - - await fire_typing(context.bot, user.id, "text_handler", window_id=wid) - - # New message pushes pane content down — kill any in-flight bash capture. - cancel_bash_capture(user.id, wid) - - # Pending AskUserQuestion / ExitPlanMode / Permission on the pane - # would consume our keystrokes as menu navigation (digits select, - # Enter submits). Surface the prompt to the user and bail before - # send_to_window — the user must answer via the keyboard. - if await _intercept_if_pending_ui(context.bot, user.id, wid, update.message): - return False - - return await _dispatch_text_to_active(update, context, user.id, wid, text) - - -# Re-export so existing callers (callbacks/dir_browser.py) keep working. -from ._session_create import create_and_activate_session # noqa: E402 - +# Preserve the deliberately narrow historical star-import contract. Named +# imports of all handlers/private helpers above continue to work as before. __all__ = ["create_and_activate_session"] diff --git a/src/ccbot/codex_usage.py b/src/ccbot/codex_usage.py index 2d546142..f7048f32 100644 --- a/src/ccbot/codex_usage.py +++ b/src/ccbot/codex_usage.py @@ -189,7 +189,11 @@ def read_latest_rollout_usage( try: paths = sorted( root.rglob("rollout-*.jsonl"), - key=lambda path: path.stat().st_mtime, + # Some filesystems assign the exact same timestamp even to two + # consecutive writes. Codex stores rollouts below YYYY/MM/DD and + # names them chronologically, so the full path is a deterministic + # newest-first tie-break instead of relying on rglob order. + key=lambda path: (path.stat().st_mtime_ns, path.as_posix()), reverse=True, ) except OSError as e: diff --git a/src/ccbot/handlers/__init__.py b/src/ccbot/handlers/__init__.py index 3f62e3ec..9ef83bec 100644 --- a/src/ccbot/handlers/__init__.py +++ b/src/ccbot/handlers/__init__.py @@ -2,10 +2,13 @@ This package contains the Telegram bot handlers split by functionality: - callback_data: Callback data constants (CB_* prefixes) - - message_queue: Per-user message queue management - message_sender: Safe message sending helpers with MarkdownV2 fallback - history: Message history pagination - directory_browser: Directory selection UI - interactive_ui: Interactive UI (AskUserQuestion, Permission Prompt, etc.) - status_polling: Terminal status line polling + +Live-card implementation is split across focused ``card_*`` modules; the +historical ``notifications`` and ``card_model`` paths are compatibility +facades. See ``doc/refactor-architecture.md`` for the extension map. """ diff --git a/src/ccbot/handlers/archive.py b/src/ccbot/handlers/archive.py index 37dfff93..30637a15 100644 --- a/src/ccbot/handlers/archive.py +++ b/src/ccbot/handlers/archive.py @@ -1,13 +1,8 @@ -"""Archive listing UI and lifecycle helpers. +"""Archive listing UI, restore lifecycle, and periodic cleanup sweeps. -Periodic sweeps: - - idle_archive_sweep: archive a session after the user's selected idle TTL. - - purge_sweep: drop state.json records older than ARCHIVE_PURGE_AFTER. - Transcripts on disk are kept for audit. - -Interactive UI: - - build_archive_page: render an archived-sessions page with inline buttons. - - inspect, restore, delete callback handlers. +Pure archive text normalization lives in ``archive_blurb``. Stateful helpers +remain here to preserve historical imports and monkeypatch seams around +``session_manager``, ``tmux_manager``, ``config`` and blurb collection. """ from __future__ import annotations @@ -15,14 +10,11 @@ import asyncio import json import logging -import re import time import aiofiles from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup -from pathlib import Path - from ..config import config from ..i18n import t from ..session import ( @@ -34,107 +26,25 @@ from ..session_claude_io import build_session_file_path from ..tmux_manager import tmux_manager from ..transcript_parser import TranscriptParser -from .callback_data import ( - CB_ARC_ALL, - CB_ARC_INSPECT, - CB_ARC_PAGE, +from .archive_blurb import ( + _RE_INJECTED_USER_MSG, + _RE_SYSTEM_UI_TEXT, + _clean_user_msg, + _display_name, + _shorten_workdir, + _truncate_at_word, ) +from .callback_data import CB_ARC_ALL, CB_ARC_INSPECT, CB_ARC_PAGE from .cleanup import clear_session_state -# Per-session blurb cache — keyed by claude_session_id. The blurb is -# the first 1-3 user messages of the session, concatenated until the -# soft length budget kicks in. Archived JSONLs are append-frozen so a -# single scan covers the session's lifetime in archive. +logger = logging.getLogger(__name__) + _BLURB_CACHE: dict[str, str] = {} -# Hard character cap on the combined blurb (all included messages -# plus their hard-break separators). When the first message alone -# exceeds this, it gets truncated with ``…`` on a word boundary; -# subsequent messages are skipped if including them would overshoot. _BLURB_TOTAL_BUDGET = 140 -# Hard cap on how many user messages can land in one blurb. Keeps the -# row short for chatty intros ("hi" / "go" / "do it") that wouldn't hit -# the byte budget on their own. _BLURB_MAX_MESSAGES = 3 - -# Visible divider between session rows on a page. Unicode box-drawing -# chars render the same in rich, MarkdownV2 fallback and plain text; -# the CommonMark ``---`` thematic break would render as a true ``
`` -# under rich but degrade to escaped ``\-\-\-`` in the MarkdownV2 path. _SESSION_DIVIDER = "─────" - -# Claude Code injects its own "user" messages — local-command caveats, -# system reminders, bash plumbing chrome — alongside the genuine user -# prompt. Skipping these when sniffing the first real message keeps the -# archive blurb on-topic. Pattern matches the opening tag (same list as -# ``TranscriptParser._RE_SYSTEM_TAGS``, kept local to avoid a private- -# attribute lint warning). -_RE_INJECTED_USER_MSG = re.compile( - r"<(bash-input|bash-stdout|bash-stderr|local-command-caveat|system-reminder)" -) -# Claude Code also writes "user"-typed JSONL rows for its own UI events: -# ``[Request interrupted by user]`` after a Ctrl-C, slash-command echos -# (``Set model to …``, ``Set effort to …``, ``Compacted``, ``Cleared``, -# ``Memory updated``, ``Memory file …``), and bracket-only status -# markers (``[Resumed]``, ``[2-hour limit reached …]``). These look -# like the user typed them but they're CLI chrome — don't let them -# leak into the archive blurb. -_RE_SYSTEM_UI_TEXT = re.compile( - r"^\s*(?:" - r"\[[^\]\n]+\]\s*$" # whole message is one bracketed marker - r"|Set (?:model|effort|thinking) to\b" - r"|Compact(?:ed|ing)\b" - r"|Cleared\b" - r"|Memory (?:updated|file)\b" - r")", - re.IGNORECASE, -) - - -def _shorten_workdir(path: str) -> str: - """Replace the user's home prefix with ``~`` so paths fit on one row. - Mirrors ``bot._common.shorten_workdir`` — kept here to avoid a - handlers→bot import inversion.""" - if not path: - return "" - home = str(Path.home()) - if path == home: - return "~" - if path.startswith(home + "/"): - return "~" + path[len(home) :] - return path - - -def _clean_user_msg(text: str) -> str: - """Collapse whitespace and strip a leading slash-command prefix. - - Doesn't truncate — the budget is handled at the accumulation level - in ``_collect_user_messages``. The leading-slash strip means a row - that starts with ``/resume real ask`` reads ``real ask`` (the - user's actual ask, not the dispatch verb). - """ - if not text: - return "" - cleaned = " ".join(text.split()) - if cleaned.startswith("/"): - head, _, rest = cleaned.partition(" ") - cleaned = rest if rest else head - return cleaned.strip("` ") - - -def _truncate_at_word(text: str, budget: int) -> str: - """Clip ``text`` to ``budget`` chars on the nearest whole-word - boundary, appending ``…``. - - Scans back from the budget to the previous space; falls back to a - hard cut only if no plausible word boundary exists in the last 24 - chars (very long URLs / single-word messages). - """ - if len(text) <= budget: - return text - cut = text.rfind(" ", 0, budget) - if cut < budget - 24: - cut = budget - return text[:cut].rstrip() + "…" +PAGE_SIZE = 6 +DEFAULT_LOOKBACK_SECONDS = 72 * 3600 def _format_blurb(messages: list[str]) -> str: @@ -276,25 +186,6 @@ async def _archive_blurb(sess: Session) -> str: return blurb -def _display_name(sess: Session) -> str: - """Human-readable form of ``sess.name`` — Haiku produces kebab-case - (``archive-pagination-fix``); for the body row and the inline - button label we render it with spaces (``archive pagination fix``) - so it reads as a natural phrase. Directory-derived names - (``workdir-2``) pass through the same transform without harm. - """ - return (sess.name or sess.id).replace("-", " ") - - -logger = logging.getLogger(__name__) - -# How many archived sessions to render per /archive page. -PAGE_SIZE = 6 - -# Default lookback window for /archive (0-72h). /archive --all extends this. -DEFAULT_LOOKBACK_SECONDS = 72 * 3600 - - def _format_age(user_id: int, ts: float, now: float | None = None) -> str: """Compact human age (``5m``, ``3h``, ``2d``) with localized ``ago`` suffix.""" if not ts: diff --git a/src/ccbot/handlers/archive_blurb.py b/src/ccbot/handlers/archive_blurb.py new file mode 100644 index 00000000..b7946f07 --- /dev/null +++ b/src/ccbot/handlers/archive_blurb.py @@ -0,0 +1,94 @@ +"""Pure text helpers and filters for archived-session presentation. + +The stateful archive facade imports these historical private names so existing +callers continue to use ``ccbot.handlers.archive`` unchanged. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from ..session import Session + + +__all__ = [ + "_RE_INJECTED_USER_MSG", + "_RE_SYSTEM_UI_TEXT", + "_shorten_workdir", + "_clean_user_msg", + "_truncate_at_word", + "_display_name", +] + +_RE_INJECTED_USER_MSG = re.compile( + r"<(bash-input|bash-stdout|bash-stderr|local-command-caveat|system-reminder)" +) + +_RE_SYSTEM_UI_TEXT = re.compile( + r"^\s*(?:" + r"\[[^\]\n]+\]\s*$" # whole message is one bracketed marker + r"|Set (?:model|effort|thinking) to\b" + r"|Compact(?:ed|ing)\b" + r"|Cleared\b" + r"|Memory (?:updated|file)\b" + r")", + re.IGNORECASE, +) + + +def _shorten_workdir(path: str) -> str: + """Replace the user's home prefix with ``~`` so paths fit on one row. + Mirrors ``bot._common.shorten_workdir`` — kept here to avoid a + handlers→bot import inversion.""" + if not path: + return "" + home = str(Path.home()) + if path == home: + return "~" + if path.startswith(home + "/"): + return "~" + path[len(home) :] + return path + + +def _clean_user_msg(text: str) -> str: + """Collapse whitespace and strip a leading slash-command prefix. + + Doesn't truncate — the budget is handled at the accumulation level + in ``_collect_user_messages``. The leading-slash strip means a row + that starts with ``/resume real ask`` reads ``real ask`` (the + user's actual ask, not the dispatch verb). + """ + if not text: + return "" + cleaned = " ".join(text.split()) + if cleaned.startswith("/"): + head, _, rest = cleaned.partition(" ") + cleaned = rest if rest else head + return cleaned.strip("` ") + + +def _truncate_at_word(text: str, budget: int) -> str: + """Clip ``text`` to ``budget`` chars on the nearest whole-word + boundary, appending ``…``. + + Scans back from the budget to the previous space; falls back to a + hard cut only if no plausible word boundary exists in the last 24 + chars (very long URLs / single-word messages). + """ + if len(text) <= budget: + return text + cut = text.rfind(" ", 0, budget) + if cut < budget - 24: + cut = budget + return text[:cut].rstrip() + "…" + + +def _display_name(sess: Session) -> str: + """Human-readable form of ``sess.name`` — Haiku produces kebab-case + (``archive-pagination-fix``); for the body row and the inline + button label we render it with spaces (``archive pagination fix``) + so it reads as a natural phrase. Directory-derived names + (``workdir-2``) pass through the same transform without harm. + """ + return (sess.name or sess.id).replace("-", " ") diff --git a/src/ccbot/handlers/bg_status.py b/src/ccbot/handlers/bg_status.py index 60c56f50..8d355c77 100644 --- a/src/ccbot/handlers/bg_status.py +++ b/src/ccbot/handlers/bg_status.py @@ -11,6 +11,7 @@ - "error" ❌ error event while bg - "needs_action" ❓ AskUserQuestion / ExitPlanMode / permission prompt detected on bg session + - "stalled" ⚠️ unfinished turn stayed silent past its threshold The pending interactive UI itself is detected and remembered here (``pending_interactive_ui``) so the switcher-tap handler can render @@ -51,7 +52,7 @@ logger = logging.getLogger(__name__) -Status = Literal["working", "finished", "error", "needs_action"] +Status = Literal["working", "finished", "error", "needs_action", "stalled"] _STATUS_EMOJI: dict[Status, str] = { @@ -59,6 +60,7 @@ "finished": "✅", "error": "❌", "needs_action": "❓", + "stalled": "⚠️", } @@ -345,7 +347,13 @@ def load_per_user(raw: dict[str, Any] | None) -> None: if not isinstance(data, dict): continue status_val = data.get("status", "working") - if status_val not in ("working", "finished", "error", "needs_action"): + if status_val not in ( + "working", + "finished", + "error", + "needs_action", + "stalled", + ): continue try: last_change = float(data.get("last_change", 0.0)) diff --git a/src/ccbot/handlers/card_binding.py b/src/ccbot/handlers/card_binding.py new file mode 100644 index 00000000..2aec0767 --- /dev/null +++ b/src/ccbot/handlers/card_binding.py @@ -0,0 +1,86 @@ +"""Atomic helpers for a card's Telegram carrier representation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .card_types import CardState, CarrierKind + +__all__ = [ + "CarrierBinding", + "bind_carrier", + "carrier_kind", + "clear_carrier", + "restore_carrier", + "snapshot_carrier", +] + + +@dataclass(frozen=True) +class CarrierBinding: + """Complete transport state tied to one Telegram message identity.""" + + msg_id: int | None + kind: CarrierKind = CarrierKind.TEXT + rich_media_file_id: str = "" + pane_hash: str = "" + photo_edit_ts: float = 0.0 + + +def carrier_kind(state: CardState) -> CarrierKind: + """Return the normalized carrier kind, tolerating legacy test fixtures.""" + if state.is_rich_media_msg: + return CarrierKind.RICH_MEDIA + if state.is_photo_msg: + return CarrierKind.LEGACY_PHOTO + return CarrierKind.TEXT + + +def snapshot_carrier(state: CardState) -> CarrierBinding: + """Capture all state that belongs to the current Telegram message.""" + return CarrierBinding( + msg_id=state.msg_id, + kind=carrier_kind(state), + rich_media_file_id=state.rich_media_file_id, + pane_hash=state.last_pane_hash, + photo_edit_ts=state.last_photo_edit_ts, + ) + + +def restore_carrier(state: CardState, binding: CarrierBinding) -> None: + """Atomically restore a previously captured carrier binding.""" + bind_carrier( + state, + binding.msg_id, + binding.kind, + rich_media_file_id=binding.rich_media_file_id, + pane_hash=binding.pane_hash, + photo_edit_ts=binding.photo_edit_ts, + ) + + +def bind_carrier( + state: CardState, + msg_id: int | None, + kind: CarrierKind = CarrierKind.TEXT, + *, + rich_media_file_id: str = "", + pane_hash: str = "", + photo_edit_ts: float = 0.0, +) -> None: + """Bind ``state`` to one message and set its transport kind as a unit.""" + state.msg_id = msg_id + state.is_rich_media_msg = kind is CarrierKind.RICH_MEDIA + state.is_photo_msg = kind is CarrierKind.LEGACY_PHOTO + state.rich_media_file_id = ( + rich_media_file_id if kind is CarrierKind.RICH_MEDIA else "" + ) + state.last_pane_hash = pane_hash if kind is not CarrierKind.TEXT else "" + state.last_photo_edit_ts = photo_edit_ts if kind is not CarrierKind.TEXT else 0.0 + + +def clear_carrier(state: CardState) -> int | None: + """Release the Telegram message and clear every media-specific field.""" + old_msg_id = state.msg_id + bind_carrier(state, None) + return old_msg_id diff --git a/src/ccbot/handlers/card_budget.py b/src/ccbot/handlers/card_budget.py new file mode 100644 index 00000000..1e3dc027 --- /dev/null +++ b/src/ccbot/handlers/card_budget.py @@ -0,0 +1,251 @@ +"""Low-level card text sizing, chunking, and elapsed-time helpers.""" + +from __future__ import annotations + +import re + +from .card_text import _strip_for_card +from .card_types import ( + CARD_PAGE_BUDGET, + CARD_PAGE_LINES_DEFAULT, + CARD_PAGE_LINES_OVERSHOOT, + SPOILER_MAX_LINES, + Event, +) + +__all__ = [ + "_format_elapsed", + "_format_hhmm", + "_format_hhmmss", + "_is_in_flight", + "_body_trim", + "_SENTENCE_END_RE", + "_table_continuation_prefix", + "_chunk_final_text", + "_trimmed_body", + "_count_lines", + "_MD_V2_ESCAPE_CHARS", + "_estimate_md_v2_size", + "_char_pos_at_byte_budget", +] + +# ─── Render helpers ─────────────────────────────────────────────────── + + +def _format_elapsed(seconds: float) -> str: + """Format ``M:SS`` for an elapsed timer (negative → ``0:00``).""" + s = max(0, int(seconds)) + return f"{s // 60}:{s % 60:02d}" + + +def _format_hhmm(epoch: float) -> str: + import datetime as _dt + + return _dt.datetime.fromtimestamp(epoch).strftime("%H:%M") + + +def _format_hhmmss(epoch: float) -> str: + import datetime as _dt + + return _dt.datetime.fromtimestamp(epoch).strftime("%H:%M:%S") + + +def _is_in_flight(event: Event, events: list[Event], idx: int) -> bool: + """Per spec: ``⏳`` lives only on the LAST event of the latest page. + + Older events with ``completed_at=None`` are implicitly considered + finished by virtue of a newer event having started — for the user + the timer "moves" with whatever block claude is currently producing. + """ + if idx != len(events) - 1: + return False + if event.completed_at is not None: + return False + return event.type in ("tool_use", "thinking", "text") + + +def _body_trim(body: str, max_lines: int = SPOILER_MAX_LINES) -> str: + """Trim body content to ``max_lines`` lines. Excess → ``… (+N more lines)``.""" + if not body: + return "" + lines = body.split("\n") + if len(lines) <= max_lines: + return body + kept = lines[:max_lines] + extra = len(lines) - max_lines + kept.append(f"… (+{extra} more lines)") + return "\n".join(kept) + + +_SENTENCE_END_RE = re.compile(r"[.!?][\s)\]\"»]*\s") + + +def _table_continuation_prefix(chunk: str, remaining: str) -> str: + """Header+separator rows to prepend when a cut landed inside a GFM table. + + If ``chunk`` ends with table rows (a trailing run of ``|``-lines whose + first two are a header + ``|---|`` separator) and ``remaining`` starts + with another table row, the table was split mid-body — the continuation + page would render as headerless junk. Returns ``"header\\nsep\\n"`` so + the caller can re-emit them; ``""`` when no table was cut. + """ + rem_first = remaining.lstrip("\n").split("\n", 1)[0] + if not rem_first.lstrip().startswith("|"): + return "" + lines = chunk.rstrip("\n").split("\n") + i = len(lines) + while i > 0 and lines[i - 1].lstrip().startswith("|"): + i -= 1 + run = lines[i:] + if len(run) < 2: + return "" + sep_core = run[1].strip().strip("|").replace("|", "").replace(" ", "") + if not sep_core or not set(sep_core) <= {"-", ":"}: + return "" + return run[0] + "\n" + run[1] + "\n" + + +def _chunk_final_text( + text: str, + budget_lines: int = CARD_PAGE_LINES_DEFAULT, + byte_budget: int = CARD_PAGE_BUDGET, +) -> list[str]: + """Split a long final answer into chunks ≤ ``budget_lines`` AND ≤ ``byte_budget``. + + Smart-boundary preference (per user spec): paragraph (``\\n\\n``) → + line (``\\n``) → sentence terminator (``.!?``) → word (space) → hard. + Allows up to ``CARD_PAGE_LINES_OVERSHOOT`` extra lines so a sentence + isn't broken mid-content. NEVER breaks mid-word. + + The byte cap mirrors Telegram's 4096-byte edit limit (with headroom + for header / divider / footer / bg-panel). Without it, a wide + single-paragraph answer can pass the line cap and still overflow + after MarkdownV2 escaping — every reserved char gets a ``\\`` + prefix, blowing the rendered size past the limit. + + Empty / short input returns a single-chunk list. + """ + if not text: + return [] + if _count_lines(text) <= budget_lines and _estimate_md_v2_size(text) <= byte_budget: + return [text] + + chunks: list[str] = [] + remaining = text + while ( + _count_lines(remaining) > budget_lines + or _estimate_md_v2_size(remaining) > byte_budget + ): + rem_lines = remaining.split("\n") + # 1. Paragraph break — look at the last \n\n within budget+overshoot, + # clamped to the byte budget so we never search past the safe + # rendered-size window. + cap = budget_lines + CARD_PAGE_LINES_OVERSHOOT + char_cap_lines = sum( + len(rem_lines[i]) + 1 for i in range(min(cap, len(rem_lines))) + ) + char_cap_bytes = _char_pos_at_byte_budget(remaining, byte_budget) + char_cap = ( + min(char_cap_lines, char_cap_bytes) if char_cap_bytes else char_cap_lines + ) + # If even one char is over byte budget, char_cap_bytes is 0 — use a + # minimal cap so the boundary scans still see SOMETHING. Edge case. + if char_cap <= 0: + char_cap = max(1, char_cap_lines) + cut = remaining.rfind("\n\n", 0, char_cap) + + # 2. Line break within budget (no overshoot). + if cut <= 0: + char_budget = sum( + len(rem_lines[i]) + 1 for i in range(min(budget_lines, len(rem_lines))) + ) + char_budget = min(char_budget, char_cap) + cut = remaining.rfind("\n", 0, char_budget) + + # 3. Sentence terminator within budget+overshoot. + if cut <= 0: + m_iter = list(_SENTENCE_END_RE.finditer(remaining[:char_cap])) + if m_iter: + cut = m_iter[-1].end() + + # 4. Word boundary within budget+overshoot. + if cut <= 0: + cut = remaining.rfind(" ", 0, char_cap) + + # 5. Hard cut (last resort — only if no other boundary found in + # the entire overshoot window). Use char_cap to avoid mid-word + # if possible; otherwise raw budget cut. + if cut <= 0: + cut = char_cap if char_cap > 0 else len(remaining) + + chunk = remaining[:cut].rstrip() + if chunk: + chunks.append(chunk) + remaining = remaining[cut:].lstrip("\n").lstrip(" ") + # Cut landed inside a GFM table → re-emit header+separator so the + # next page renders as a valid table. Length guard keeps forward + # progress (never prepend more than the cut consumed). + if chunk: + prefix = _table_continuation_prefix(chunk, remaining) + if prefix and len(prefix) < cut: + remaining = prefix + remaining + if remaining: + chunks.append(remaining) + return chunks + + +def _trimmed_body(body: str) -> str: + """Trim and home-path-clean a tool / thinking body so it's safe to + drop into a spoiler block. Returns empty when there's nothing left + to show.""" + return _body_trim(_strip_for_card(body)) + + +def _count_lines(text: str) -> int: + """Count logical \\n-delimited lines in a rendered string.""" + if not text: + return 0 + return text.count("\n") + 1 + + +# Telegram MarkdownV2 reserved chars — each one gains a leading ``\`` +# during ``convert_markdown``. We use this as an upper-bound estimate of +# the post-render byte count without paying for a real telegramify +# round-trip on every event. The bound is sloppy on purpose: better to +# oversplit a long answer than to send a 4096+ byte payload and lose the +# whole card edit to ``Message_too_long``. +_MD_V2_ESCAPE_CHARS = frozenset("_*[]()~`>#+-=|{}.!\\") + + +def _estimate_md_v2_size(text: str) -> int: + """Upper bound on ``len(convert_markdown(text))`` (chars / bytes-ASCII). + + Each MarkdownV2 reserved char contributes ``+1`` over the raw length + for its escape backslash. Real telegramify-markdown sometimes leaves + a few of these unescaped inside valid markdown tokens (``**bold**`` + etc.), but using an over-estimate is the safe direction — we'd + rather chunk earlier than discover overflow at edit time. + """ + if not text: + return 0 + extra = sum(1 for c in text if c in _MD_V2_ESCAPE_CHARS) + return len(text) + extra + + +def _char_pos_at_byte_budget(text: str, byte_budget: int) -> int: + """Largest ``p`` such that ``_estimate_md_v2_size(text[:p]) <= byte_budget``. + + Returns ``len(text)`` if the whole string fits. Used by + ``_chunk_final_text`` to clamp the boundary-search window when a + long answer would otherwise overflow Telegram's 4096-byte edit cap + even at very few visual lines. + """ + if byte_budget <= 0 or not text: + return 0 + size = 0 + for i, c in enumerate(text): + bump = 2 if c in _MD_V2_ESCAPE_CHARS else 1 + if size + bump > byte_budget: + return i + size += bump + return len(text) diff --git a/src/ccbot/handlers/card_carrier.py b/src/ccbot/handlers/card_carrier.py new file mode 100644 index 00000000..530bc485 --- /dev/null +++ b/src/ccbot/handlers/card_carrier.py @@ -0,0 +1,614 @@ +"""Transfer, pause, restore, clear, and resume Telegram card carriers.""" + +from __future__ import annotations + +from __future__ import annotations + +import asyncio +import logging +import time + +from telegram import Bot + +from ..session import Session, session_manager +from .card_binding import ( + bind_carrier, + clear_carrier, + restore_carrier, + snapshot_carrier, +) +from .card_model import ( + CardState, + _card_is_busy, +) +from .card_types import CarrierKind, TurnPhase +from .card_registry import ( + _cards, + _card_lock, + _carrier_edit_lock, + _strip_stale_switchers, + _register_msg, + reset_card, + _legacy, +) + +logger = logging.getLogger(__name__) + + +__all__ = [ + "cancel_pending_card_edits", + "close_card_view", + "set_card_context_pct", + "mark_card_paused", + "pause_card_view", + "transfer_card_to_carrier", + "activate_card_on_carrier", + "card_is_below", + "detach_paused_cards_at_message", + "release_card_message", + "resume_card_view", + "paint_card_on_carrier", + "restore_card", + "clear_card", +] + + +async def cancel_pending_card_edits(timeout: float = 2.0) -> None: + """Cancel + drain every deferred ``_edit_card`` task across all cards. + + Called from ``post_shutdown`` so we don't leave ``_deferred_edit`` + tasks in the "pending" state when the event loop closes — asyncio + logs ``Task was destroyed but it is pending!`` for each one, and + any in-flight Telegram edit can race with the final state save. + """ + tasks: list[asyncio.Task[None]] = [] + for state in _cards.values(): + t = state.pending_edit + if t is not None and not t.done(): + t.cancel() + tasks.append(t) + state.pending_edit = None + if not tasks: + return + try: + await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), timeout=timeout + ) + except asyncio.TimeoutError: + logger.warning( + "card-edit shutdown drain timed out after %ss with %d tasks pending", + timeout, + sum(1 for t in tasks if not t.done()), + ) + + +async def close_card_view(bot: Bot, user_id: int, session_id: str) -> None: + """Release the live card slot so the next event creates a fresh + message instead of editing the old carrier. + + Used by the Shot button (Task #51): the screenshot photo replaces + the live card visually, and when the user comes back from the + screenshot we want a NEW card message to appear (replacement of + one message by another), not an in-place edit of a now-stale + carrier far up the chat. + + Steps: + - Cancel any pending edit on the old carrier. + - **Delete** the old carrier message so the chat reads as a + clean replacement (per #52 follow-up — stripping the keyboard + was confusing, the orphaned message read like a frozen card). + - Drop ``msg_id`` so the next claude event / Shot Back spawns a + fresh card. + - Leave ``in_menu_view=True`` so events buffer until the user + actually navigates back (the Shot Back handler clears it). + """ + state = _cards.get((user_id, session_id)) + if state is None: + return + if state.pending_edit is not None and not state.pending_edit.done(): + state.pending_edit.cancel() + state.pending_edit = None + old_msg_id = clear_carrier(state) + state.last_rendered = "" + state.in_menu_view = True + if old_msg_id is not None: + try: + await bot.delete_message(chat_id=user_id, message_id=old_msg_id) + except Exception as e: + logger.debug( + "close_card_view: delete old msg failed msg_id=%s: %s", + old_msg_id, + e, + ) + logger.info( + "card_close user=%d sess=%s old_msg_id=%s", + user_id, + session_id, + old_msg_id, + extra={ + "event": "card_close", + "user_id": user_id, + "session_id": session_id, + "old_msg_id": old_msg_id, + }, + ) + + +def set_card_context_pct(user_id: int, session_id: str, pct: int) -> None: + """Stash the latest context-window fill percentage for this session's + live card. Read by ``_render_card`` to paint a ``context: N%`` line + above the bg-status panel. No-op when no state exists yet. + """ + state = _cards.setdefault((user_id, session_id), CardState()) + state.context_pct = pct + + +def mark_card_paused(user_id: int, session_id: str) -> None: + """Force a card to ``in_menu_view=True``, creating empty state if + none exists. Differs from :func:`pause_card_view` which silently + no-ops on a missing state — needed for the Shot → switcher path + where the user pivots onto a session whose card was never seeded. + """ + _cards.setdefault((user_id, session_id), CardState()).in_menu_view = True + + +def pause_card_view(user_id: int, session_id: str) -> None: + """Mark the live card paused so session updates buffer instead of + rendering. Called when the user opens a Menu / sub-screen on the + card's message — otherwise a stream of tool calls would overwrite + whatever they're looking at.""" + state = _cards.get((user_id, session_id)) + if state is None: + logger.info( + "card_pause skip user=%d sess=%s reason=no_state", + user_id, + session_id, + extra={ + "event": "card_pause_skip", + "user_id": user_id, + "session_id": session_id, + "reason": "no_state", + }, + ) + return + state.in_menu_view = True + logger.info( + "card_pause user=%d sess=%s msg_id=%s lines=%d", + user_id, + session_id, + state.msg_id, + len(state.events), + extra={ + "event": "card_pause", + "user_id": user_id, + "session_id": session_id, + "msg_id": state.msg_id, + "lines": len(state.events), + }, + ) + + +def transfer_card_to_carrier( + user_id: int, + from_session_id: str | None, + to_session_id: str, + target_message_id: int, +) -> int | None: + """Hand off ownership of ``target_message_id`` from one session's + live card to another's. Called when the switcher flips active. + + Returns the message id of the TO session's *previous* card when it + was a different message — that message is now orphaned (nothing will + ever edit it again) and the caller must strip its keyboard, or the + chat ends up with two tappable switchers. Returns None when there is + nothing to clean up. + + Effect: + - FROM session is paused (``in_menu_view=True``) so its events + buffer silently in ``state.events`` instead of editing the + carrier (which now belongs to the TO session). No new chat + message lands until the user switches back or types text. + - TO session claims the carrier (``msg_id=target_message_id``) + and its pause is released, so the next event for it renders + on the carrier — overlaying the preview that the callback + just painted. + + No-op when ``from_session_id == to_session_id`` (user tapped the + already-active session). The previous live-card behaviour — where + A's lingering ``msg_id`` clobbered B's preview every time A emitted + a tool call — falls out naturally because A is now paused. + """ + if from_session_id == to_session_id: + logger.info( + "card_transfer skip user=%d sess=%s reason=same_session", + user_id, + to_session_id, + extra={ + "event": "card_transfer_skip", + "user_id": user_id, + "session_id": to_session_id, + "reason": "same_session", + }, + ) + return None + from_msg_id_was: int | None = None + source_binding = None + if from_session_id: + from_state = _cards.get((user_id, from_session_id)) + if from_state is not None: + from_msg_id_was = from_state.msg_id + source_binding = snapshot_carrier(from_state) + if ( + from_state.pending_edit is not None + and not from_state.pending_edit.done() + ): + from_state.pending_edit.cancel() + from_state.pending_edit = None + from_state.in_menu_view = True + to_state = _cards.setdefault((user_id, to_session_id), CardState()) + to_msg_id_was = to_state.msg_id + if to_state.pending_edit is not None and not to_state.pending_edit.done(): + to_state.pending_edit.cancel() + to_state.pending_edit = None + if source_binding is not None and source_binding.msg_id == target_message_id: + restore_carrier(to_state, source_binding) + else: + bind_carrier(to_state, target_message_id, CarrierKind.TEXT) + session_manager.set_card_msg(user_id, target_message_id) + # Pause the TO card across the switch window. The caller (CB_SW_USE) + # will paint history on this message_id next, and then call + # ``release_card_message`` which clears both ``msg_id`` and + # ``in_menu_view``. If we left ``in_menu_view=False`` here, any bg + # event arriving in the ~150 ms parse + edit window would trigger + # ``refresh_panel`` — that path sees + # ``msg_id=carrier`` + ``in_menu_view=False`` and rerenders the + # live-card body over the carrier, clobbering the history paint + # we're racing to land. Symptom: user sees "header + bg panel" + # instead of transcript after a switch. + to_state.in_menu_view = True + logger.info( + "card_transfer user=%d from=%s (from_msg=%s) to=%s (was_msg=%s) carrier=%s", + user_id, + from_session_id or "-", + from_msg_id_was, + to_session_id, + to_msg_id_was, + target_message_id, + extra={ + "event": "card_transfer", + "user_id": user_id, + "from_session_id": from_session_id, + "from_msg_id_was": from_msg_id_was, + "to_session_id": to_session_id, + "to_msg_id_was": to_msg_id_was, + "carrier_msg_id": target_message_id, + }, + ) + if to_msg_id_was is not None and to_msg_id_was != target_message_id: + return to_msg_id_was + return None + + +async def activate_card_on_carrier( + user_id: int, + from_session_id: str | None, + to_session_id: str, + target_message_id: int, +) -> int | None: + """Atomically hand the live carrier to a newly-active session. + + The carrier-edit lock is a barrier against an edit from the previous + active session that is already in flight. Once the barrier opens, pause + the old card, claim the carrier for the target, and flip ``active_sessions`` + before another card edit can start. The caller paints the target after + this returns; any queued old-session edit then sees the paused state and + becomes a no-op. + + Returns the target session's orphaned previous card message id, matching + :func:`transfer_card_to_carrier`. + """ + async with _carrier_edit_lock(user_id): + orphan_msg_id = transfer_card_to_carrier( + user_id, + from_session_id, + to_session_id, + target_message_id, + ) + session_manager.set_active_session(user_id, to_session_id) + return orphan_msg_id + + +def card_is_below(user_id: int, session_id: str, message_id: int) -> bool: + """True when the session's live card already sits *below* + ``message_id`` in the chat. + + Telegram message ids are monotonically increasing per chat, so a + card whose ``msg_id`` is greater than the user's message was posted + after it and is already "in front" — a repost would only churn. + Used by the voice flow: the card is reposted at voice-receipt, so + when whisper returns 30 s later there is nothing to move, just the + 🎙 marker to drop with an in-place edit. + """ + state = _cards.get((user_id, session_id)) + return state is not None and state.msg_id is not None and state.msg_id > message_id + + +def detach_paused_cards_at_message(user_id: int, message_id: int) -> None: + """Release card state bound to ``message_id`` when the carrier has + been repurposed for a different flow. + + The pause→resume design assumes the user eventually returns to the + live card via ``resume_card_view`` (typing text, etc.). But when + the carrier message gets hijacked for a different session — e.g. + user navigates ``Menu → + new`` and confirms a directory, the new + session's "Created" status now owns the message — the OLD session's + pause never gets released and its events buffer forever. Worse, + ``state.msg_id`` still points at a message that's no longer its + card, so a later edit would clobber whatever's there. + + This helper resets ``msg_id`` (so the next event opens a fresh + card) and clears the pause flags for every card on this user that + happened to be paused on the now-stolen message. + """ + detached: list[str] = [] + for (uid, sid), state in list(_cards.items()): + if uid != user_id or state.msg_id != message_id: + continue + if state.pending_edit is not None and not state.pending_edit.done(): + state.pending_edit.cancel() + state.pending_edit = None + clear_carrier(state) + state.in_menu_view = False + # Mark continuation so the next card visually flags carry-over + # (``…continued`` in the header). + state.is_continuation = True + detached.append(sid) + if detached: + logger.info( + "card_detach user=%d msg=%s sessions=%s", + user_id, + message_id, + detached, + extra={ + "event": "card_detach", + "user_id": user_id, + "msg_id": message_id, + "sessions": detached, + }, + ) + + +def release_card_message(user_id: int, session_id: str) -> None: + """Drop the live-card binding to its current Telegram message_id + without touching the message itself. + + Called from the switcher-tap handler right after history is painted + on the carrier: the carrier now holds a frozen transcript view, and + the TO session's live card must NOT keep editing it. With ``msg_id`` + cleared, the next claude event opens a fresh card below (carrying + the bg-status panel and prior-context seed); the history carrier + stays put and remains paginable. + + Buffered ``lines`` are also wiped — they were destined for the + overwritten card; the fresh card starts empty on its next event. + """ + state = _cards.get((user_id, session_id)) + if state is None: + return + if state.pending_edit is not None and not state.pending_edit.done(): + state.pending_edit.cancel() + state.pending_edit = None + clear_carrier(state) + state.in_menu_view = False + state.events = [] + state.last_rendered = "" + state.is_continuation = True + # A6: this is a non-destructive carrier hand-off — the session keeps + # running with a full transcript. Allow the next event's fresh card + # to re-seed so its footer page counter reflects the real recent + # turn-history instead of collapsing to ``1/1``. + state.seed_attempted = False + state.seed_mtime = -1.0 + logger.info( + "card_release user=%d sess=%s", + user_id, + session_id, + extra={ + "event": "card_release", + "user_id": user_id, + "session_id": session_id, + }, + ) + + +async def resume_card_view(bot: Bot, user_id: int, sess: Session) -> None: + """Drop the menu-pause so future events render again, and re-paint + the carrier with the buffered events. + + For the currently-active session, clears ``in_menu_view`` even when + ``msg_id`` was lost (carrier stale / deleted / not yet created). Earlier + this returned early without clearing the pause, leaving the active card + stuck in ``must_buffer=True`` forever. A background session is the one + exception: its pause and carrier binding must remain untouched so a late + voice dispatch cannot reclaim the newly-active session's carrier. + """ + # ``setdefault`` so a session with no card-state yet (just-switched + # bg session via Shot's switcher) still lands on a visible surface. + # Without this, resume_card_view silently bailed and Back left the + # user staring at empty chat. + state = _cards.setdefault((user_id, sess.id), CardState()) + + async def _spawn_fresh() -> None: + await _legacy("_ensure_seeded")(user_id, sess, state) + fresh_text = _legacy("_render_card")(sess, state, user_id=user_id) + fresh_kb = _legacy("build_footer_keyboard")( + user_id, screen="main", is_busy=_card_is_busy(state) + ) + await _legacy("_send_card")( + bot, user_id, sess, state, text=fresh_text, reply_markup=fresh_kb + ) + + # Spawn-serialization (Task #50): hold the per-session lock across + # the msg_id check + send/edit. Otherwise a claude event arriving + # during ``_ensure_seeded`` / ``_send_card`` can race and produce a + # duplicate card via ``update_session_card``. + async with _card_lock(user_id, sess.id): + # The active-session check and the Telegram edit share the same + # cross-session barrier as switcher hand-off. A slow voice dispatch + # may have started while this session was active and resumed after the + # carrier moved elsewhere; it must not clear the old card's pause or + # repaint the new owner's carrier. + async with _carrier_edit_lock(user_id): + if not _legacy("is_active_for_user")(user_id, sess): + logger.info( + "card_resume skip user=%d sess=%s reason=background", + user_id, + sess.id, + ) + return + state.in_menu_view = False + if state.pending_edit is not None and not state.pending_edit.done(): + state.pending_edit.cancel() + state.pending_edit = None + if state.msg_id is None: + # No carrier — spawn a fresh card now so the user lands on a + # visible surface immediately (used by Shot → Back after #51's + # ``close_card_view`` drops msg_id). Previously we waited for + # the next claude event; on quiet sessions that left the user + # staring at empty chat. + await _spawn_fresh() + return + text = _legacy("_render_card")(sess, state, user_id=user_id) + keyboard = _legacy("build_footer_keyboard")( + user_id, screen="main", is_busy=_card_is_busy(state) + ) + if await _legacy("_edit_card_unlocked")( + bot, user_id, state, text=text, reply_markup=keyboard + ): + state.last_rendered = text + state.last_edit_ts = time.monotonic() + return + # ``_edit_card_unlocked`` returned False — the carrier was lost + # (stale msg, already-deleted, or bot can't edit it) and already + # reset msg_id internally. Spawn a fresh card so the user still + # lands on a visible live surface. + await _spawn_fresh() + + +async def paint_card_on_carrier( + bot: Bot, + user_id: int, + sess: Session, + carrier_msg_id: int, +) -> None: + """Claim ``carrier_msg_id`` as ``sess``'s live card and paint it. + + Used by Menu → Sessions: the carrier is the menu message the user just + tapped, and we want it to become the live card (one unified surface + instead of a separate list rendering). The previous ``state.msg_id`` + is left as a frozen artifact in chat — the next claude event uses + the new carrier. + """ + state = _cards.setdefault((user_id, sess.id), CardState()) + # Menu → Sessions on a fresh post-restart state: seed history first + # so the user lands on a card with their conversation, not 1/1. + await _legacy("_ensure_seeded")(user_id, sess, state) + if state.pending_edit is not None and not state.pending_edit.done(): + state.pending_edit.cancel() + state.pending_edit = None + bind_carrier(state, carrier_msg_id, CarrierKind.TEXT) + state.in_menu_view = False + state.last_rendered = "" + _register_msg(user_id, carrier_msg_id, sess.id) + session_manager.set_card_msg(user_id, carrier_msg_id) + text = _legacy("_render_card")(sess, state, user_id=user_id) + keyboard = _legacy("build_footer_keyboard")( + user_id, screen="main", is_busy=_card_is_busy(state) + ) + if await _legacy("_edit_card")( + bot, user_id, state, text=text, reply_markup=keyboard + ): + state.last_rendered = text + state.last_edit_ts = time.monotonic() + # Migrate the switcher pointer onto the new carrier so previous + # switcher rows in chat stop being the canonical surface. + await _strip_stale_switchers(bot, user_id, carrier_msg_id, sess.id) + session_manager.set_last_switcher_msg(user_id, carrier_msg_id) + + +async def restore_card(bot: Bot, user_id: int, sess: Session, card_msg_id: int) -> bool: + """Repaint a persisted live card in place after a bot restart. + + ``_cards`` is in-memory only, so a restart loses every live card's + ``CardState`` and the chat is left with a frozen, orphaned card + message. The card's ``message_id`` is persisted per active session + (``session_manager.card_msg_id``); on startup we rebuild a fresh + ``CardState``, seed the recent transcript from JSONL, and edit the + original message in place so the live card resumes on the same + message instead of a new one appearing on the next event. + + Returns True if the in-place edit landed. On failure (message + deleted by the user, edit rejected) the stale pointer is cleared so + the next claude event spawns a fresh card normally. + """ + existing = _cards.get((user_id, sess.id)) + if existing is not None and existing.msg_id is not None: + # A claude event already raced ahead and established a live card + # for this session — leave it alone rather than fight it. + return True + state = _cards.setdefault((user_id, sess.id), CardState()) + bind_carrier(state, card_msg_id, CarrierKind.TEXT) + state.last_rendered = "" + await _legacy("_ensure_seeded")(user_id, sess, state) + state.turn_phase = TurnPhase.RUNNING if _card_is_busy(state) else TurnPhase.IDLE + _register_msg(user_id, card_msg_id, sess.id) + text = _legacy("_render_card")(sess, state, user_id=user_id) + keyboard = _legacy("build_footer_keyboard")( + user_id, screen="main", is_busy=_card_is_busy(state) + ) + if await _legacy("_edit_card")( + bot, user_id, state, text=text, reply_markup=keyboard + ): + state.last_rendered = text + state.last_edit_ts = time.monotonic() + return True + # The message is gone — drop both the cached state and the persisted + # pointer so the next event creates a fresh card cleanly. + _cards.pop((user_id, sess.id), None) + session_manager.clear_card_msg(user_id) + return False + + +async def clear_card(bot: Bot, user_id: int, sess: Session) -> None: + """Wipe the live card's body in response to a user-driven /clear. + + Edits the existing message to a header-only "(cleared)" snapshot + and keeps an empty, seed-latched state. Dropping the state here would let + ``resume_card_view`` immediately re-seed the old JSONL transcript and make + the cleared Telegram history reappear. + No-op when there is no live card. + """ + state = _cards.get((user_id, sess.id)) + if state is None or state.msg_id is None: + reset_card(user_id, sess.id) + return + state.turn_phase = TurnPhase.IDLE + if state.pending_edit is not None and not state.pending_edit.done(): + state.pending_edit.cancel() + state.pending_edit = None + state.events = [] + state.current_page_idx = None + state.context_pct = 0 + state.is_continuation = False + state.in_menu_view = False + state.kb_prompt = "" + state.kb_ui_name = "" + state.in_kb_mode = False + state.seed_attempted = True + state.seed_mtime = -1.0 + state.stall_watch_active = False + state.last_stall_pane_refresh_ts = 0.0 + state.last_rendered = "" + text = _legacy("_render_card")(sess, state, footer="(cleared)", user_id=user_id) + cleared_kb = _legacy("build_footer_keyboard")(user_id, screen="main", is_busy=False) + await _legacy("_edit_card")(bot, user_id, state, text=text, reply_markup=cleared_kb) diff --git a/src/ccbot/handlers/card_event_render.py b/src/ccbot/handlers/card_event_render.py new file mode 100644 index 00000000..6ad3bdcb --- /dev/null +++ b/src/ccbot/handlers/card_event_render.py @@ -0,0 +1,177 @@ +"""Render individual live-card events and their expandable tool bodies.""" + +from __future__ import annotations + +from .card_budget import _format_elapsed, _format_hhmm, _trimmed_body +from .card_types import Event + +__all__ = [ + "_EXT_TO_LANG", + "_lang_for_path", + "_format_tool_args", + "_format_tool_content", + "_build_tool_spoiler_body", + "_spoiler_body", + "_headed_block", + "render_event", +] + +# File-extension → language hint for syntax-highlighted fenced code +# blocks inside a tool's spoiler body. Telegram's rich-message rendering +# accepts these as the info string after ```` ``` ````. +_EXT_TO_LANG: dict[str, str] = { + "py": "python", + "ts": "typescript", + "tsx": "tsx", + "js": "javascript", + "jsx": "jsx", + "go": "go", + "rs": "rust", + "java": "java", + "kt": "kotlin", + "json": "json", + "yaml": "yaml", + "yml": "yaml", + "toml": "toml", + "md": "markdown", + "sql": "sql", + "html": "html", + "css": "css", + "scss": "scss", + "sh": "bash", + "bash": "bash", + "zsh": "bash", + "c": "c", + "cpp": "cpp", + "h": "c", + "hpp": "cpp", + "rb": "ruby", + "php": "php", + "swift": "swift", + "lua": "lua", + "xml": "xml", +} + + +def _lang_for_path(path: str) -> str: + """Pick a syntax-highlight language hint from a file path's extension. + + Returns an empty string when the extension is unknown / absent — + callers should fall back to a no-language fenced block (still + monospace, just no highlighting). + """ + if not path: + return "" + basename = path.rsplit("/", 1)[-1] + if basename == "Dockerfile" or basename.lower().endswith(".dockerfile"): + return "dockerfile" + if "." not in basename: + return "" + ext = basename.rsplit(".", 1)[-1].lower() + return _EXT_TO_LANG.get(ext, "") + + +def _format_tool_args(tool_name: str, args: str) -> str: + """Wrap a tool's args (command / path / pattern / URL) for visual + contrast inside the spoiler body — Bash gets a ``bash`` fenced + block (syntax highlighting), everything else gets an inline + ``code`` span. + """ + if not args: + return "" + if tool_name == "Bash": + return f"```bash\n{args}\n```" + return f"`{args}`" + + +def _format_tool_content(tool_name: str, args: str, content: str) -> str: + """Wrap a tool's content block (file body / diff) when it's actual + code — Read/Write content gets a fenced block in the file's + language, Edit content gets a ``diff`` block. Bash stdout, Grep + matches, WebFetch / WebSearch text are NOT code and stay plain. + """ + if not content: + return "" + if tool_name in ("Read", "Write"): + lang = _lang_for_path(args) + return f"```{lang}\n{content}\n```" if lang else f"```\n{content}\n```" + if tool_name == "Edit": + return f"```diff\n{content}\n```" + return content + + +def _build_tool_spoiler_body(tool_name: str, args: str, content: str) -> str: + """Assemble the spoiler body for a tool event — args first + (highlighted), then content (highlighted when it's code).""" + parts: list[str] = [] + if args: + parts.append(_format_tool_args(tool_name, args)) + if content: + parts.append(_format_tool_content(tool_name, args, content)) + return "\n".join(parts) + + +def _spoiler_body(body: str) -> str: + """Legacy plain-expandable wrapper kept for callers (tests, the + notifications re-export) that don't pair a head with the body. + + ``render_event`` itself moved to :func:`_headed_block` so the tool + event line becomes the spoiler label instead of sitting on its own + above a plain spoiler. New code shouldn't call this directly. + """ + from ..transcript_format import format_expandable_quote + + trimmed = _trimmed_body(body) + if not trimmed: + return "" + return format_expandable_quote(trimmed) + + +def _headed_block(head: str, body: str) -> str: + """Return ``head`` if there's no body, else wrap ``(head, body)`` + in the ``EXPANDABLE_HEADED`` sentinel so the rich renderer makes + ``head`` the spoiler label and ``body`` the collapsible content + (without repeating the head).""" + from ..transcript_format import format_expandable_with_head + + trimmed = _trimmed_body(body) + if not trimmed: + return head + return format_expandable_with_head(head, trimmed) + + +def render_event(event: Event, *, in_flight: bool, now: float) -> str: + """Render one Event as a plain-text block for the card.""" + # Build the trailing time-or-elapsed marker + if in_flight: + marker = f" · ⏳ {_format_elapsed(now - event.started_at)}" + elif event.type in ("tool_use", "thinking", "text"): + marker = f" · {_format_hhmm(event.started_at)}" + else: + marker = "" + + if event.type == "user_msg": + return f"👤 {event.text}" + + if event.type == "thinking": + return _headed_block(f"∴ thinking{marker}", event.body) + + if event.type == "tool_use": + if event.is_error: + glyph = "✗" + elif in_flight: + glyph = "▷" + else: + glyph = "✓" + return _headed_block(f"{glyph} {event.text}{marker}", event.body) + + if event.type == "tool_result": + # Fallback when the matching tool_use Event isn't found (parser + # race / restart). Render as a standalone row. + return _headed_block(f"✓ {event.text}{marker}", event.body) + + if event.type in ("text", "final_text", "error"): + # Mid-stream / final / error — inline, no glyph. + return event.text + + return event.text diff --git a/src/ccbot/handlers/card_events.py b/src/ccbot/handlers/card_events.py new file mode 100644 index 00000000..8d06e255 --- /dev/null +++ b/src/ccbot/handlers/card_events.py @@ -0,0 +1,128 @@ +"""Convert monitor messages into card events and fold tool results.""" + +from __future__ import annotations + +from ..session_monitor import NewMessage +from .card_event_render import _build_tool_spoiler_body +from .card_text import ( + _extract_expquote_inner, + _parse_timestamp, + _split_tool_text, + _strip_for_card, + _trim, +) +from .card_types import CardState, Event + +__all__ = [ + "_build_event", + "_apply_tool_result", +] + + +def _build_event(msg: NewMessage) -> Event: + """Build an ``Event`` from one ``NewMessage``. + + ``tool_result`` is the only special case: callers should NOT append + the returned Event to the card. Instead they look up the matching + ``tool_use`` Event by ``tool_use_id`` and fold the result in via + ``_apply_tool_result``. We still build a placeholder Event so + callers that find no match (race / restart) can fall back to + appending it. + """ + text = _strip_for_card(msg.text or "") + raw_body = msg.text or "" + started = _parse_timestamp(msg.timestamp) + + if msg.content_type == "thinking": + # Thinking text reaches us already wrapped in EXPQUOTE sentinels + # (transcript_parser → format_expandable_quote). For the card we + # render as plain indented text — pull the inner content out so + # ``_indent_body`` doesn't strip it away as a quote block. + inner = _extract_expquote_inner(raw_body) + body_text = inner if inner else "" + # The placeholder ``(thinking)`` is parser fallback when there's + # no thinking_text — show only the head, no duplicated body row. + if body_text.strip() == "(thinking)": + body_text = "" + return Event( + type="thinking", + text="", # head is just "∴ thinking" — no per-event preview text + body=body_text, + started_at=started, + ) + if msg.content_type == "tool_use": + name, args, _summary, content = _split_tool_text(raw_body) + # Card head shows ONLY the tool name (e.g. "Bash" / "Read") — + # args / content go under the spoiler. Bash args get a fenced + # ``bash`` block, other tools' args land in an inline ``code`` + # span; Read/Write content picks a language from the file + # extension; Edit content goes through ``diff``. + spoiler_body = _build_tool_spoiler_body(name, args, content) + return Event( + type="tool_use", + text=_trim(name, 80), + body=spoiler_body, + started_at=started, + tool_use_id=msg.tool_use_id, + tool_name=msg.tool_name, + ) + if msg.content_type == "tool_result": + name, args, summary, content = _split_tool_text(raw_body) + # Head: just the tool name + summary inline (e.g. "Edit · Added + # 12 lines"). args / content go through the same syntax- + # highlight pipeline as tool_use. + head_with_summary = ( + f"{name} · {summary}" if (name and summary) else name or summary + ) + spoiler_body = _build_tool_spoiler_body(name, args, content) + return Event( + type="tool_result", + text=_trim(head_with_summary, 120), + body=spoiler_body, + started_at=started, + tool_use_id=msg.tool_use_id, + image_data=msg.image_data, + is_error=msg.is_error, + ) + if msg.role == "user": + return Event( + type="user_msg", + text=_trim(text, 200), + body=raw_body, + started_at=started, + ) + is_final = msg.stop_reason in ("end_turn", "stop_sequence", "max_tokens") + # Narrative text events (mid-stream chunks and final answers) render + # ``event.text`` verbatim — don't ``_trim`` them, that would clip the + # answer at 200 chars and flatten newlines. The 200-char ``_trim`` cap + # is only meaningful for one-line summary heads (tool_use / thinking / + # user_msg). + return Event( + type="final_text" if is_final else "text", + text=text, + body=raw_body, + started_at=started, + completed_at=started if is_final else None, + is_page_break=is_final, + ) + + +def _apply_tool_result(state: CardState, result: Event) -> bool: + """Fold a ``tool_result`` Event into the matching ``tool_use``. + + Mutates the tool_use Event in place: ``completed_at``, ``body`` and + ``is_error`` are updated; image_data is carried over for the send + path. Returns True on success, False when no match found (caller + should append ``result`` as-is). + """ + if not result.tool_use_id: + return False + for ev in reversed(state.events): + if ev.type == "tool_use" and ev.tool_use_id == result.tool_use_id: + ev.completed_at = result.started_at + ev.body = result.body or ev.body + ev.text = result.text or ev.text + ev.is_error = result.is_error + ev.image_data = result.image_data + return True + return False diff --git a/src/ccbot/handlers/card_kb_state.py b/src/ccbot/handlers/card_kb_state.py new file mode 100644 index 00000000..93d41a73 --- /dev/null +++ b/src/ccbot/handlers/card_kb_state.py @@ -0,0 +1,178 @@ +"""Enter and leave the interactive keyboard view on a live-card carrier.""" + +from __future__ import annotations + +from __future__ import annotations + +import logging +import time + +from telegram import Bot + +from ..session import Session, session_manager +from .card_model import ( + CardState, +) +from .kb_mode import build_kb_mode_keyboard + +from .card_registry import ( + _cards, + _card_lock, + _legacy, +) + +logger = logging.getLogger(__name__) + + +__all__ = [ + "_inline_screens_enabled", + "has_pending_kb", + "enter_kb_mode", + "exit_kb_mode", + "get_card_state", +] + + +def _inline_screens_enabled(user_id: int | None) -> bool: + """Read the ``card_inline_screenshots`` user-setting (default False).""" + if user_id is None: + return False + settings = session_manager.get_user_settings(user_id) + return bool(settings.get("card_inline_screenshots", False)) + + +def has_pending_kb(user_id: int, session_id: str) -> tuple[bool, bool]: + """Return (has_prompt, in_kb_mode) for the (user, session) card. + + Public alternative to peeking at ``_cards``. ``has_prompt=True`` means + a prompt is pending; ``in_kb_mode`` reflects whether the card msg is + currently displaying kb-mode view vs the regular card. + """ + state = _cards.get((user_id, session_id)) + if state is None: + return False, False + return bool(state.kb_prompt), state.in_kb_mode + + +async def enter_kb_mode( + bot: Bot, + user_id: int, + sess: Session, + prompt_content: str, + ui_name: str, +) -> None: + """Flip the active session's card msg into kb-mode view. + + Edits the existing card msg (or creates one if missing) so its body + shows the prompt content and its keyboard is the kb-mode 3×3 grid + + [Back][+ new][≡ Menu]. State is marked ``in_kb_mode=True`` and + ``kb_prompt`` snapshot so subsequent paints stay consistent. + + No-op if state is already in kb-mode with the same prompt — avoids + pointless edits when status_polling re-detects the prompt each poll. + """ + state = get_card_state(user_id, sess) + # Short-circuit ONLY when the kb-mode card is actually present in + # chat. After ``close_card_view`` (Shot tap) ``msg_id`` is None but + # ``in_kb_mode`` stays True — without the ``msg_id is not None`` + # check, subsequent status_polling re-detections of the same UI + # would no-op and the kb-mode card would never be re-spawned. + if ( + state.in_kb_mode + and state.kb_prompt == prompt_content + and state.msg_id is not None + ): + return + state.kb_prompt = prompt_content + state.kb_ui_name = ui_name + state.in_kb_mode = True + # kb-mode is an interrupt: claude is BLOCKED waiting for the user's + # answer. If the user happens to be on Menu / List / Settings / + # History on the same carrier (``in_menu_view=True``), ``_edit_card`` + # would short-circuit and the kb keyboard would never surface — the + # user only saw it appear after tapping Shot, which dropped + # ``msg_id=None`` and re-spawned a fresh card via ``_send_card``. + # Clearing the flag here lets ``_edit_card`` repaint the carrier + # with the kb prompt; the menu navigation is preempted because the + # session can't proceed without the user's input anyway. + state.in_menu_view = False + if not sess.window_id: + return + text = _legacy("_render_card")(sess, state, user_id=user_id) + kb = build_kb_mode_keyboard(user_id, sess.window_id, ui_name=ui_name) + # Spawn-serialization (Task #50): a parallel ``update_session_card`` + # could otherwise observe ``msg_id is None`` during ``_send_card`` + # and spawn its own card too. + async with _card_lock(user_id, sess.id): + if state.msg_id is None: + await _legacy("_send_card")( + bot, user_id, sess, state, text=text, reply_markup=kb + ) + else: + await _legacy("_edit_card")(bot, user_id, state, text=text, reply_markup=kb) + state.last_rendered = text + state.last_edit_ts = time.monotonic() + logger.info( + "kb_mode entered user=%d sess=%s ui=%s prompt_len=%d", + user_id, + sess.id, + ui_name, + len(prompt_content), + extra={ + "event": "kb_mode_entered", + "user_id": user_id, + "session_id": sess.id, + "ui_name": ui_name, + "prompt_len": len(prompt_content), + }, + ) + + +async def exit_kb_mode( + bot: Bot, + user_id: int, + sess: Session, + *, + clear_pending: bool = False, +) -> None: + """Flip the card back from kb-mode to regular view. + + ``clear_pending=False`` (default) — user tapped Back. ``kb_prompt`` + is KEPT so the Resume button shows up in the footer. Tapping Resume + re-enters kb-mode with the same prompt. + + ``clear_pending=True`` — claude moved past the prompt (terminal_parser + no longer detects it, after double-poll confirm) OR user explicitly + acted via a kb key. Wipe both ``in_kb_mode`` and ``kb_prompt`` so + the Resume button disappears. + """ + state = _cards.get((user_id, sess.id)) + if state is None: + return + was_in_kb = state.in_kb_mode + state.in_kb_mode = False + if clear_pending: + state.kb_prompt = "" + state.kb_ui_name = "" + if state.msg_id is None or not was_in_kb: + return + text = _legacy("_render_card")(sess, state, user_id=user_id) + if await _legacy("_edit_card")(bot, user_id, state, text=text): + state.last_rendered = text + state.last_edit_ts = time.monotonic() + logger.info( + "kb_mode exited user=%d sess=%s cleared=%s", + user_id, + sess.id, + clear_pending, + extra={ + "event": "kb_mode_exited", + "user_id": user_id, + "session_id": sess.id, + "clear_pending": clear_pending, + }, + ) + + +def get_card_state(user_id: int, sess: Session) -> CardState: + return _cards.setdefault((user_id, sess.id), CardState()) diff --git a/src/ccbot/handlers/card_layout.py b/src/ccbot/handlers/card_layout.py new file mode 100644 index 00000000..a8a04026 --- /dev/null +++ b/src/ccbot/handlers/card_layout.py @@ -0,0 +1,313 @@ +"""Compose the complete live-card body, prompt view, footer, and status panel.""" + +from __future__ import annotations + +import re +import time + +from ..i18n import t +from ..session import Session +from . import bg_status +from .card_budget import _format_hhmmss +from .card_event_render import render_event +from .card_pagination import ( + _EVENT_JOINER, + _rechunk_oversized_finals_inplace, + _resolve_line_budget, + _resolved_page_idx, + _trim_page_events, + paginate_events_for_card, + render_page, +) +from .card_types import CardState, Event +from .switcher import session_emoji + +__all__ = [ + "_BOX_DRAWING_RE", + "_BORDER_ONLY_LINE_RE", + "_BOX_FRAME_RE", + "_NUMBERED_OPTION_RE", + "_RULE_LINE_RE", + "_KB_BLOCK_SEPARATOR", + "_KB_PARAGRAPH_SEPARATOR", + "_KB_HARD_BREAK_JOIN", + "_format_kb_prompt", + "_rule_between_options", + "_sanitize_prompt_block", + "_render_card", +] + +# ─── Card composition ───────────────────────────────────────────────── + + +# Box-drawing / block-element glyphs (U+2500–U+259F). Claude Code's +# AskUserQuestion renders each option's ``preview`` inside a box-drawing +# frame (``┌ │ ├ ─ …``); captured verbatim into the kb-mode card those +# borders mangle the body. We strip them on the kb-mode path. +_BOX_DRAWING_RE = re.compile(r"[─-▟]") +_BORDER_ONLY_LINE_RE = re.compile(r"^[\s─-▟]*$") +# Box-drawing FRAME glyphs (verticals + corners + junctions + double-line), +# EXCLUDING the plain horizontals ─ ━ which show up as benign dividers in +# otherwise-normal prompts. Their presence is the signal that Claude Code +# framed the option previews in boxes (the case that mangles the card). A +# normal prompt — even one carrying a ── divider — matches none of these, so +# the sanitize/code-fence path stays a strict no-op for the well-behaved case. +_BOX_FRAME_RE = re.compile(r"[│┃┌-╋═-╬]") + +# Numbered-option row in an AskUserQuestion / picker pane. Tolerates the +# common cursor markers (``> `` / ``❯ ``) and arbitrary leading whitespace +# (Claude Code uses `` 2.`` to align the non-cursor rows under the +# cursor row). Anchors to the start of the line so prose like +# ``Step 1.`` never trips it. +_NUMBERED_OPTION_RE = re.compile(r"^\s*[>❯▶]?\s*\d+\.\s") + +# Source-level horizontal rule the generated ``─────`` separators +# already cover. Drop these during formatting so a verbatim divider +# line in the pane (e.g. NORMAL_PROMPT in the regression tests) doesn't +# show up as ``───── ───── ─────`` once we add ours. +_RULE_LINE_RE = re.compile(r"^[─\-=_]{3,}$") + + +_KB_BLOCK_SEPARATOR = "\n\n─────\n\n" +_KB_PARAGRAPH_SEPARATOR = "\n\n" +_KB_HARD_BREAK_JOIN = " \n" + + +def _format_kb_prompt(raw: str) -> str: + """Render a captured AskUserQuestion / picker pane as kb-mode body. + + Numbered options (``1. Foo``, ``❯ 2. Bar``) become their own blocks + separated by a ``─────`` rule — same archive-style split used in + ``handlers/archive.py`` for the session list. The header / hint + chrome around the options keeps a hard-break join so wrapping prose + stays readable on the phone. + + Pure prompts with no numbered options (ExitPlanMode, plain + confirmations) fall back to the cheap paragraph+hard-break join — + no spurious dividers. + """ + paragraphs: list[list[str]] = [] + current: list[str] = [] + + def _flush() -> None: + if current: + paragraphs.append(current.copy()) + current.clear() + + for line in raw.splitlines(): + stripped = line.strip() + if not stripped: + _flush() + continue + if _RULE_LINE_RE.match(stripped): + # Source rule lines are absorbed by the generated dividers + # — surviving as content would double up the separator. + _flush() + continue + current.append(line) + _flush() + + if not paragraphs: + return "" + + # Re-split each paragraph at numbered-option boundaries so every + # option ends up as its own block (and gets its own ─── divider). + refined: list[list[str]] = [] + for para in paragraphs: + buf: list[str] = [] + for line in para: + if _NUMBERED_OPTION_RE.match(line): + if buf: + refined.append(buf) + buf = [] + refined.append([line]) + else: + buf.append(line) + if buf: + refined.append(buf) + + has_options = any(_NUMBERED_OPTION_RE.match(p[0]) for p in refined if p) + sep = _KB_BLOCK_SEPARATOR if has_options else _KB_PARAGRAPH_SEPARATOR + return sep.join(_KB_HARD_BREAK_JOIN.join(p) for p in refined) + + +def _rule_between_options(body: str) -> str: + """Splice a ``─────`` rule before each numbered option (after the first). + + Used by the box-frame branch of ``_render_card``. That branch renders + the de-framed prompt inside a code fence, which suppresses MarkdownV2 — + so options can't be separated by markup the way the frameless path does. + Instead we splice literal ``─────`` rule lines between options; inside + the fence they render as plain monospace dividers, giving the same + archive-style separation without re-introducing the blockquote-collapse + the fence exists to prevent. + + Each option keeps its trailing preview/description lines (they ride with + the option until the next numbered row). Pre-existing source rule lines + are dropped so separators never double up. + """ + out: list[str] = [] + seen_option = False + for line in body.splitlines(): + if _RULE_LINE_RE.match(line.strip()): + continue # absorbed by the generated rules + if _NUMBERED_OPTION_RE.match(line): + if seen_option: + out.append("─────") + seen_option = True + out.append(line) + return "\n".join(out) + + +def _sanitize_prompt_block(text: str) -> str: + """Strip terminal box-drawing borders from a captured interactive prompt. + + Drops border-only lines and removes box-drawing glyphs from content + lines (preserving indentation + internal spacing). Collapses 3+ blank + lines that the border removal can leave behind. + """ + out: list[str] = [] + for line in text.splitlines(): + if _BORDER_ONLY_LINE_RE.match(line): + # Keep a single blank as a paragraph break, drop runs. + if out and out[-1] != "": + out.append("") + continue + cleaned = _BOX_DRAWING_RE.sub("", line).rstrip() + out.append(cleaned) + while out and out[0] == "": + out.pop(0) + while out and out[-1] == "": + out.pop() + return "\n".join(out) + + +def _render_card( + sess: Session, + state: CardState, + *, + footer: str = "", + user_id: int | None = None, +) -> str: + emoji = session_emoji(sess) + state_label = sess.dir_label if sess.state == "active" else sess.state + cont_marker = " · …continued" if state.is_continuation else "" + # Last-event timestamp in the header — HH:MM:SS of the most recent + # event of any kind (per-event timestamps inside the body stay HH:MM). + ts_suffix = "" + if state.last_event_ts > 0: + ts_suffix = " · " + _format_hhmmss(state.last_event_ts) + name_part = sess.name or sess.id + header = f"{emoji} *{name_part}* · {state_label}{cont_marker}{ts_suffix}" + if sess.goal: + header += f"\ngoal: {sess.goal}" + + # kb-mode view: card msg shows the interactive prompt content + kb + # keyboard. The regular event log is BELOW the keyboard (footer'd by + # the keyboard rather than by switcher/pagination). See Task #41. + if state.in_kb_mode and state.kb_prompt: + raw = state.kb_prompt + if _BOX_FRAME_RE.search(raw): + # Claude Code framed the option previews in box-drawing boxes + # (┌ │ ├ …). Captured verbatim those borders mangle the body and + # telegramify collapses the long region into an expandable + # blockquote (the "✂ N lines hidden" artifact). Strip the borders + # and render as a fenced code block — literal monospace, no + # MarkdownV2 escaping, no blockquote collapse. Guard a stray ```. + body = _sanitize_prompt_block(raw) + # Splice ───── rules between numbered options so they're visibly + # separated inside the fence (which suppresses MarkdownV2, so the + # frameless path's markup dividers can't apply here). + body = _rule_between_options(body) + prompt_part = body if "```" in body else f"```\n{body}\n```" + else: + # Format pane lines into explicit blocks: each numbered + # option ("1. Foo", "❯ 2. Bar") gets its own ─── divider + # — same archive-style split as ``handlers/archive.py``'s + # session list. Non-option prompts (ExitPlanMode and the + # like) fall back to hard-break-only join so wrapping + # prose stays one paragraph. See ``_format_kb_prompt``. + prompt_part = _format_kb_prompt(raw) + parts = [header, "─────", "⌨ *Waiting for your input:*", prompt_part] + # Paragraph-break join (same trap the bottom of this function + # already handles): single ``\n`` between header / separator / + # title would let the rich parser glue them onto one line. + rendered = "\n\n".join(parts) + state.media_anchor_offset = len(rendered) + return rendered + + # Budget is in LINES (per user setting ``card_page_lines``). + line_budget = _resolve_line_budget(user_id) + # Lazy re-chunk: if any final_text Event in state.events exceeds + # the CURRENT budget (e.g. user just lowered Settings → Page size, + # or budget changed since finalize_task), split it into multiple + # final_text Events on the fly. This is what makes the budget + # ULTIMATIVE per spec — even already-finalised answers get rebuilt + # to fit the new size. Idempotent: chunks below budget stay intact. + _rechunk_oversized_finals_inplace(state, line_budget) + + pages = paginate_events_for_card(state, user_id) + idx = _resolved_page_idx(state, len(pages)) + + # Optional bg-panel always lives at the bottom. + panel = "" + if user_id is not None: + panel = bg_status.render_panel(user_id, active_session_id=sess.id) + + # Safety net: a sub-page should fit by construction, but a single + # huge event (one tool_result well over budget) can still overflow + # — and we can't split it (EXPQUOTE atomicity). When that happens + # _trim_page_events keeps anchor + tail; the dropped events become + # genuinely inaccessible (no prior sub-page covers them), so the + # marker phrasing acknowledges that. + page_events = _trim_page_events(pages[idx], line_budget) + body = render_page(page_events, now=time.time()) + if len(page_events) < len(pages[idx]): + dropped = len(pages[idx]) - len(page_events) + body = f"… (+{dropped} events trimmed to fit)\n{body}" + if state.voice_pending: + pending_row = render_event( + Event( + type="user_msg", + text=t(user_id or 0, "voice.transcribing"), + started_at=time.time(), + ), + in_flight=False, + now=time.time(), + ) + body = _EVENT_JOINER.join(part for part in (body, pending_row) if part) + + parts = [header, "─────"] + if body: + parts.append(body) + if footer: + parts.append("─────") + parts.append(footer) + # Everything appended after this point is service metadata. Record the + # exact raw-text boundary so rich-media transport can place the terminal + # screenshot before ``context`` and the background-session panel without + # searching for localized/rendered labels. + state.media_anchor_offset = len("\n\n".join(parts)) + # Active session's own context-fill — single line at the very + # bottom of the card body, just above the bg-status panel. + # See ``set_card_context_pct``. + if state.context_pct is not None: + # Same `` ``-paragraph trick used by ``_EVENT_JOINER``: + # CommonMark collapses consecutive blank lines into one + # paragraph break, but a paragraph that contains a + # non-breaking space survives — visibly DOUBLES the gap above + # the ``context: N%`` row so it doesn't read glued onto the + # last body event. + parts.append("\u00a0") + parts.append(f"context: {state.context_pct}%") + if panel: + # The panel carries its own ``─── фон ───`` label-separator + # (pivot #39 feedback: previously the bg-row glued to the last + # body line). Same nbsp-paragraph trick to widen the gap. + parts.append("\u00a0") + parts.append(panel) + # Paragraph-break join (``\n\n``) — single ``\n`` is a CommonMark + # soft break that the rich parser collapses to a space, glueing + # ``header ───── body ───── footer`` onto one row instead of each + # on its own line. Same trap we hit in /archive and the bg-panel. + return "\n\n".join(parts) diff --git a/src/ccbot/handlers/card_model.py b/src/ccbot/handlers/card_model.py index 7434f73a..3386f98f 100644 --- a/src/ccbot/handlers/card_model.py +++ b/src/ccbot/handlers/card_model.py @@ -1,46 +1,95 @@ -"""Pure model + render layer for the live card. +"""Compatibility facade for the decomposed live-card model. -This module holds the stateless, side-effect-free building blocks behind -``handlers.notifications``: the ``Event`` / ``CardState`` dataclasses, the -event builders (``_build_event`` / ``_apply_tool_result``), the -MarkdownV2-size estimators, and every ``render_*`` / ``paginate_*`` / -budget-trimming helper that turns a ``CardState`` into the text painted on -a Telegram message. - -Nothing here touches the module-global card registries -(``_cards`` / ``_card_locks`` / ``_repost_intent`` / ``_msg_to_session``) -or sends / edits Telegram messages — those live in -``handlers.notifications`` (the lifecycle / facade module), which -re-exports every name defined here so existing -``from ccbot.handlers.notifications import X`` and ``notifications.X`` -call sites keep resolving unchanged. - -A "card" is a single Telegram message that the bot keeps editMessageText- -updating as Claude emits tool calls, thinking blocks, and text chunks. -Within a single card, content is paginated. Each ``Event`` with -``is_page_break=True`` (currently end_turn assistant text) becomes the -top of a new page; everything preceding it goes on the previous page. -Default focus = the page anchored to the latest answer. +Implementation is split by reason to change: state, transcript event parsing, +text budgets, event rendering, pagination, and complete-card layout. """ -from __future__ import annotations - -import asyncio -import re -import time -from dataclasses import dataclass, field - -from ..i18n import t -from ..session import Session, session_manager -from ..session_monitor import NewMessage -from . import bg_status -from .switcher import session_emoji +from .card_types import ( + CARD_HARD_LIMIT, + CARD_MAX_EVENTS, + STALE_CARD_SECONDS, + SPOILER_MAX_LINES, + CARD_PAGE_BUDGET, + CARD_PAGE_LINES_DEFAULT, + CARD_PAGE_LINES_OVERSHOOT, + CARD_SEED_TURNS, + Event, + CardState, + CarrierKind, + TurnPhase, +) +from .card_text import ( + _trim, + _EXPQUOTE_BLOCK_RE, + _EXPQUOTE_ANY_RE, + _EXPQUOTE_INNER_RE, + _extract_expquote_inner, + _strip_for_card, + _parse_timestamp, + _TOOL_HEAD_RE, + _split_tool_text, +) +from .card_budget import ( + _format_elapsed, + _format_hhmm, + _format_hhmmss, + _is_in_flight, + _body_trim, + _SENTENCE_END_RE, + _table_continuation_prefix, + _chunk_final_text, + _trimmed_body, + _count_lines, + _MD_V2_ESCAPE_CHARS, + _estimate_md_v2_size, + _char_pos_at_byte_budget, +) +from .card_event_render import ( + _EXT_TO_LANG, + _lang_for_path, + _format_tool_args, + _format_tool_content, + _build_tool_spoiler_body, + _spoiler_body, + _headed_block, + render_event, +) +from .card_events import ( + _build_event, + _apply_tool_result, +) +from .card_pagination import ( + paginate_events, + _EVENT_JOINER, + _JOINER_LINES, + _split_page_by_budget, + paginate_events_for_card, + _resolved_page_idx, + render_page, + _rechunk_oversized_finals_inplace, + _resolve_line_budget, + _trim_page_events, + card_page_info, + _is_stale, + _duplicate_of_seeded, + _card_is_busy, + _latest_inflight_idx, +) +from .card_layout import ( + _BOX_DRAWING_RE, + _BORDER_ONLY_LINE_RE, + _BOX_FRAME_RE, + _NUMBERED_OPTION_RE, + _RULE_LINE_RE, + _KB_BLOCK_SEPARATOR, + _KB_PARAGRAPH_SEPARATOR, + _KB_HARD_BREAK_JOIN, + _format_kb_prompt, + _rule_between_options, + _sanitize_prompt_block, + _render_card, +) -# Listing the underscore-prefixed helpers in ``__all__`` marks them as -# the module's intended public interface so pyright's strict -# ``reportPrivateUsage`` doesn't flag the facade re-exports in -# ``handlers.notifications`` (which must keep these names importable as -# ``notifications.`` for existing callers and the test suite). __all__ = [ "CARD_HARD_LIMIT", "CARD_MAX_EVENTS", @@ -51,7 +100,9 @@ "SPOILER_MAX_LINES", "STALE_CARD_SECONDS", "CardState", + "CarrierKind", "Event", + "TurnPhase", "_apply_tool_result", "_build_event", "_card_is_busy", @@ -79,1428 +130,36 @@ "paginate_events_for_card", "render_event", "render_page", + "_EXPQUOTE_BLOCK_RE", + "_EXPQUOTE_ANY_RE", + "_EXPQUOTE_INNER_RE", + "_parse_timestamp", + "_TOOL_HEAD_RE", + "_format_elapsed", + "_format_hhmm", + "_body_trim", + "_SENTENCE_END_RE", + "_table_continuation_prefix", + "_trimmed_body", + "_MD_V2_ESCAPE_CHARS", + "_char_pos_at_byte_budget", + "_EXT_TO_LANG", + "_lang_for_path", + "_format_tool_args", + "_format_tool_content", + "_build_tool_spoiler_body", + "_headed_block", + "_EVENT_JOINER", + "_JOINER_LINES", + "_BOX_DRAWING_RE", + "_BORDER_ONLY_LINE_RE", + "_BOX_FRAME_RE", + "_NUMBERED_OPTION_RE", + "_RULE_LINE_RE", + "_KB_BLOCK_SEPARATOR", + "_KB_PARAGRAPH_SEPARATOR", + "_KB_HARD_BREAK_JOIN", + "_format_kb_prompt", + "_rule_between_options", + "_sanitize_prompt_block", ] - -# Hard cap for rendered card text — Telegram limit is 4096; leave headroom. -CARD_HARD_LIMIT = 3800 -# Number of accumulated events kept; older events still live in state.events -# but only the last N participate in pagination (FIFO eviction beyond this). -CARD_MAX_EVENTS = 5000 -# After this much idleness, the next event opens a fresh card. -STALE_CARD_SECONDS = 5 * 60 -# Max lines of body shown inside each tool/thinking spoiler. Overflow is -# truncated with a "… (+N more lines)" trailer. Env-tunable. -SPOILER_MAX_LINES = 5 - -# Char budget for one rendered card page — kept as a hard ceiling for -# the Telegram-level 4096-char limit. Headroom for header / divider / -# bg-panel. The user-facing budget is in LINES (see ``card_page_lines`` -# user-setting / ``_resolve_line_budget``); chars budget here is only -# a sanity-cap when the page-by-lines result would still overflow TG. -CARD_PAGE_BUDGET = 3500 - -# Default page-size budget in LINES (logical \n-delimited rows in the -# MarkdownV2 source — close enough to visual lines on a phone for ±5 -# tolerance the user explicitly accepted). User overrides via -# Settings → Page size (10 / 20 / 40 / 70). -CARD_PAGE_LINES_DEFAULT = 20 - -# Allowed overshoot (in lines) when trimming a page or chunking an -# anchor so a sentence / paragraph isn't broken mid-content. -CARD_PAGE_LINES_OVERSHOOT = 5 - -# Number of trailing end_turn boundaries to pull from JSONL when seeding -# an empty ``state.events`` (e.g. after a bot restart). Each end_turn -# becomes a page boundary, so this caps the "scrollback depth" of the -# card without re-reading the full transcript on every event. -CARD_SEED_TURNS = 20 - - -@dataclass -class Event: - """One unit of conversation rendered on the card. - - ``type`` discriminates render behaviour: - - - ``user_msg`` — user's typed text echoed via ``👤`` - - ``thinking`` — claude thinking block (``∴``) - - ``tool_use`` — tool invocation (``▷``); on tool_result the - same Event's ``completed_at`` flips and ``body`` becomes the - result text. ``tool_use_id`` matches assistant→user pairing. - - ``text`` — mid-stream assistant text (stop_reason=tool_use) - - ``final_text`` — end-of-turn assistant answer; ``is_page_break`` - - ``error`` — error-only event; ``is_page_break`` - - ``interactive``— AskUserQuestion / ExitPlanMode / Permission; - rendered as a separate Telegram message, NOT in card body, but - recorded here for page-break anchoring. - - ``divider`` — historical "Результат" divider line; legacy - """ - - type: str - text: str # one-line header content (args summary / first line) - started_at: float # epoch seconds; HH:MM in header is derived from this - body: str = "" # full content under expandable blockquote - completed_at: float | None = None # set when event completes - tool_use_id: str | None = None - tool_name: str | None = None - is_page_break: bool = False # this event starts a new page - is_error: bool = False - image_data: list[tuple[str, bytes]] | None = None # tool_result images - - -@dataclass -class CardState: - msg_id: int | None = None - events: list[Event] = field(default_factory=list) - # Page the user is currently looking at. ``None`` = default focus - # (page with the latest answer-anchor). Set by pagination callbacks. - current_page_idx: int | None = None - last_event_ts: float = 0.0 - last_rendered: str = "" # last text we sent to TG; skips no-op edits - last_edit_ts: float = 0.0 # monotonic seconds; gate for CARD_EDIT_LAG coalescing - pending_edit: asyncio.Task[None] | None = None # one deferred edit task at most - is_continuation: bool = False # True after a stale-pause or overflow split - # User opened ≡ Menu / a sub-screen on the card's message. While set, - # session updates accumulate into ``events`` but are NOT rendered to - # Telegram — otherwise the next event would overwrite whatever menu - # screen the user is looking at. Cleared by ``resume_card_view`` - # (called from text_handler when the user types) or implicitly - # when the card is reset. - in_menu_view: bool = False - # kb-mode auto-persistence (Task #41). When claude shows an - # interactive prompt (AskUserQuestion / ExitPlanMode / Permission), - # the card msg is EDITED in place to show the prompt content + kb - # navigation keyboard (3×3 grid). One msg per session — no separate - # push. State machine: - # kb_prompt non-empty + in_kb_mode=True → card msg = kb-mode view - # kb_prompt non-empty + in_kb_mode=False → user tapped Back; card - # shows regular view but with [🔙 Resume action] on Shot slot - # kb_prompt empty → no pending action - kb_prompt: str = "" # current prompt content (snapshot from pane) - kb_ui_name: str = "" # AskUserQuestion / ExitPlanMode / Permission - in_kb_mode: bool = False - # Inline-screenshots mode (Task #48). When the user has - # ``card_inline_screenshots=True`` and the active session is this - # one, the card msg is a photo+caption Telegram message (the photo - # is a render of the tmux pane). On toggle off, the msg_id is reset - # so the next event creates a fresh text-mode card. - is_photo_msg: bool = False - last_pane_hash: str = "" # md5 of last captured pane text - last_photo_edit_ts: float = 0.0 # monotonic seconds; 3s throttle - # Cached context-window fill percentage for the active session, set by - # session_events whenever a new assistant turn lands. Rendered as a - # ``context: N%`` line above the bg-status panel. None = unknown. - context_pct: int | None = None - # JSONL-seed bookkeeping (A6). ``_ensure_seeded`` reads the recent - # transcript exactly once per (re)set so the live card lands with - # context after a restart. The wipe sites that empty ``events`` mid- - # session for a NON-destructive reason (stale-pause reset, carrier - # release on switcher tap) clear this flag so the next event re-seeds - # — otherwise the card rebuilds one event at a time and the footer - # page counter transiently collapses to ``1/1`` while the underlying - # transcript still spans many turn-pages. ``/clear`` leaves it True: - # that is an intentional wipe-to-zero. - seed_attempted: bool = False - # Transcript mtime (epoch seconds) at the last *empty* seed attempt, or - # -1.0 if never attempted. A freshly restored (``claude --resume``) - # session creates its card before claude has flushed the resumed - # transcript, so an early seed reads [] and must retry on a later event. - # ``_ensure_seeded`` only re-parses the (possibly multi-MB) JSONL once - # this advances, so a burst of events during the resume window costs one - # stat() each, not a full re-parse. Reset alongside ``seed_attempted`` - # at the non-destructive re-seed sites. - seed_mtime: float = -1.0 - # Stall-recovery flag. Set by ``maybe_finalize_stalled`` after it - # appends the STALL_NOTE final_text. If the stall was a false positive - # (a genuine assistant turn arrives after), the next - # ``update_session_card`` / ``finalize_task`` wipes the card binding - # and lets ``_send_card`` spawn a fresh message below the stalled - # stub — so the recovered answer is visible instead of being silently - # edited into a card the user has scrolled past or marked complete. - stall_finalized: bool = False - # Set by voice_handler right when a voice message is pinned to this - # session, before download/transcribe (which can take many seconds). - # Rendered as a synthetic trailing ``user_msg`` row so an immediate - # repost_card shows "yes, your voice landed here" in the same place - # typed prompts appear, rather than adding transient state to the card - # header. Cleared once the transcribed text is actually dispatched (or - # transcription fails). - voice_pending: bool = False - - -def _trim(s: str, limit: int = 200) -> str: - s = s.replace("\n", " ").strip() - if len(s) > limit: - return s[: limit - 1] + "…" - return s - - -_EXPQUOTE_BLOCK_RE = re.compile( - r"\x02EXPQUOTE_START\x02.*?\x02EXPQUOTE_END\x02", - re.DOTALL, -) -# Drop residual EXPQUOTE_START / EXPQUOTE_END sentinels that didn't -# pair up (transcript_format builds tool blocks with nested sentinels; -# the outer pair gets stripped but the inner one can leak in body). -_EXPQUOTE_ANY_RE = re.compile(r"\x02EXPQUOTE_(?:START|END)\x02") -# Pull the inner content out of an EXPQUOTE_START / END pair. -_EXPQUOTE_INNER_RE = re.compile( - r"\x02EXPQUOTE_START\x02(.*?)\x02EXPQUOTE_END\x02", - re.DOTALL, -) - - -def _extract_expquote_inner(text: str) -> str: - """Return the content between the FIRST EXPQUOTE_START / END pair.""" - m = _EXPQUOTE_INNER_RE.search(text or "") - return m.group(1) if m else "" - - -def _strip_for_card(text: str) -> str: - """Strip residue that would render literally in MarkdownV2 mode. - - Card text is now sent with ``parse_mode=MarkdownV2`` (via - ``send_with_fallback`` / ``_send_card_md``), so MarkdownV2 markers - like ``**bold**`` get rendered properly. We only strip: - - * The full ``EXPQUOTE_START … EXPQUOTE_END`` block when it appears - INSIDE a head line (heads are one-liners; the embedded quote - belongs in the body, not the head). - * Any orphan ``EXPQUOTE_*`` sentinel that escaped pair-matching. - * ``$HOME`` → ``~`` so long Mac paths don't waste 30+ chars. - - The MarkdownV2 ``convert_markdown`` step inside ``send_with_fallback`` - handles escaping special chars and expanding paired EXPQUOTE blocks - into expandable blockquote syntax. - """ - import os - - out = _EXPQUOTE_BLOCK_RE.sub("", text) - out = _EXPQUOTE_ANY_RE.sub("", out) - home = os.path.expanduser("~") - if home and home != "/": - out = out.replace(home, "~") - return out - - -def _parse_timestamp(ts: str) -> float: - """Parse ISO-8601 timestamp from a JSONL entry into epoch seconds. - - Returns ``time.time()`` when the input is empty or unparseable so - callers can use the result unconditionally as an ``started_at``. - """ - if not ts: - return time.time() - try: - import datetime as _dt - - # Tolerate trailing Z + offset forms; fromisoformat handles "+HH:MM" - # natively but historically chokes on "Z". - return _dt.datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp() - except (ValueError, TypeError): - return time.time() - - -_TOOL_HEAD_RE = re.compile( - r"^\s*\**(?P[A-Za-z][\w-]*)\**\s*\((?P.*)\)\s*$", re.DOTALL -) - - -def _split_tool_text(raw: str) -> tuple[str, str, str, str]: - """Split transcript_format's tool text into name / args / summary / content. - - ``raw`` reaches us in the shape:: - - **ToolName**(args) ← head_block (can span multiple - lines if args = bash heredoc / - multi-line edit diff) - ⎿ Output N lines ← summary line (optional) - \\x02EXPQUOTE_START\\x02\\x02EXPQUOTE_END\\x02 - - The summary line starts with whitespace + ``⎿``. Everything before - that marker (or before the EXPQUOTE_START sentinel, whichever comes - first) is the head_block — possibly multiple lines when args - contains literal newlines (bash heredoc). - - Returns ``(name, args, summary, content)`` where: - - * ``name`` = bare tool name (``Bash``, ``Read``, ``Edit``). - * ``args`` = whatever was between the outermost parens — pushed - under the spoiler so long commands don't blow up the head line. - * ``summary`` = ``⎿`` line content (``Output 5 lines``). - * ``content`` = inside the EXPQUOTE block, minus duplicate head / - summary lines that transcript_parser sometimes re-embeds. - - When the head doesn't parse as ``Name(args)`` (orphan tool_result - fallback or weird format), the full head_block lands in ``name`` - and ``args`` is empty. - """ - if not raw: - return "", "", "", "" - - # Locate end-of-head: the first ``\n ⎿`` summary marker OR the - # first ``\x02EXPQUOTE_START\x02`` content marker, whichever comes - # earlier. Whatever's BEFORE that boundary is the head_block (may - # span multiple lines when args is a bash heredoc). - summary_marker_re = re.compile(r"\n\s*⎿") - summary_match = summary_marker_re.search(raw) - quote_idx = raw.find("\x02EXPQUOTE_START\x02") - head_end = len(raw) - if summary_match is not None: - head_end = min(head_end, summary_match.start()) - if quote_idx >= 0: - head_end = min(head_end, quote_idx) - head_block = raw[:head_end].rstrip("\n") - - name = _strip_for_card(head_block) - args = "" - m = _TOOL_HEAD_RE.match(head_block) - if m: - name = m.group("name").strip() - args = m.group("args").strip() - # The first-line ``Name(`` prefix being matched means the regex - # already used DOTALL — args may legitimately contain newlines. - - summary = "" - after_head = raw[head_end:] - if after_head.startswith("\n"): - after_head = after_head[1:] - # Pull the summary line if it's first. - if after_head.lstrip(" ").startswith("⎿"): - nl = after_head.find("\n") - if nl == -1: - summary_line = after_head - after_head = "" - else: - summary_line = after_head[:nl] - after_head = after_head[nl + 1 :] - summary = _strip_for_card(summary_line.lstrip(" ").lstrip("⎿").strip()) - - # ``after_head`` is now either an EXPQUOTE block or plain rest. - inner = _extract_expquote_inner(after_head) if after_head else "" - content = inner if inner else after_head - # Drop duplicate head/summary rows that transcript_parser may - # re-embed at the top of the EXPQUOTE block. - if content: - content_lines = content.split("\n") - first_norm = _strip_for_card(content_lines[0]).strip() - head_norm = _strip_for_card(head_block).strip() - if ( - first_norm == head_norm - or (head_norm and first_norm.endswith(head_norm)) - or ( - first_norm.startswith(("✓ ", "▷ ", "✗ ")) - and head_norm - and head_norm in first_norm - ) - ): - content_lines = content_lines[1:] - if content_lines and content_lines[0].lstrip().startswith("⎿"): - content_lines = content_lines[1:] - content = "\n".join(content_lines).strip("\n") - return name, args, summary, content - - -def _build_event(msg: NewMessage) -> Event: - """Build an ``Event`` from one ``NewMessage``. - - ``tool_result`` is the only special case: callers should NOT append - the returned Event to the card. Instead they look up the matching - ``tool_use`` Event by ``tool_use_id`` and fold the result in via - ``_apply_tool_result``. We still build a placeholder Event so - callers that find no match (race / restart) can fall back to - appending it. - """ - text = _strip_for_card(msg.text or "") - raw_body = msg.text or "" - started = _parse_timestamp(msg.timestamp) - - if msg.content_type == "thinking": - # Thinking text reaches us already wrapped in EXPQUOTE sentinels - # (transcript_parser → format_expandable_quote). For the card we - # render as plain indented text — pull the inner content out so - # ``_indent_body`` doesn't strip it away as a quote block. - inner = _extract_expquote_inner(raw_body) - body_text = inner if inner else "" - # The placeholder ``(thinking)`` is parser fallback when there's - # no thinking_text — show only the head, no duplicated body row. - if body_text.strip() == "(thinking)": - body_text = "" - return Event( - type="thinking", - text="", # head is just "∴ thinking" — no per-event preview text - body=body_text, - started_at=started, - ) - if msg.content_type == "tool_use": - name, args, _summary, content = _split_tool_text(raw_body) - # Card head shows ONLY the tool name (e.g. "Bash" / "Read") — - # args / content go under the spoiler. Bash args get a fenced - # ``bash`` block, other tools' args land in an inline ``code`` - # span; Read/Write content picks a language from the file - # extension; Edit content goes through ``diff``. - spoiler_body = _build_tool_spoiler_body(name, args, content) - return Event( - type="tool_use", - text=_trim(name, 80), - body=spoiler_body, - started_at=started, - tool_use_id=msg.tool_use_id, - tool_name=msg.tool_name, - ) - if msg.content_type == "tool_result": - name, args, summary, content = _split_tool_text(raw_body) - # Head: just the tool name + summary inline (e.g. "Edit · Added - # 12 lines"). args / content go through the same syntax- - # highlight pipeline as tool_use. - head_with_summary = ( - f"{name} · {summary}" if (name and summary) else name or summary - ) - spoiler_body = _build_tool_spoiler_body(name, args, content) - return Event( - type="tool_result", - text=_trim(head_with_summary, 120), - body=spoiler_body, - started_at=started, - tool_use_id=msg.tool_use_id, - image_data=msg.image_data, - is_error=msg.is_error, - ) - if msg.role == "user": - return Event( - type="user_msg", - text=_trim(text, 200), - body=raw_body, - started_at=started, - ) - is_final = msg.stop_reason in ("end_turn", "stop_sequence", "max_tokens") - # Narrative text events (mid-stream chunks and final answers) render - # ``event.text`` verbatim — don't ``_trim`` them, that would clip the - # answer at 200 chars and flatten newlines. The 200-char ``_trim`` cap - # is only meaningful for one-line summary heads (tool_use / thinking / - # user_msg). - return Event( - type="final_text" if is_final else "text", - text=text, - body=raw_body, - started_at=started, - completed_at=started if is_final else None, - is_page_break=is_final, - ) - - -def _apply_tool_result(state: CardState, result: Event) -> bool: - """Fold a ``tool_result`` Event into the matching ``tool_use``. - - Mutates the tool_use Event in place: ``completed_at``, ``body`` and - ``is_error`` are updated; image_data is carried over for the send - path. Returns True on success, False when no match found (caller - should append ``result`` as-is). - """ - if not result.tool_use_id: - return False - for ev in reversed(state.events): - if ev.type == "tool_use" and ev.tool_use_id == result.tool_use_id: - ev.completed_at = result.started_at - ev.body = result.body or ev.body - ev.text = result.text or ev.text - ev.is_error = result.is_error - ev.image_data = result.image_data - return True - return False - - -# ─── Render helpers ─────────────────────────────────────────────────── - - -def _format_elapsed(seconds: float) -> str: - """Format ``M:SS`` for an elapsed timer (negative → ``0:00``).""" - s = max(0, int(seconds)) - return f"{s // 60}:{s % 60:02d}" - - -def _format_hhmm(epoch: float) -> str: - import datetime as _dt - - return _dt.datetime.fromtimestamp(epoch).strftime("%H:%M") - - -def _format_hhmmss(epoch: float) -> str: - import datetime as _dt - - return _dt.datetime.fromtimestamp(epoch).strftime("%H:%M:%S") - - -def _is_in_flight(event: Event, events: list[Event], idx: int) -> bool: - """Per spec: ``⏳`` lives only on the LAST event of the latest page. - - Older events with ``completed_at=None`` are implicitly considered - finished by virtue of a newer event having started — for the user - the timer "moves" with whatever block claude is currently producing. - """ - if idx != len(events) - 1: - return False - if event.completed_at is not None: - return False - return event.type in ("tool_use", "thinking", "text") - - -def _body_trim(body: str, max_lines: int = SPOILER_MAX_LINES) -> str: - """Trim body content to ``max_lines`` lines. Excess → ``… (+N more lines)``.""" - if not body: - return "" - lines = body.split("\n") - if len(lines) <= max_lines: - return body - kept = lines[:max_lines] - extra = len(lines) - max_lines - kept.append(f"… (+{extra} more lines)") - return "\n".join(kept) - - -_SENTENCE_END_RE = re.compile(r"[.!?][\s)\]\"»]*\s") - - -def _table_continuation_prefix(chunk: str, remaining: str) -> str: - """Header+separator rows to prepend when a cut landed inside a GFM table. - - If ``chunk`` ends with table rows (a trailing run of ``|``-lines whose - first two are a header + ``|---|`` separator) and ``remaining`` starts - with another table row, the table was split mid-body — the continuation - page would render as headerless junk. Returns ``"header\\nsep\\n"`` so - the caller can re-emit them; ``""`` when no table was cut. - """ - rem_first = remaining.lstrip("\n").split("\n", 1)[0] - if not rem_first.lstrip().startswith("|"): - return "" - lines = chunk.rstrip("\n").split("\n") - i = len(lines) - while i > 0 and lines[i - 1].lstrip().startswith("|"): - i -= 1 - run = lines[i:] - if len(run) < 2: - return "" - sep_core = run[1].strip().strip("|").replace("|", "").replace(" ", "") - if not sep_core or not set(sep_core) <= {"-", ":"}: - return "" - return run[0] + "\n" + run[1] + "\n" - - -def _chunk_final_text( - text: str, - budget_lines: int = CARD_PAGE_LINES_DEFAULT, - byte_budget: int = CARD_PAGE_BUDGET, -) -> list[str]: - """Split a long final answer into chunks ≤ ``budget_lines`` AND ≤ ``byte_budget``. - - Smart-boundary preference (per user spec): paragraph (``\\n\\n``) → - line (``\\n``) → sentence terminator (``.!?``) → word (space) → hard. - Allows up to ``CARD_PAGE_LINES_OVERSHOOT`` extra lines so a sentence - isn't broken mid-content. NEVER breaks mid-word. - - The byte cap mirrors Telegram's 4096-byte edit limit (with headroom - for header / divider / footer / bg-panel). Without it, a wide - single-paragraph answer can pass the line cap and still overflow - after MarkdownV2 escaping — every reserved char gets a ``\\`` - prefix, blowing the rendered size past the limit. - - Empty / short input returns a single-chunk list. - """ - if not text: - return [] - if _count_lines(text) <= budget_lines and _estimate_md_v2_size(text) <= byte_budget: - return [text] - - chunks: list[str] = [] - remaining = text - while ( - _count_lines(remaining) > budget_lines - or _estimate_md_v2_size(remaining) > byte_budget - ): - rem_lines = remaining.split("\n") - # 1. Paragraph break — look at the last \n\n within budget+overshoot, - # clamped to the byte budget so we never search past the safe - # rendered-size window. - cap = budget_lines + CARD_PAGE_LINES_OVERSHOOT - char_cap_lines = sum( - len(rem_lines[i]) + 1 for i in range(min(cap, len(rem_lines))) - ) - char_cap_bytes = _char_pos_at_byte_budget(remaining, byte_budget) - char_cap = ( - min(char_cap_lines, char_cap_bytes) if char_cap_bytes else char_cap_lines - ) - # If even one char is over byte budget, char_cap_bytes is 0 — use a - # minimal cap so the boundary scans still see SOMETHING. Edge case. - if char_cap <= 0: - char_cap = max(1, char_cap_lines) - cut = remaining.rfind("\n\n", 0, char_cap) - - # 2. Line break within budget (no overshoot). - if cut <= 0: - char_budget = sum( - len(rem_lines[i]) + 1 for i in range(min(budget_lines, len(rem_lines))) - ) - char_budget = min(char_budget, char_cap) - cut = remaining.rfind("\n", 0, char_budget) - - # 3. Sentence terminator within budget+overshoot. - if cut <= 0: - m_iter = list(_SENTENCE_END_RE.finditer(remaining[:char_cap])) - if m_iter: - cut = m_iter[-1].end() - - # 4. Word boundary within budget+overshoot. - if cut <= 0: - cut = remaining.rfind(" ", 0, char_cap) - - # 5. Hard cut (last resort — only if no other boundary found in - # the entire overshoot window). Use char_cap to avoid mid-word - # if possible; otherwise raw budget cut. - if cut <= 0: - cut = char_cap if char_cap > 0 else len(remaining) - - chunk = remaining[:cut].rstrip() - if chunk: - chunks.append(chunk) - remaining = remaining[cut:].lstrip("\n").lstrip(" ") - # Cut landed inside a GFM table → re-emit header+separator so the - # next page renders as a valid table. Length guard keeps forward - # progress (never prepend more than the cut consumed). - if chunk: - prefix = _table_continuation_prefix(chunk, remaining) - if prefix and len(prefix) < cut: - remaining = prefix + remaining - if remaining: - chunks.append(remaining) - return chunks - - -def _trimmed_body(body: str) -> str: - """Trim and home-path-clean a tool / thinking body so it's safe to - drop into a spoiler block. Returns empty when there's nothing left - to show.""" - return _body_trim(_strip_for_card(body)) - - -# File-extension → language hint for syntax-highlighted fenced code -# blocks inside a tool's spoiler body. Telegram's rich-message rendering -# accepts these as the info string after ```` ``` ````. -_EXT_TO_LANG: dict[str, str] = { - "py": "python", - "ts": "typescript", - "tsx": "tsx", - "js": "javascript", - "jsx": "jsx", - "go": "go", - "rs": "rust", - "java": "java", - "kt": "kotlin", - "json": "json", - "yaml": "yaml", - "yml": "yaml", - "toml": "toml", - "md": "markdown", - "sql": "sql", - "html": "html", - "css": "css", - "scss": "scss", - "sh": "bash", - "bash": "bash", - "zsh": "bash", - "c": "c", - "cpp": "cpp", - "h": "c", - "hpp": "cpp", - "rb": "ruby", - "php": "php", - "swift": "swift", - "lua": "lua", - "xml": "xml", -} - - -def _lang_for_path(path: str) -> str: - """Pick a syntax-highlight language hint from a file path's extension. - - Returns an empty string when the extension is unknown / absent — - callers should fall back to a no-language fenced block (still - monospace, just no highlighting). - """ - if not path: - return "" - basename = path.rsplit("/", 1)[-1] - if basename == "Dockerfile" or basename.lower().endswith(".dockerfile"): - return "dockerfile" - if "." not in basename: - return "" - ext = basename.rsplit(".", 1)[-1].lower() - return _EXT_TO_LANG.get(ext, "") - - -def _format_tool_args(tool_name: str, args: str) -> str: - """Wrap a tool's args (command / path / pattern / URL) for visual - contrast inside the spoiler body — Bash gets a ``bash`` fenced - block (syntax highlighting), everything else gets an inline - ``code`` span. - """ - if not args: - return "" - if tool_name == "Bash": - return f"```bash\n{args}\n```" - return f"`{args}`" - - -def _format_tool_content(tool_name: str, args: str, content: str) -> str: - """Wrap a tool's content block (file body / diff) when it's actual - code — Read/Write content gets a fenced block in the file's - language, Edit content gets a ``diff`` block. Bash stdout, Grep - matches, WebFetch / WebSearch text are NOT code and stay plain. - """ - if not content: - return "" - if tool_name in ("Read", "Write"): - lang = _lang_for_path(args) - return f"```{lang}\n{content}\n```" if lang else f"```\n{content}\n```" - if tool_name == "Edit": - return f"```diff\n{content}\n```" - return content - - -def _build_tool_spoiler_body(tool_name: str, args: str, content: str) -> str: - """Assemble the spoiler body for a tool event — args first - (highlighted), then content (highlighted when it's code).""" - parts: list[str] = [] - if args: - parts.append(_format_tool_args(tool_name, args)) - if content: - parts.append(_format_tool_content(tool_name, args, content)) - return "\n".join(parts) - - -def _spoiler_body(body: str) -> str: - """Legacy plain-expandable wrapper kept for callers (tests, the - notifications re-export) that don't pair a head with the body. - - ``render_event`` itself moved to :func:`_headed_block` so the tool - event line becomes the spoiler label instead of sitting on its own - above a plain spoiler. New code shouldn't call this directly. - """ - from ..transcript_format import format_expandable_quote - - trimmed = _trimmed_body(body) - if not trimmed: - return "" - return format_expandable_quote(trimmed) - - -def _headed_block(head: str, body: str) -> str: - """Return ``head`` if there's no body, else wrap ``(head, body)`` - in the ``EXPANDABLE_HEADED`` sentinel so the rich renderer makes - ``head`` the spoiler label and ``body`` the collapsible content - (without repeating the head).""" - from ..transcript_format import format_expandable_with_head - - trimmed = _trimmed_body(body) - if not trimmed: - return head - return format_expandable_with_head(head, trimmed) - - -def render_event(event: Event, *, in_flight: bool, now: float) -> str: - """Render one Event as a plain-text block for the card.""" - # Build the trailing time-or-elapsed marker - if in_flight: - marker = f" · ⏳ {_format_elapsed(now - event.started_at)}" - elif event.type in ("tool_use", "thinking", "text"): - marker = f" · {_format_hhmm(event.started_at)}" - else: - marker = "" - - if event.type == "user_msg": - return f"👤 {event.text}" - - if event.type == "thinking": - return _headed_block(f"∴ thinking{marker}", event.body) - - if event.type == "tool_use": - if event.is_error: - glyph = "✗" - elif in_flight: - glyph = "▷" - else: - glyph = "✓" - return _headed_block(f"{glyph} {event.text}{marker}", event.body) - - if event.type == "tool_result": - # Fallback when the matching tool_use Event isn't found (parser - # race / restart). Render as a standalone row. - return _headed_block(f"✓ {event.text}{marker}", event.body) - - if event.type in ("text", "final_text", "error"): - # Mid-stream / final / error — inline, no glyph. - return event.text - - return event.text - - -def paginate_events(events: list[Event]) -> list[list[Event]]: - """Split ``events`` into pages by ``is_page_break``. - - Page break: each Event with ``is_page_break=True`` becomes the TOP - of a new page (everything before it lives on the previous page). - Empty input → ``[[]]`` so callers can address page 0. - - NOTE: this is the "logical" pagination — by answer boundary only. - Live cards must use :func:`paginate_events_for_card` to also split - over-budget logical pages into navigable sub-pages, so the ◀/▶ - counter matches what's actually rendered. - """ - pages: list[list[Event]] = [] - current: list[Event] = [] - for ev in events: - if ev.is_page_break and current: - pages.append(current) - current = [ev] - else: - current.append(ev) - if current: - pages.append(current) - return pages if pages else [[]] - - -# Inter-event joiner — sandwiches a non-breaking-space paragraph -# between events so CommonMark/MarkdownV2 render a TWO-paragraph gap -# (a single blank line is what consecutive ``\n\n\n`` collapsed to, -# which the user found too tight between thinking / tool / text blocks). -# Using `` `` (instead of HTML ``
``) keeps the gap consistent -# across the rich-message path AND the MarkdownV2 fallback — ``
`` -# isn't in ``_html_inline_to_markdown``'s whitelist so it would leak -# as a literal ``
`` to chat in the fallback path. -_EVENT_JOINER = "\n\n \n\n" -# Account for the joiner when summing per-event line counts in -# sub-pagination — ``_EVENT_JOINER`` contains 4 ``\n`` chars + 1 -# whitespace char, which adds 3 logical lines between any two events. -_JOINER_LINES = 3 - - -def _split_page_by_budget(page: list[Event], budget_lines: int) -> list[list[Event]]: - """Split one logical page into budget-fitting sub-pages. - - Returns the page unchanged when it fits in BOTH ``budget_lines + - CARD_PAGE_LINES_OVERSHOOT`` AND ``CARD_PAGE_BUDGET`` bytes (the - MD-V2-rendered byte cap Telegram enforces at edit time). Otherwise - greedy-packs events forward: flush to a new sub-page when adding - the next event (plus joiner overhead) would push us past either - budget. - - Without the byte check, a page with many small events (e.g. a - chain of single-line tool_use rows with MD-V2-escape-heavy paths) - can pass the line budget but still produce a >4096-byte rendered - body — Telegram refuses the edit with ``Message_too_long``, the - card body stops rendering for that page (observed on tests/@120). - - A single huge event (one tool_result that alone exceeds budget) - lands on its own sub-page — we don't split events, EXPQUOTE - sentinels must stay paired. - - Sub-pages are navigable via ◀/▶: the user lands on the LATEST - sub-page (default focus) and can step back to read older events. - """ - if not page: - return [page] - now = time.time() - cap = budget_lines + CARD_PAGE_LINES_OVERSHOOT - rendered = render_page(page, now=now) - if ( - _count_lines(rendered) <= cap - and _estimate_md_v2_size(rendered) <= CARD_PAGE_BUDGET - ): - return [page] - sub_pages: list[list[Event]] = [] - current: list[Event] = [] - current_lines = 0 - current_bytes = 0 - # Joiner byte cost = 4 ``\n`` (1 byte each) + 1 `` `` (2 bytes - # UTF-8). MD-V2 escape doesn't touch any of these so the - # post-conversion size matches the source. - _JOINER_BYTES = len(_EVENT_JOINER.encode("utf-8")) - for ev in page: - rendered_ev = render_event(ev, in_flight=False, now=now) - ev_lines = _count_lines(rendered_ev) - ev_bytes = _estimate_md_v2_size(rendered_ev) - line_overhead = _JOINER_LINES if current else 0 - byte_overhead = _JOINER_BYTES if current else 0 - line_overflow = current_lines + line_overhead + ev_lines > budget_lines - byte_overflow = current_bytes + byte_overhead + ev_bytes > CARD_PAGE_BUDGET - if current and (line_overflow or byte_overflow): - sub_pages.append(current) - current = [ev] - current_lines = ev_lines - current_bytes = ev_bytes - else: - current.append(ev) - current_lines += line_overhead + ev_lines - current_bytes += byte_overhead + ev_bytes - if current: - sub_pages.append(current) - return sub_pages - - -def paginate_events_for_card( - state: CardState, user_id: int | None -) -> list[list[Event]]: - """Canonical pagination for live cards (is_page_break + budget split). - - The ◀/▶ counter and the rendered body MUST agree. Older callers - that used :func:`paginate_events` directly would report 1/1 while - the body silently dropped middle events ("(+N older events on - previous pages)"). This unified entry point makes both sides see - the same page list. - """ - budget = _resolve_line_budget(user_id) - base_pages = paginate_events(state.events) - final_pages: list[list[Event]] = [] - for page in base_pages: - final_pages.extend(_split_page_by_budget(page, budget)) - return final_pages or [[]] - - -def _resolved_page_idx(state: CardState, total_pages: int) -> int: - """``current_page_idx`` clamped, with ``None`` → last (default focus).""" - if total_pages <= 0: - return 0 - if state.current_page_idx is None: - return total_pages - 1 - return max(0, min(state.current_page_idx, total_pages - 1)) - - -def render_page(events: list[Event], now: float) -> str: - """Render the events of one page into a single body string. - - Events are joined by ``_EVENT_JOINER`` — a non-breaking-space - paragraph wedged between two paragraph breaks. CommonMark / Telegram - rich would otherwise collapse two consecutive blank rows into a - single one, but a paragraph that contains a ``\\u00a0`` survives - trimming and gives the user a visibly larger gap between thinking, - tool_use and tool_result blocks. - """ - parts: list[str] = [] - for i, ev in enumerate(events): - parts.append(render_event(ev, in_flight=_is_in_flight(ev, events, i), now=now)) - return _EVENT_JOINER.join(parts) - - -# ─── Card composition ───────────────────────────────────────────────── - - -# Box-drawing / block-element glyphs (U+2500–U+259F). Claude Code's -# AskUserQuestion renders each option's ``preview`` inside a box-drawing -# frame (``┌ │ ├ ─ …``); captured verbatim into the kb-mode card those -# borders mangle the body. We strip them on the kb-mode path. -_BOX_DRAWING_RE = re.compile(r"[─-▟]") -_BORDER_ONLY_LINE_RE = re.compile(r"^[\s─-▟]*$") -# Box-drawing FRAME glyphs (verticals + corners + junctions + double-line), -# EXCLUDING the plain horizontals ─ ━ which show up as benign dividers in -# otherwise-normal prompts. Their presence is the signal that Claude Code -# framed the option previews in boxes (the case that mangles the card). A -# normal prompt — even one carrying a ── divider — matches none of these, so -# the sanitize/code-fence path stays a strict no-op for the well-behaved case. -_BOX_FRAME_RE = re.compile(r"[│┃┌-╋═-╬]") - -# Numbered-option row in an AskUserQuestion / picker pane. Tolerates the -# common cursor markers (``> `` / ``❯ ``) and arbitrary leading whitespace -# (Claude Code uses `` 2.`` to align the non-cursor rows under the -# cursor row). Anchors to the start of the line so prose like -# ``Step 1.`` never trips it. -_NUMBERED_OPTION_RE = re.compile(r"^\s*[>❯▶]?\s*\d+\.\s") - -# Source-level horizontal rule the generated ``─────`` separators -# already cover. Drop these during formatting so a verbatim divider -# line in the pane (e.g. NORMAL_PROMPT in the regression tests) doesn't -# show up as ``───── ───── ─────`` once we add ours. -_RULE_LINE_RE = re.compile(r"^[─\-=_]{3,}$") - - -_KB_BLOCK_SEPARATOR = "\n\n─────\n\n" -_KB_PARAGRAPH_SEPARATOR = "\n\n" -_KB_HARD_BREAK_JOIN = " \n" - - -def _format_kb_prompt(raw: str) -> str: - """Render a captured AskUserQuestion / picker pane as kb-mode body. - - Numbered options (``1. Foo``, ``❯ 2. Bar``) become their own blocks - separated by a ``─────`` rule — same archive-style split used in - ``handlers/archive.py`` for the session list. The header / hint - chrome around the options keeps a hard-break join so wrapping prose - stays readable on the phone. - - Pure prompts with no numbered options (ExitPlanMode, plain - confirmations) fall back to the cheap paragraph+hard-break join — - no spurious dividers. - """ - paragraphs: list[list[str]] = [] - current: list[str] = [] - - def _flush() -> None: - if current: - paragraphs.append(current.copy()) - current.clear() - - for line in raw.splitlines(): - stripped = line.strip() - if not stripped: - _flush() - continue - if _RULE_LINE_RE.match(stripped): - # Source rule lines are absorbed by the generated dividers - # — surviving as content would double up the separator. - _flush() - continue - current.append(line) - _flush() - - if not paragraphs: - return "" - - # Re-split each paragraph at numbered-option boundaries so every - # option ends up as its own block (and gets its own ─── divider). - refined: list[list[str]] = [] - for para in paragraphs: - buf: list[str] = [] - for line in para: - if _NUMBERED_OPTION_RE.match(line): - if buf: - refined.append(buf) - buf = [] - refined.append([line]) - else: - buf.append(line) - if buf: - refined.append(buf) - - has_options = any(_NUMBERED_OPTION_RE.match(p[0]) for p in refined if p) - sep = _KB_BLOCK_SEPARATOR if has_options else _KB_PARAGRAPH_SEPARATOR - return sep.join(_KB_HARD_BREAK_JOIN.join(p) for p in refined) - - -def _rule_between_options(body: str) -> str: - """Splice a ``─────`` rule before each numbered option (after the first). - - Used by the box-frame branch of ``_render_card``. That branch renders - the de-framed prompt inside a code fence, which suppresses MarkdownV2 — - so options can't be separated by markup the way the frameless path does. - Instead we splice literal ``─────`` rule lines between options; inside - the fence they render as plain monospace dividers, giving the same - archive-style separation without re-introducing the blockquote-collapse - the fence exists to prevent. - - Each option keeps its trailing preview/description lines (they ride with - the option until the next numbered row). Pre-existing source rule lines - are dropped so separators never double up. - """ - out: list[str] = [] - seen_option = False - for line in body.splitlines(): - if _RULE_LINE_RE.match(line.strip()): - continue # absorbed by the generated rules - if _NUMBERED_OPTION_RE.match(line): - if seen_option: - out.append("─────") - seen_option = True - out.append(line) - return "\n".join(out) - - -def _sanitize_prompt_block(text: str) -> str: - """Strip terminal box-drawing borders from a captured interactive prompt. - - Drops border-only lines and removes box-drawing glyphs from content - lines (preserving indentation + internal spacing). Collapses 3+ blank - lines that the border removal can leave behind. - """ - out: list[str] = [] - for line in text.splitlines(): - if _BORDER_ONLY_LINE_RE.match(line): - # Keep a single blank as a paragraph break, drop runs. - if out and out[-1] != "": - out.append("") - continue - cleaned = _BOX_DRAWING_RE.sub("", line).rstrip() - out.append(cleaned) - while out and out[0] == "": - out.pop(0) - while out and out[-1] == "": - out.pop() - return "\n".join(out) - - -def _render_card( - sess: Session, - state: CardState, - *, - footer: str = "", - user_id: int | None = None, -) -> str: - emoji = session_emoji(sess) - state_label = sess.dir_label if sess.state == "active" else sess.state - cont_marker = " · …continued" if state.is_continuation else "" - # Last-event timestamp in the header — HH:MM:SS of the most recent - # event of any kind (per-event timestamps inside the body stay HH:MM). - ts_suffix = "" - if state.last_event_ts > 0: - ts_suffix = " · " + _format_hhmmss(state.last_event_ts) - name_part = sess.name or sess.id - header = f"{emoji} *{name_part}* · {state_label}{cont_marker}{ts_suffix}" - if sess.goal: - header += f"\ngoal: {sess.goal}" - - # kb-mode view: card msg shows the interactive prompt content + kb - # keyboard. The regular event log is BELOW the keyboard (footer'd by - # the keyboard rather than by switcher/pagination). See Task #41. - if state.in_kb_mode and state.kb_prompt: - raw = state.kb_prompt - if _BOX_FRAME_RE.search(raw): - # Claude Code framed the option previews in box-drawing boxes - # (┌ │ ├ …). Captured verbatim those borders mangle the body and - # telegramify collapses the long region into an expandable - # blockquote (the "✂ N lines hidden" artifact). Strip the borders - # and render as a fenced code block — literal monospace, no - # MarkdownV2 escaping, no blockquote collapse. Guard a stray ```. - body = _sanitize_prompt_block(raw) - # Splice ───── rules between numbered options so they're visibly - # separated inside the fence (which suppresses MarkdownV2, so the - # frameless path's markup dividers can't apply here). - body = _rule_between_options(body) - prompt_part = body if "```" in body else f"```\n{body}\n```" - else: - # Format pane lines into explicit blocks: each numbered - # option ("1. Foo", "❯ 2. Bar") gets its own ─── divider - # — same archive-style split as ``handlers/archive.py``'s - # session list. Non-option prompts (ExitPlanMode and the - # like) fall back to hard-break-only join so wrapping - # prose stays one paragraph. See ``_format_kb_prompt``. - prompt_part = _format_kb_prompt(raw) - parts = [header, "─────", "⌨ *Waiting for your input:*", prompt_part] - # Paragraph-break join (same trap the bottom of this function - # already handles): single ``\n`` between header / separator / - # title would let the rich parser glue them onto one line. - return "\n\n".join(parts) - - # Budget is in LINES (per user setting ``card_page_lines``). - line_budget = _resolve_line_budget(user_id) - # Lazy re-chunk: if any final_text Event in state.events exceeds - # the CURRENT budget (e.g. user just lowered Settings → Page size, - # or budget changed since finalize_task), split it into multiple - # final_text Events on the fly. This is what makes the budget - # ULTIMATIVE per spec — even already-finalised answers get rebuilt - # to fit the new size. Idempotent: chunks below budget stay intact. - _rechunk_oversized_finals_inplace(state, line_budget) - - pages = paginate_events_for_card(state, user_id) - idx = _resolved_page_idx(state, len(pages)) - - # Optional bg-panel always lives at the bottom. - panel = "" - if user_id is not None: - panel = bg_status.render_panel(user_id, active_session_id=sess.id) - - # Safety net: a sub-page should fit by construction, but a single - # huge event (one tool_result well over budget) can still overflow - # — and we can't split it (EXPQUOTE atomicity). When that happens - # _trim_page_events keeps anchor + tail; the dropped events become - # genuinely inaccessible (no prior sub-page covers them), so the - # marker phrasing acknowledges that. - page_events = _trim_page_events(pages[idx], line_budget) - body = render_page(page_events, now=time.time()) - if len(page_events) < len(pages[idx]): - dropped = len(pages[idx]) - len(page_events) - body = f"… (+{dropped} events trimmed to fit)\n{body}" - if state.voice_pending: - pending_row = render_event( - Event( - type="user_msg", - text=t(user_id or 0, "voice.transcribing"), - started_at=time.time(), - ), - in_flight=False, - now=time.time(), - ) - body = _EVENT_JOINER.join(part for part in (body, pending_row) if part) - - parts = [header, "─────"] - if body: - parts.append(body) - if footer: - parts.append("─────") - parts.append(footer) - # Active session's own context-fill — single line at the very - # bottom of the card body, just above the bg-status panel. - # See ``set_card_context_pct``. - if state.context_pct is not None: - # Same `` ``-paragraph trick used by ``_EVENT_JOINER``: - # CommonMark collapses consecutive blank lines into one - # paragraph break, but a paragraph that contains a - # non-breaking space survives — visibly DOUBLES the gap above - # the ``context: N%`` row so it doesn't read glued onto the - # last body event. - parts.append(" ") - parts.append(f"context: {state.context_pct}%") - if panel: - # The panel carries its own ``─── фон ───`` label-separator - # (pivot #39 feedback: previously the bg-row glued to the last - # body line). Same nbsp-paragraph trick to widen the gap. - parts.append(" ") - parts.append(panel) - # Paragraph-break join (``\n\n``) — single ``\n`` is a CommonMark - # soft break that the rich parser collapses to a space, glueing - # ``header ───── body ───── footer`` onto one row instead of each - # on its own line. Same trap we hit in /archive and the bg-panel. - return "\n\n".join(parts) - - -def _count_lines(text: str) -> int: - """Count logical \\n-delimited lines in a rendered string.""" - if not text: - return 0 - return text.count("\n") + 1 - - -# Telegram MarkdownV2 reserved chars — each one gains a leading ``\`` -# during ``convert_markdown``. We use this as an upper-bound estimate of -# the post-render byte count without paying for a real telegramify -# round-trip on every event. The bound is sloppy on purpose: better to -# oversplit a long answer than to send a 4096+ byte payload and lose the -# whole card edit to ``Message_too_long``. -_MD_V2_ESCAPE_CHARS = frozenset("_*[]()~`>#+-=|{}.!\\") - - -def _estimate_md_v2_size(text: str) -> int: - """Upper bound on ``len(convert_markdown(text))`` (chars / bytes-ASCII). - - Each MarkdownV2 reserved char contributes ``+1`` over the raw length - for its escape backslash. Real telegramify-markdown sometimes leaves - a few of these unescaped inside valid markdown tokens (``**bold**`` - etc.), but using an over-estimate is the safe direction — we'd - rather chunk earlier than discover overflow at edit time. - """ - if not text: - return 0 - extra = sum(1 for c in text if c in _MD_V2_ESCAPE_CHARS) - return len(text) + extra - - -def _char_pos_at_byte_budget(text: str, byte_budget: int) -> int: - """Largest ``p`` such that ``_estimate_md_v2_size(text[:p]) <= byte_budget``. - - Returns ``len(text)`` if the whole string fits. Used by - ``_chunk_final_text`` to clamp the boundary-search window when a - long answer would otherwise overflow Telegram's 4096-byte edit cap - even at very few visual lines. - """ - if byte_budget <= 0 or not text: - return 0 - size = 0 - for i, c in enumerate(text): - bump = 2 if c in _MD_V2_ESCAPE_CHARS else 1 - if size + bump > byte_budget: - return i - size += bump - return len(text) - - -def _rechunk_oversized_finals_inplace(state: CardState, budget_lines: int) -> None: - """Walk ``state.events`` and split oversized ``final_text`` Events. - - Idempotent: an Event already fitting BOTH ``budget_lines`` AND the - MarkdownV2-rendered byte budget (``CARD_PAGE_BUDGET``) is left - untouched. An oversized Event is replaced (in place, preserving - order) by N ``final_text`` Events produced by ``_chunk_final_text``, - each marked ``is_page_break=True`` so pagination treats every chunk - as a separate page. - - The byte gate matters: a wide single-paragraph answer can fit in - ``cap`` visual lines and STILL produce a >4096-byte payload after - MarkdownV2 escaping → ``Message_too_long`` on edit, plain-text - fallback and repost all fail → the live card freezes on the previous - body and the user never sees the reply. Splitting on rendered size - keeps every chunk within Telegram's edit limit. - """ - cap_lines = budget_lines + CARD_PAGE_LINES_OVERSHOOT - i = 0 - while i < len(state.events): - ev = state.events[i] - if ev.type != "final_text" or not ev.text: - i += 1 - continue - fits_lines = _count_lines(ev.text) <= cap_lines - fits_bytes = _estimate_md_v2_size(ev.text) <= CARD_PAGE_BUDGET - if fits_lines and fits_bytes: - i += 1 - continue - chunks = _chunk_final_text(ev.text, budget_lines, CARD_PAGE_BUDGET) - if len(chunks) <= 1: - # _chunk_final_text refused to split (e.g. one huge unbroken - # token with no boundary candidates). Leave as is. - i += 1 - continue - replacement = [ - Event( - type="final_text", - text=chunk, - body=chunk, - started_at=ev.started_at, - completed_at=ev.completed_at, - is_page_break=True, - ) - for chunk in chunks - ] - state.events[i : i + 1] = replacement - i += len(replacement) - - -def _resolve_line_budget(user_id: int | None) -> int: - """Read the user's ``card_page_lines`` setting (15/30/50/100). - - Returns the default when the user has no setting or ``user_id`` is - None (e.g. unit-test paths). Always clamps to the allowed range. - """ - if user_id is None: - return CARD_PAGE_LINES_DEFAULT - try: - raw = session_manager.get_user_settings(user_id).get( - "card_page_lines", CARD_PAGE_LINES_DEFAULT - ) - value = int(raw) - except (TypeError, ValueError): - value = CARD_PAGE_LINES_DEFAULT - if value not in (10, 20, 40, 70): - return CARD_PAGE_LINES_DEFAULT - return value - - -def _trim_page_events(events: list[Event], budget_lines: int) -> list[Event]: - """Drop middle events from ``events`` until rendered line-count - ≤ ``budget_lines`` (with ``CARD_PAGE_LINES_OVERSHOOT`` slack). - - Always preserves: - * The FIRST event (page anchor — usually the ``is_page_break`` - final_text answer; user needs the answer at the top of the page). - * The TAIL events that fit in remaining budget (latest signal — - in-flight tool, last narration). - - Middle events drop first. Whole-event boundaries only so EXPQUOTE - sentinels stay paired. - """ - if not events: - return events - now = time.time() - full_lines = _count_lines(render_page(events, now=now)) - cap = budget_lines + CARD_PAGE_LINES_OVERSHOOT - if full_lines <= cap: - return events - anchor = events[0] - anchor_lines = _count_lines(render_event(anchor, in_flight=False, now=now)) - remaining = max(0, budget_lines - anchor_lines) - # Walk from the end (excluding anchor), accumulating until budget. - kept_tail_rev: list[Event] = [] - total = 0 - for i in range(len(events) - 1, 0, -1): - rendered = render_event(events[i], in_flight=False, now=now) - ev_lines = _count_lines(rendered) - if kept_tail_rev and total + ev_lines > remaining: - break - kept_tail_rev.append(events[i]) - total += ev_lines - kept_tail = list(reversed(kept_tail_rev)) - return [anchor, *kept_tail] - - -def card_page_info(state: CardState, user_id: int | None = None) -> tuple[int, int]: - """Return (current_page_idx, total_pages) for the keyboard counter. - - Uses :func:`paginate_events_for_card` so the count reflects the - budget-aware sub-pagination — matching what's actually rendered. - ``user_id`` is only optional for legacy callers; passing it in - yields the user-specific budget (otherwise default budget is used, - which can mismatch the rendered card). - """ - pages = paginate_events_for_card(state, user_id) - total = max(1, len(pages)) - idx = _resolved_page_idx(state, total) - return idx, total - - -def _is_stale(state: CardState) -> bool: - if state.msg_id is None or state.last_event_ts <= 0: - return False - return (time.time() - state.last_event_ts) >= STALE_CARD_SECONDS - - -def _duplicate_of_seeded(events: list[Event], candidate: Event) -> bool: - """True when ``candidate`` already appears in ``events``. - - Guards the live-append path against double-rendering a turn that a - JSONL re-seed already pulled in. When a stale card is wiped and - re-seeded (``_update_session_card_locked`` / - ``release_card_message`` → ``_ensure_seeded``), the seed re-reads the - transcript — which already contains the just-submitted user prompt - that triggered this very update — and the same message is then - appended again as the live event, rendering the user's message - twice. - - Matched on ``(type, started_at, text)``. ``started_at`` is the JSONL - timestamp parsed deterministically by ``_parse_timestamp``, so the - seeded copy and the live copy of one entry share a bit-identical - value while two distinct turns never collide (distinct timestamps). - A user legitimately repeating the same text lands a new JSONL entry - with a later timestamp, so it is not deduped. - """ - for ev in events: - if ( - ev.type == candidate.type - and ev.started_at == candidate.started_at - and ev.text == candidate.text - ): - return True - return False - - -def _card_is_busy(state: CardState) -> bool: - """Is this card actually producing output right now? Drives the - Stop ↔ Kill keyboard split AND the polling-side TYPING indicator. - - Busy iff ALL of: - 1. ``msg_id`` set (card alive). - 2. There IS an event log AND its tail is not a terminal event - (``final_text`` / ``error``). After ``finalize_task`` lands - a ``final_text`` chunk the turn is done — TYPING and the - Stop button should clear immediately, not linger for the - grace window. - 3. Last event was within ``2 × CARD_EDIT_LAG`` (bridges the - 100-500 ms ``tool_use`` ↔ ``tool_result`` gap; longer gaps - where claude is silently thinking are picked up by - ``status_polling`` via the pane spinner instead). - """ - from ..config import config - - if state.msg_id is None: - return False - if state.last_event_ts <= 0: - return False - if not state.events: - return False - last = state.events[-1] - if last.type in ("final_text", "error"): - return False - now = time.time() - grace = max(2.0, config.card_edit_lag * 2) - return (now - state.last_event_ts) < grace - - -def _latest_inflight_idx(page_events: list[Event]) -> int | None: - """Index of the last in-flight event on a page, or None if none.""" - for i in range(len(page_events) - 1, -1, -1): - if _is_in_flight(page_events[i], page_events, i): - return i - return None diff --git a/src/ccbot/handlers/card_pagination.py b/src/ccbot/handlers/card_pagination.py new file mode 100644 index 00000000..42e44c31 --- /dev/null +++ b/src/ccbot/handlers/card_pagination.py @@ -0,0 +1,384 @@ +"""Paginate card events and enforce per-user line and Telegram byte budgets.""" + +from __future__ import annotations + +import time + +from ..session import session_manager +from .card_budget import ( + _chunk_final_text, + _count_lines, + _estimate_md_v2_size, + _is_in_flight, +) +from .card_event_render import render_event +from .card_types import ( + CARD_PAGE_BUDGET, + CARD_PAGE_LINES_DEFAULT, + CARD_PAGE_LINES_OVERSHOOT, + STALE_CARD_SECONDS, + CardState, + Event, +) + +__all__ = [ + "paginate_events", + "_EVENT_JOINER", + "_JOINER_LINES", + "_split_page_by_budget", + "paginate_events_for_card", + "_resolved_page_idx", + "render_page", + "_rechunk_oversized_finals_inplace", + "_resolve_line_budget", + "_trim_page_events", + "card_page_info", + "_is_stale", + "_duplicate_of_seeded", + "_card_is_busy", + "_latest_inflight_idx", +] + + +def paginate_events(events: list[Event]) -> list[list[Event]]: + """Split ``events`` into pages by ``is_page_break``. + + Page break: each Event with ``is_page_break=True`` becomes the TOP + of a new page (everything before it lives on the previous page). + Empty input → ``[[]]`` so callers can address page 0. + + NOTE: this is the "logical" pagination — by answer boundary only. + Live cards must use :func:`paginate_events_for_card` to also split + over-budget logical pages into navigable sub-pages, so the ◀/▶ + counter matches what's actually rendered. + """ + pages: list[list[Event]] = [] + current: list[Event] = [] + for ev in events: + if ev.is_page_break and current: + pages.append(current) + current = [ev] + else: + current.append(ev) + if current: + pages.append(current) + return pages if pages else [[]] + + +# Inter-event joiner — sandwiches a non-breaking-space paragraph +# between events so CommonMark/MarkdownV2 render a TWO-paragraph gap +# (a single blank line is what consecutive ``\n\n\n`` collapsed to, +# which the user found too tight between thinking / tool / text blocks). +# Using `` `` (instead of HTML ``
``) keeps the gap consistent +# across the rich-message path AND the MarkdownV2 fallback — ``
`` +# isn't in ``_html_inline_to_markdown``'s whitelist so it would leak +# as a literal ``
`` to chat in the fallback path. +_EVENT_JOINER = "\n\n \n\n" +# Account for the joiner when summing per-event line counts in +# sub-pagination — ``_EVENT_JOINER`` contains 4 ``\n`` chars + 1 +# whitespace char, which adds 3 logical lines between any two events. +_JOINER_LINES = 3 + + +def _split_page_by_budget(page: list[Event], budget_lines: int) -> list[list[Event]]: + """Split one logical page into budget-fitting sub-pages. + + Returns the page unchanged when it fits in BOTH ``budget_lines + + CARD_PAGE_LINES_OVERSHOOT`` AND ``CARD_PAGE_BUDGET`` bytes (the + MD-V2-rendered byte cap Telegram enforces at edit time). Otherwise + greedy-packs events forward: flush to a new sub-page when adding + the next event (plus joiner overhead) would push us past either + budget. + + Without the byte check, a page with many small events (e.g. a + chain of single-line tool_use rows with MD-V2-escape-heavy paths) + can pass the line budget but still produce a >4096-byte rendered + body — Telegram refuses the edit with ``Message_too_long``, the + card body stops rendering for that page (observed on tests/@120). + + A single huge event (one tool_result that alone exceeds budget) + lands on its own sub-page — we don't split events, EXPQUOTE + sentinels must stay paired. + + Sub-pages are navigable via ◀/▶: the user lands on the LATEST + sub-page (default focus) and can step back to read older events. + """ + if not page: + return [page] + now = time.time() + cap = budget_lines + CARD_PAGE_LINES_OVERSHOOT + rendered = render_page(page, now=now) + if ( + _count_lines(rendered) <= cap + and _estimate_md_v2_size(rendered) <= CARD_PAGE_BUDGET + ): + return [page] + sub_pages: list[list[Event]] = [] + current: list[Event] = [] + current_lines = 0 + current_bytes = 0 + # Joiner byte cost = 4 ``\n`` (1 byte each) + 1 `` `` (2 bytes + # UTF-8). MD-V2 escape doesn't touch any of these so the + # post-conversion size matches the source. + _JOINER_BYTES = len(_EVENT_JOINER.encode("utf-8")) + for ev in page: + rendered_ev = render_event(ev, in_flight=False, now=now) + ev_lines = _count_lines(rendered_ev) + ev_bytes = _estimate_md_v2_size(rendered_ev) + line_overhead = _JOINER_LINES if current else 0 + byte_overhead = _JOINER_BYTES if current else 0 + line_overflow = current_lines + line_overhead + ev_lines > budget_lines + byte_overflow = current_bytes + byte_overhead + ev_bytes > CARD_PAGE_BUDGET + if current and (line_overflow or byte_overflow): + sub_pages.append(current) + current = [ev] + current_lines = ev_lines + current_bytes = ev_bytes + else: + current.append(ev) + current_lines += line_overhead + ev_lines + current_bytes += byte_overhead + ev_bytes + if current: + sub_pages.append(current) + return sub_pages + + +def paginate_events_for_card( + state: CardState, user_id: int | None +) -> list[list[Event]]: + """Canonical pagination for live cards (is_page_break + budget split). + + The ◀/▶ counter and the rendered body MUST agree. Older callers + that used :func:`paginate_events` directly would report 1/1 while + the body silently dropped middle events ("(+N older events on + previous pages)"). This unified entry point makes both sides see + the same page list. + """ + budget = _resolve_line_budget(user_id) + base_pages = paginate_events(state.events) + final_pages: list[list[Event]] = [] + for page in base_pages: + final_pages.extend(_split_page_by_budget(page, budget)) + return final_pages or [[]] + + +def _resolved_page_idx(state: CardState, total_pages: int) -> int: + """``current_page_idx`` clamped, with ``None`` → last (default focus).""" + if total_pages <= 0: + return 0 + if state.current_page_idx is None: + return total_pages - 1 + return max(0, min(state.current_page_idx, total_pages - 1)) + + +def render_page(events: list[Event], now: float) -> str: + """Render the events of one page into a single body string. + + Events are joined by ``_EVENT_JOINER`` — a non-breaking-space + paragraph wedged between two paragraph breaks. CommonMark / Telegram + rich would otherwise collapse two consecutive blank rows into a + single one, but a paragraph that contains a ``\\u00a0`` survives + trimming and gives the user a visibly larger gap between thinking, + tool_use and tool_result blocks. + """ + parts: list[str] = [] + for i, ev in enumerate(events): + parts.append(render_event(ev, in_flight=_is_in_flight(ev, events, i), now=now)) + return _EVENT_JOINER.join(parts) + + +def _rechunk_oversized_finals_inplace(state: CardState, budget_lines: int) -> None: + """Walk ``state.events`` and split oversized ``final_text`` Events. + + Idempotent: an Event already fitting BOTH ``budget_lines`` AND the + MarkdownV2-rendered byte budget (``CARD_PAGE_BUDGET``) is left + untouched. An oversized Event is replaced (in place, preserving + order) by N ``final_text`` Events produced by ``_chunk_final_text``, + each marked ``is_page_break=True`` so pagination treats every chunk + as a separate page. + + The byte gate matters: a wide single-paragraph answer can fit in + ``cap`` visual lines and STILL produce a >4096-byte payload after + MarkdownV2 escaping → ``Message_too_long`` on edit, plain-text + fallback and repost all fail → the live card freezes on the previous + body and the user never sees the reply. Splitting on rendered size + keeps every chunk within Telegram's edit limit. + """ + cap_lines = budget_lines + CARD_PAGE_LINES_OVERSHOOT + i = 0 + while i < len(state.events): + ev = state.events[i] + if ev.type != "final_text" or not ev.text: + i += 1 + continue + fits_lines = _count_lines(ev.text) <= cap_lines + fits_bytes = _estimate_md_v2_size(ev.text) <= CARD_PAGE_BUDGET + if fits_lines and fits_bytes: + i += 1 + continue + chunks = _chunk_final_text(ev.text, budget_lines, CARD_PAGE_BUDGET) + if len(chunks) <= 1: + # _chunk_final_text refused to split (e.g. one huge unbroken + # token with no boundary candidates). Leave as is. + i += 1 + continue + replacement = [ + Event( + type="final_text", + text=chunk, + body=chunk, + started_at=ev.started_at, + completed_at=ev.completed_at, + is_page_break=True, + ) + for chunk in chunks + ] + state.events[i : i + 1] = replacement + i += len(replacement) + + +def _resolve_line_budget(user_id: int | None) -> int: + """Read the user's ``card_page_lines`` setting (15/30/50/100). + + Returns the default when the user has no setting or ``user_id`` is + None (e.g. unit-test paths). Always clamps to the allowed range. + """ + if user_id is None: + return CARD_PAGE_LINES_DEFAULT + try: + raw = session_manager.get_user_settings(user_id).get( + "card_page_lines", CARD_PAGE_LINES_DEFAULT + ) + value = int(raw) + except (TypeError, ValueError): + value = CARD_PAGE_LINES_DEFAULT + if value not in (10, 20, 40, 70): + return CARD_PAGE_LINES_DEFAULT + return value + + +def _trim_page_events(events: list[Event], budget_lines: int) -> list[Event]: + """Drop middle events from ``events`` until rendered line-count + ≤ ``budget_lines`` (with ``CARD_PAGE_LINES_OVERSHOOT`` slack). + + Always preserves: + * The FIRST event (page anchor — usually the ``is_page_break`` + final_text answer; user needs the answer at the top of the page). + * The TAIL events that fit in remaining budget (latest signal — + in-flight tool, last narration). + + Middle events drop first. Whole-event boundaries only so EXPQUOTE + sentinels stay paired. + """ + if not events: + return events + now = time.time() + full_lines = _count_lines(render_page(events, now=now)) + cap = budget_lines + CARD_PAGE_LINES_OVERSHOOT + if full_lines <= cap: + return events + anchor = events[0] + anchor_lines = _count_lines(render_event(anchor, in_flight=False, now=now)) + remaining = max(0, budget_lines - anchor_lines) + # Walk from the end (excluding anchor), accumulating until budget. + kept_tail_rev: list[Event] = [] + total = 0 + for i in range(len(events) - 1, 0, -1): + rendered = render_event(events[i], in_flight=False, now=now) + ev_lines = _count_lines(rendered) + if kept_tail_rev and total + ev_lines > remaining: + break + kept_tail_rev.append(events[i]) + total += ev_lines + kept_tail = list(reversed(kept_tail_rev)) + return [anchor, *kept_tail] + + +def card_page_info(state: CardState, user_id: int | None = None) -> tuple[int, int]: + """Return (current_page_idx, total_pages) for the keyboard counter. + + Uses :func:`paginate_events_for_card` so the count reflects the + budget-aware sub-pagination — matching what's actually rendered. + ``user_id`` is only optional for legacy callers; passing it in + yields the user-specific budget (otherwise default budget is used, + which can mismatch the rendered card). + """ + pages = paginate_events_for_card(state, user_id) + total = max(1, len(pages)) + idx = _resolved_page_idx(state, total) + return idx, total + + +def _is_stale(state: CardState) -> bool: + if state.msg_id is None or state.last_event_ts <= 0: + return False + return (time.time() - state.last_event_ts) >= STALE_CARD_SECONDS + + +def _duplicate_of_seeded(events: list[Event], candidate: Event) -> bool: + """True when ``candidate`` already appears in ``events``. + + Guards the live-append path against double-rendering a turn that a + JSONL re-seed already pulled in. When a stale card is wiped and + re-seeded (``_update_session_card_locked`` / + ``release_card_message`` → ``_ensure_seeded``), the seed re-reads the + transcript — which already contains the just-submitted user prompt + that triggered this very update — and the same message is then + appended again as the live event, rendering the user's message + twice. + + Matched on ``(type, started_at, text)``. ``started_at`` is the JSONL + timestamp parsed deterministically by ``_parse_timestamp``, so the + seeded copy and the live copy of one entry share a bit-identical + value while two distinct turns never collide (distinct timestamps). + A user legitimately repeating the same text lands a new JSONL entry + with a later timestamp, so it is not deduped. + """ + for ev in events: + if ( + ev.type == candidate.type + and ev.started_at == candidate.started_at + and ev.text == candidate.text + ): + return True + return False + + +def _card_is_busy(state: CardState) -> bool: + """Is this card actually producing output right now? Drives the + Stop ↔ Kill keyboard split AND the polling-side TYPING indicator. + + Busy iff ALL of: + 1. ``msg_id`` set (card alive). + 2. There IS an event log AND its tail is not a terminal event + (``final_text`` / ``error``). After ``finalize_task`` lands + a ``final_text`` chunk the turn is done — TYPING and the + Stop button should clear immediately, not linger for the + grace window. + 3. Last event was within ``2 × CARD_EDIT_LAG`` (bridges the + 100-500 ms ``tool_use`` ↔ ``tool_result`` gap; longer gaps + where claude is silently thinking are picked up by + ``status_polling`` via the pane spinner instead). + """ + from ..config import config + + if state.msg_id is None: + return False + if state.last_event_ts <= 0: + return False + if not state.events: + return False + last = state.events[-1] + if last.type in ("final_text", "error"): + return False + now = time.time() + grace = max(2.0, config.card_edit_lag * 2) + return (now - state.last_event_ts) < grace + + +def _latest_inflight_idx(page_events: list[Event]) -> int | None: + """Index of the last in-flight event on a page, or None if none.""" + for i in range(len(page_events) - 1, -1, -1): + if _is_in_flight(page_events[i], page_events, i): + return i + return None diff --git a/src/ccbot/handlers/card_registry.py b/src/ccbot/handlers/card_registry.py new file mode 100644 index 00000000..87c2997c --- /dev/null +++ b/src/ccbot/handlers/card_registry.py @@ -0,0 +1,291 @@ +"""Shared live-card registries, locks, message ownership, and repost intent state.""" + +from __future__ import annotations + +from __future__ import annotations + +import asyncio +import logging + +from telegram import Bot + +from ..session import session_manager +from .card_binding import clear_carrier +from .card_model import ( + CardState, +) + +logger = logging.getLogger(__name__) + + +__all__ = [ + "_cards", + "_card_surface_tasks", + "_card_locks", + "_card_lock", + "_carrier_edit_locks", + "_carrier_edit_lock", + "_user_send_locks", + "_user_send_lock", + "_strip_stale_switchers", + "_MSG_REGISTRY_LIMIT", + "_msg_to_session", + "_register_msg", + "lookup_session_for_message", + "reset_card_msg_id_for_user", + "_inline_screens_enabled", + "_should_buffer", + "_repost_intent", + "begin_repost_intent", + "end_repost_intent", + "reset_card", + "_legacy", +] + +# Per-(user, session.id) card state. +_cards: dict[tuple[int, str], CardState] = {} + +# Fire-and-forget receipt surfaces started by Telegram intake. Keeping them in +# one registry lets shutdown cancel cleanly instead of leaving Telegram edits +# alive after the application has started closing its HTTP client. +_card_surface_tasks: set[asyncio.Task[bool]] = set() + +# Per-(user, session.id) async lock. Acquired by every code path that +# may decide to ``_send_card`` (spawn a fresh card msg) so two +# concurrent paths can't both observe ``state.msg_id is None`` and +# both spawn — the artefact behind Task #50 ("2 messages in wrong +# order after switcher / new card"). Edit-only paths that never spawn +# (refresh_panel, card_timer_loop ticks, _deferred_edit) don't take +# the lock — at worst they race a spawn and either succeed against +# the freshly-spawned msg or hit lost-carrier and reset msg_id, which +# is recovered on the next event. +_card_locks: dict[tuple[int, str], asyncio.Lock] = {} + + +def _card_lock(user_id: int, session_id: str) -> asyncio.Lock: + """Get-or-create the spawn-serialization lock for one card.""" + key = (user_id, session_id) + lock = _card_locks.get(key) + if lock is None: + lock = asyncio.Lock() + _card_locks[key] = lock + return lock + + +# Per-user barrier for Telegram edits of the shared live-card carrier. +# +# A switch reuses the same Telegram message for another session. The old +# session may already have an editMessageText request in flight when the user +# taps the switcher; cancelling its deferred task is then too late. Without a +# barrier that old request can finish after the target session is painted and +# overwrite the carrier with the previous session's text. +# +# Every ``_edit_card`` holds this lock for the whole Telegram request. The +# switch hand-off acquires it before pausing the old owner and flipping the +# active-session pointer, so all older edits finish first and all newer old- +# session edits observe ``in_menu_view=True`` before they can reach Telegram. +_carrier_edit_locks: dict[int, asyncio.Lock] = {} + + +def _carrier_edit_lock(user_id: int) -> asyncio.Lock: + """Get-or-create the cross-session carrier-edit lock for one user.""" + lock = _carrier_edit_locks.get(user_id) + if lock is None: + lock = asyncio.Lock() + _carrier_edit_locks[user_id] = lock + return lock + + +# Per-user spawn lock. ``_send_card`` holds it across the whole +# "send the message → strip every other card's keyboard → record the new +# switcher carrier" sequence, so two *different sessions* spawning cards +# concurrently (a voice repost racing a typed-message repost) can't +# interleave those steps and leave the per-user ``last_switcher_msg_id`` +# pointing at the older message — the desync behind "I tap the switcher +# on the last message but the previous one gets edited". +# +# ``_card_lock`` alone doesn't cover this: it is keyed per (user, +# session), so two sessions never contend on it. Lock order is always +# session-lock → user-lock; no path acquires them the other way round. +_user_send_locks: dict[int, asyncio.Lock] = {} + + +def _user_send_lock(user_id: int) -> asyncio.Lock: + """Get-or-create the cross-session spawn-serialization lock for a user.""" + lock = _user_send_locks.get(user_id) + if lock is None: + lock = asyncio.Lock() + _user_send_locks[user_id] = lock + return lock + + +async def _strip_stale_switchers( + bot: Bot, user_id: int, keep_msg_id: int, keep_session_id: str | None +) -> None: + """Leave exactly ONE message in the chat carrying a live footer / + switcher keyboard: ``keep_msg_id``. + + Stripping only ``last_switcher_msg_id`` (the previous behaviour) is + not enough — that pointer is a single per-user slot, while every + session owns its own card message and ``_edit_card`` re-attaches a + keyboard on every edit without moving the pointer. Two live cards + could therefore end up tappable at once, and a switcher tap would + repaint whichever message the tap came from rather than the newest. + + So: strip the pointer's message *and* every other known card message + for this user. Cards in kb-mode are skipped — their keyboard is the + AskUserQuestion / ExitPlanMode navigation grid the user still has to + act on, not a stale switcher. + """ + targets: list[int] = [] + prev = session_manager.get_last_switcher_msg(user_id) + if prev and prev != keep_msg_id: + targets.append(prev) + for (uid, sid), st in _cards.items(): + if uid != user_id or sid == keep_session_id or st.in_kb_mode: + continue + if st.msg_id is not None and st.msg_id != keep_msg_id: + if st.msg_id not in targets: + targets.append(st.msg_id) + for msg_id in targets: + try: + await bot.edit_message_reply_markup( + chat_id=user_id, message_id=msg_id, reply_markup=None + ) + except Exception: + # Already stripped / deleted / not editable — nothing to do. + pass + + +# Reverse lookup so reply-quote can route a one-shot user message to the +# session that owns the message being replied to. Capped via FIFO eviction. +_MSG_REGISTRY_LIMIT = 2000 +_msg_to_session: dict[tuple[int, int], str] = {} + + +def _register_msg(user_id: int, message_id: int, session_id: str) -> None: + """Remember which session a bot message belongs to for reply-quote routing.""" + key = (user_id, message_id) + # Best-effort eviction: drop ~10% of the oldest entries when the cap + # is hit. dict preserves insertion order in CPython 3.7+. + if len(_msg_to_session) >= _MSG_REGISTRY_LIMIT and key not in _msg_to_session: + drop = max(1, _MSG_REGISTRY_LIMIT // 10) + for k in list(_msg_to_session.keys())[:drop]: + _msg_to_session.pop(k, None) + _msg_to_session[key] = session_id + + +def lookup_session_for_message(user_id: int, message_id: int) -> str | None: + """Resolve a Telegram message id back to the Session.id it represents.""" + return _msg_to_session.get((user_id, message_id)) + + +def reset_card_msg_id_for_user(user_id: int) -> None: + """Drop the msg_id for every card of ``user_id`` so the next event + creates a fresh msg of the (possibly changed) correct type. + + Called when the user toggles ``card_inline_screenshots``. We orphan + the old carrier so the next event starts a fresh card below the next + user message with the requested media layout. + """ + for (uid, _sid), state in _cards.items(): + if uid != user_id: + continue + clear_carrier(state) + state.last_rendered = "" + + +def _inline_screens_enabled(user_id: int | None) -> bool: + """Read the ``card_inline_screenshots`` user-setting (default False).""" + if user_id is None: + return False + settings = session_manager.get_user_settings(user_id) + return bool(settings.get("card_inline_screenshots", False)) + + +def _should_buffer(user_id: int, session_id: str, state: CardState) -> bool: + """Return True when the live card must buffer events instead of + rendering. Four reasons: + + 1. The user has the carrier on a Menu / sub-screen + (``state.in_menu_view`` — set by ``pause_card_view`` / + ``transfer_card_to_carrier``, cleared by ``resume_card_view`` / + ``release_card_message`` / ``detach_paused_cards_at_message``). + 2. The session is currently a background one for this user + (``get_active_session(user_id).id != session_id``). Computed + live, NOT stored — a session that's briefly bg and then active + again recovers without help. (Earlier this was implemented as a + sticky ``state.in_menu_view = True`` inside update_session_card; + the flag never got cleared on becoming active again, so the card + stayed paused forever — silent until the next typed message + woke ``resume_card_view``. This helper makes the bg check live + so that class of bug can't reoccur.) + 3. ``text_handler`` has signalled an imminent ``repost_card`` for + this (user, session) via ``begin_repost_intent``. Without the + buffer, claude's first reply event after the user's typed text + races against the repost and both ``update_session_card`` and + ``repost_card`` end up calling ``_send_card`` — two cards land + in chat (or one survives + claude's first event is lost when + ``delete_message`` succeeds on a card that already had content). + Buffering defers the rendering until ``end_repost_intent`` + cleared the flag; events accumulate in ``state.events`` and + drain into the freshly-reposted card on the next render. + 4. The card is in kb-mode (``state.in_kb_mode``). Without this, + a stray streaming event (assistant text emitted right before the + AskUserQuestion lands, e.g.) would trigger ``_edit_card`` with + the default footer keyboard — overwriting the kb keyboard the + user needs to act on. Buffer until ``exit_kb_mode`` clears the + flag; the drained events land on the next post-prompt render. + """ + if state.in_menu_view: + return True + if state.in_kb_mode: + return True + if (user_id, session_id) in _repost_intent: + return True + active = session_manager.get_active_session(user_id) + return active is None or active.id != session_id + + +# (user_id, session_id) pairs for which ``text_handler`` is mid-dispatch +# and will call ``repost_card`` shortly. While the pair is in this set, +# ``update_session_card`` buffers events instead of spawning a fresh +# card — see ``_should_buffer`` reason 3. Populated/cleared by +# ``begin_repost_intent`` / ``end_repost_intent``. +_repost_intent: set[tuple[int, str]] = set() + + +def begin_repost_intent(user_id: int, session_id: str) -> None: + """Mark (user, session) as repost-in-progress so concurrent + claude events buffer instead of spawning their own card. + + Idempotent: re-marking a still-set pair is a no-op. Call + ``end_repost_intent`` AFTER ``repost_card`` (success or failure) + so the buffer drains. The buffer is the spawn-race fix's safety + net — even if ``repost_card`` itself fails, ``end_repost_intent`` + lets normal rendering resume on the next event. + """ + _repost_intent.add((user_id, session_id)) + + +def end_repost_intent(user_id: int, session_id: str) -> None: + """Clear the repost-in-progress flag set by ``begin_repost_intent``. + + Safe to call when no flag is set. + """ + _repost_intent.discard((user_id, session_id)) + + +def reset_card(user_id: int, session_id: str) -> None: + """Drop the cached card so the next event creates a fresh message.""" + _cards.pop((user_id, session_id), None) + + +def _legacy(name: str): + """Resolve a patch-sensitive dependency from the notifications facade.""" + import sys + + facade = sys.modules.get("ccbot.handlers.notifications") + if facade is None or not hasattr(facade, name): + raise RuntimeError(f"notifications facade is missing {name}") + return getattr(facade, name) diff --git a/src/ccbot/handlers/card_rich_media.py b/src/ccbot/handlers/card_rich_media.py new file mode 100644 index 00000000..2178d895 --- /dev/null +++ b/src/ccbot/handlers/card_rich_media.py @@ -0,0 +1,169 @@ +"""Rich Markdown transport for live cards with an inline pane image.""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import Any + +from telegram import InlineKeyboardMarkup, Message +from telegram.error import BadRequest, RetryAfter + +from .. import rich +from ..config import config +from ..session import session_manager +from .card_binding import bind_carrier, clear_carrier +from .card_types import CardState, CarrierKind +from .card_registry import lookup_session_for_message +from .kb_mode import _capture_pane_png + +logger = logging.getLogger(__name__) + +# Keep the same visible gap used between card events. Telegram collapses an +# empty ``


`` around media, while a non-breaking-space paragraph is +# preserved as its own rich block by every transport path. +_MEDIA_SPACER = "\u00a0" + + +@dataclass(frozen=True) +class RichCardSend: + """A sent rich-media carrier and its reusable Telegram photo id.""" + + message: Message + photo_file_id: str + + +async def send_rich_media_card( + bot: Any, + user_id: int, + state: CardState, + text: str, + pane_png: bytes, + *, + reply_markup: InlineKeyboardMarkup | None, +) -> RichCardSend | None: + """Send a rich card containing ``pane_png``; None requests legacy fallback.""" + if not config.rich_messages: + return None + try: + message = await rich.send_rich_message( + bot, + user_id, + _rich_card_markdown(text, state), + reply_markup=reply_markup, + photo=pane_png, + disable_notification=True, + ) + except RetryAfter: + raise + except Exception as exc: + logger.warning("rich-media card send failed chat=%s: %s", user_id, exc) + return None + return RichCardSend( + message=message, + photo_file_id=rich.extract_rich_photo_file_id(message) or "", + ) + + +async def edit_rich_media_card( + bot: Any, + user_id: int, + state: CardState, + *, + text: str, + reply_markup: InlineKeyboardMarkup | None, + min_photo_interval: float, + refresh_pane: bool = True, +) -> bool: + """Edit text while keeping the terminal screenshot at its card anchor.""" + if state.msg_id is None or not config.rich_messages: + return False + + sess_id = lookup_session_for_message(user_id, state.msg_id) + sess = session_manager.get_session(sess_id) if sess_id else None + window_id = sess.window_id if sess is not None else "" + elapsed = time.monotonic() - state.last_photo_edit_ts + + photo: bytes | str | None = state.rich_media_file_id or None + pane_hash = state.last_pane_hash + uploaded_new_pane = False + if window_id and (photo is None or refresh_pane and elapsed >= min_photo_interval): + png, captured_hash = await _capture_pane_png(window_id) + if ( + png is not None + and captured_hash + and (photo is None or captured_hash != state.last_pane_hash) + ): + photo = png + pane_hash = captured_hash + uploaded_new_pane = True + + if photo is None: + logger.warning( + "rich-media card has no reusable pane photo msg=%s", state.msg_id + ) + return False + + try: + result = await rich.edit_rich_message( + bot, + user_id, + state.msg_id, + _rich_card_markdown(text, state), + reply_markup=reply_markup, + photo=photo, + ) + except RetryAfter: + raise + except BadRequest as exc: + error = str(exc) + if "message is not modified" in error.lower(): + return True + if _is_lost_carrier(error): + logger.info( + "rich-media card lost carrier msg=%s err=%s", state.msg_id, error + ) + clear_carrier(state) + return False + logger.warning("rich-media card edit failed msg=%s: %s", state.msg_id, error) + return False + except Exception as exc: + logger.warning("rich-media card edit failed msg=%s: %s", state.msg_id, exc) + return False + + if uploaded_new_pane: + new_file_id = rich.extract_rich_photo_file_id(result) + # If a local Bot API proxy returned only True, do not reuse the old + # id on the next text edit: that would restore the previous image. + bind_carrier( + state, + state.msg_id, + CarrierKind.RICH_MEDIA, + rich_media_file_id=new_file_id or "", + pane_hash=pane_hash, + photo_edit_ts=time.monotonic(), + ) + return True + + +def _rich_card_markdown(text: str, state: CardState) -> str: + """Insert the spaced photo before context/background service metadata.""" + offset = state.media_anchor_offset + if offset <= 0 or offset > len(text): + return rich.to_rich_markdown(text) + body = rich.to_rich_markdown(text[:offset]).rstrip() + service_tail = rich.to_rich_markdown(text[offset:].lstrip()).lstrip() + parts = [body, _MEDIA_SPACER, rich.RICH_PHOTO_ANCHOR, _MEDIA_SPACER] + if service_tail: + parts.append(service_tail) + return "\n\n".join(parts) + + +def _is_lost_carrier(error: str) -> bool: + lowered = error.lower() + return ( + "message to edit not found" in lowered + or "message can't be edited" in lowered + or "message_id_invalid" in lowered + ) diff --git a/src/ccbot/handlers/card_seed.py b/src/ccbot/handlers/card_seed.py new file mode 100644 index 00000000..c0d414e5 --- /dev/null +++ b/src/ccbot/handlers/card_seed.py @@ -0,0 +1,240 @@ +"""Seed live-card state from an existing Claude or Codex transcript.""" + +from __future__ import annotations + +from __future__ import annotations + +import logging +import sys +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import cast + + +from ..session import Session, session_manager +from ..session_monitor import NewMessage +from .card_model import ( + CARD_SEED_TURNS, + CardState, + Event, + _apply_tool_result, + _build_event, +) + +from .card_registry import _cards + +logger = logging.getLogger(__name__) + +SeedLoader = Callable[..., Awaitable[list[Event]]] + + +__all__ = [ + "get_card_state", + "_seed_events_from_jsonl", + "_transcript_mtime", + "_ensure_seeded", +] + + +def get_card_state(user_id: int, sess: Session) -> CardState: + return _cards.setdefault((user_id, sess.id), CardState()) + + +async def _seed_events_from_jsonl( + sess: Session, max_turns: int = CARD_SEED_TURNS +) -> list[Event]: + """Build a list[Event] from the session's JSONL transcript. + + Pulls the last ``max_turns`` end-of-turn boundaries so the card has + visible history after a bot restart (when in-memory ``state.events`` + is empty). Returns ``[]`` on any failure — caller just continues + with an empty card. + + ``max_turns`` defaults to the module constant but is overridden by + ``_ensure_seeded`` from the user's ``card_history`` setting. + """ + if not sess.window_id: + return [] + # Derive the transcript path by pure path math instead of + # ``resolve_session_for_window`` — the latter fully walks the JSONL + # just to refresh summary/token stats we don't use here, then we read + # the file again below. On a multi-MB resumed transcript that wasted + # walk costs >1s. Same fast-path the /history cache already uses. + state = session_manager.get_window_state(sess.window_id) + if not state.session_id or not state.cwd: + return [] + if state.transcript_path: + fp = Path(state.transcript_path) + elif sess.backend == "codex": + from ..codex_session_io import build_session_file_path + + fp = build_session_file_path(state.session_id, state.cwd) + else: + from ..session_claude_io import build_session_file_path + + fp = build_session_file_path(state.session_id, state.cwd) + if fp is None or not fp.exists(): + return [] + file_path = str(fp) + import json as _json + from pathlib import Path as _Path + + from ..transcript_parser import TranscriptParser + + try: + raw = _Path(file_path).read_text(encoding="utf-8", errors="replace") + except OSError as e: + logger.debug("seed: read JSONL %s failed: %s", file_path, e) + return [] + raw_entries: list[dict[str, object]] = [] + for line in raw.splitlines(): + if not line.strip(): + continue + try: + raw_entries.append(_json.loads(line)) + except Exception: + continue + try: + parsed_list, _ = TranscriptParser.parse_entries(raw_entries, pending_tools=None) + except Exception as e: + logger.debug("seed: parse_entries failed: %s", e) + return [] + + # Walk backwards collecting indices of end_turn boundaries (final + # assistant text). Keep only entries from the last CARD_SEED_TURNS + # boundaries — earlier history stays in JSONL for /screenshot or + # other history paths. + end_turn_idxs: list[int] = [] + for i in range(len(parsed_list) - 1, -1, -1): + p = parsed_list[i] + if ( + getattr(p, "role", "") == "assistant" + and getattr(p, "content_type", "") == "text" + and getattr(p, "stop_reason", "") + in ("end_turn", "stop_sequence", "max_tokens") + ): + end_turn_idxs.append(i) + if len(end_turn_idxs) >= max_turns: + break + if end_turn_idxs: + start_idx = end_turn_idxs[-1] + # Pull a few entries back from start_idx so the user message that + # triggered the oldest kept turn is visible at the top. + start_idx = max(0, start_idx - 4) + else: + start_idx = max(0, len(parsed_list) - 80) + tail = parsed_list[start_idx:] + + # Convert ParsedEntry → NewMessage → Event. tool_results fold into + # matching tool_use via _apply_tool_result; on miss they append. + pseudo_state = CardState() + events = pseudo_state.events + for p in tail: + ct = getattr(p, "content_type", "text") + msg = NewMessage( + session_id="seed", + text=getattr(p, "text", "") or "", + is_complete=True, + content_type=ct, + tool_use_id=getattr(p, "tool_use_id", None), + role=getattr(p, "role", "assistant"), + tool_name=getattr(p, "tool_name", None), + image_data=getattr(p, "image_data", None), + stop_reason=getattr(p, "stop_reason", None), + timestamp=getattr(p, "timestamp", "") or "", + ) + ev = _build_event(msg) + if ct == "tool_result" and _apply_tool_result(pseudo_state, ev): + continue + events.append(ev) + return events + + +def _legacy_seed_loader() -> SeedLoader: + """Resolve the notifications facade's monkeypatchable seed loader.""" + facade = sys.modules.get(f"{__package__}.notifications") + if facade is None: + return _seed_events_from_jsonl + candidate = getattr(facade, "_seed_events_from_jsonl", _seed_events_from_jsonl) + return cast(SeedLoader, candidate) + + +def _transcript_mtime(sess: Session) -> float: + """Return the mtime (epoch seconds) of the session's JSONL transcript, + or -1.0 if the path can't be resolved / the file is missing. + + Cheap (single ``stat``) — used by ``_ensure_seeded`` to gate empty-seed + retries on a restored session without re-parsing the whole transcript. + """ + if not sess.window_id: + return -1.0 + state = session_manager.get_window_state(sess.window_id) + if not state.session_id or not state.cwd: + return -1.0 + if state.transcript_path: + fp = Path(state.transcript_path) + elif sess.backend == "codex": + from ..codex_session_io import build_session_file_path + + fp = build_session_file_path(state.session_id, state.cwd) + else: + from ..session_claude_io import build_session_file_path + + fp = build_session_file_path(state.session_id, state.cwd) + if fp is None: + return -1.0 + try: + return fp.stat().st_mtime + except OSError: + return -1.0 + + +async def _ensure_seeded(user_id: int, sess: Session, state: CardState) -> None: + """Seed ``state.events`` from JSONL on first access after restart. + + No-op when events already exist. Latches ``seed_attempted`` only on a + *successful* (non-empty) seed: a freshly restored (``claude --resume``) + session builds its card before claude has flushed the resumed transcript + to disk, so an early read returns [] — latching then would block the + seed forever and the history would never reach the card. An empty read + instead leaves the flag clear and retries on a later event, gated on the + transcript mtime advancing (``state.seed_mtime``) so a burst of events + during the resume window doesn't re-parse a multi-MB JSONL each time. A + wipe site that wants a re-seed clears ``seed_attempted`` + ``seed_mtime`` + (see ``CardState.seed_attempted``). + """ + if state.events: + return + if state.seed_attempted: + return + mtime = _transcript_mtime(sess) + if mtime >= 0.0 and mtime == state.seed_mtime: + # Nothing new on disk since the last empty attempt — skip the + # re-parse and wait for the transcript to grow. + return + state.seed_mtime = mtime + # User-settable depth — Settings → Card history (10/20/50/100). + try: + max_turns = int( + session_manager.get_user_settings(user_id).get( + "card_history", CARD_SEED_TURNS + ) + ) + except (TypeError, ValueError): + max_turns = CARD_SEED_TURNS + seeded = await _legacy_seed_loader()(sess, max_turns=max_turns) + if seeded: + state.events = seeded + state.seed_attempted = True + logger.info( + "card_seeded user=%d sess=%s events=%d", + user_id, + sess.id, + len(seeded), + extra={ + "event": "card_seeded", + "user_id": user_id, + "session_id": sess.id, + "events": len(seeded), + }, + ) diff --git a/src/ccbot/handlers/card_stall.py b/src/ccbot/handlers/card_stall.py new file mode 100644 index 00000000..6b878d3a --- /dev/null +++ b/src/ccbot/handlers/card_stall.py @@ -0,0 +1,280 @@ +"""Keep silent unfinished turns observable through their live terminal pane.""" + +from __future__ import annotations + +import logging +import time + +from telegram import Bot + +from ..session import Session, session_manager +from . import bg_status +from .card_binding import clear_carrier, restore_carrier, snapshot_carrier +from .card_model import ( + _card_is_busy, +) +from .card_types import TurnPhase + +from .card_registry import ( + _cards, + _card_lock, + _legacy, +) +from .card_seed import get_card_state + +logger = logging.getLogger(__name__) + + +__all__ = [ + "is_card_in_menu_view", + "is_card_finalized", + "is_card_busy", + "STALL_FINALIZE_AFTER_SECONDS", + "STALL_FINALIZE_TOOL_USE_SECONDS", + "maybe_finalize_stalled", + "is_active_for_user", + "repost_card", +] + + +def is_card_in_menu_view(user_id: int, session_id: str) -> bool: + """True if the user is currently browsing a Menu / sub-screen on + this session's card. Used by ``status_polling`` to gate the TYPING + indicator — firing it while the user navigates menus is just noise. + """ + state = _cards.get((user_id, session_id)) + return state is not None and state.in_menu_view + + +def is_card_finalized(user_id: int, session_id: str) -> bool: + """True when the card's tail event is a terminal one (``final_text`` + or ``error``). Used by ``status_polling`` to suppress a stale pane + spinner (e.g. ``Sautéed for 11m 16s · 1 shell still running``) that + persists in scrollback after end-of-turn — without this check the + typing indicator stays on forever waiting for the user-visible + spinner string to scroll off. + """ + state = _cards.get((user_id, session_id)) + if state is None or not state.events: + return False + return state.events[-1].type in ("final_text", "error") + + +def is_card_busy(user_id: int, session_id: str) -> bool: + """True when the user's live card for ``session_id`` is currently in + flight AND visible (msg_id set, finalize_task hasn't run, and the + card is not paused for menu navigation). Used by the polling-based + typing-indicator path — TYPING should fire while a turn is mid- + stream during silent gaps between events, but NOT while the user + is browsing the inline ⋯ Menu / sub-screens. While ``in_menu_view`` + the card buffers events without rendering to chat, so a "typing…" + indicator there is just noise. + """ + state = _cards.get((user_id, session_id)) + if state is None or state.in_menu_view: + return False + return _card_is_busy(state) + + +# ─── Silent unfinished-turn observation ────────────────────────────── +# A silent turn must not be guessed complete. Once its conservative idle +# threshold elapses, keep the active card RUNNING and periodically refresh its +# terminal pane. The user can then see whether the process resumes or changes +# without receiving a synthetic final answer or a separate warning push. + +# How long an active card may sit with a non-terminal tail event and an +# idle (non-busy) pane before we declare it stalled and finalise it. +# Deliberately generous: a genuinely-busy claude keeps the pane spinner +# *changing* (``Working… (17s)`` → ``(18s)`` → …) so ``pane_busy`` stays +# True and this never fires during long thinking / a slow tool. We only +# trip when the spinner is gone or frozen AND no new renderable event +# arrived for this long — i.e. the subprocess produced nothing. +# +# Two-tier threshold by tail event type. Tools legitimately run for +# minutes (slow Bash, CHYT, Map-Reduce, network) and Claude routinely +# spends a comparable amount of time reasoning after the last tool +# result before emitting the final assistant turn — both produce a +# silent JSONL tail of ``tool_use``. Pre-textual silence (``text`` / +# ``thinking`` tail) is rarer and more suspicious because Claude is +# mid-emit, so the original threshold still applies there. +STALL_FINALIZE_AFTER_SECONDS = 90.0 +STALL_FINALIZE_TOOL_USE_SECONDS = 300.0 + +_STALL_PANE_REFRESH_SECONDS = 3.0 + + +async def maybe_finalize_stalled( + bot: Bot, + user_id: int, + sess: Session, + *, + pane_busy: bool, + interactive_waiting: bool, + in_menu: bool, + now: float | None = None, +) -> bool: + """Keep an active silent turn RUNNING and refresh only its live pane. + + Fires (returns True after finalising) ONLY when ALL hold: + + * a card exists for this (user, session) with at least one event; + * the card is NOT already finalized (tail event is non-terminal — + mid ``thinking`` / ``tool_use`` / ``text``); + * the pane spinner is NOT busy (``pane_busy=False`` — gone or + frozen, per ``_pane_status_is_changing``); + * no interactive UI is waiting for the user (``interactive_waiting`` + — AskUserQuestion / ExitPlanMode / Permission / RestoreCheckpoint, + or kb-mode) and the card is not in a Menu sub-screen + (``in_menu``); + * no new renderable event arrived for ``STALL_FINALIZE_AFTER_SECONDS`` + (measured from ``state.last_event_ts``). + + The last condition is what keeps this conservative: a long-thinking + turn keeps the pane spinner changing, so ``pane_busy`` is True and we + bail; a tool_use legitimately awaiting a slow result either keeps the + spinner alive or lands its result well before the window elapses. We + only trip when the spinner has died AND the transcript stopped + growing — the exact fingerprint of a stalled / exited subprocess. + + No final event and no Telegram push are produced. An active session keeps + its pane visible; a background session gets only the ``stalled`` status + marker used by the background-session panel. + """ + state = _cards.get((user_id, sess.id)) + active = is_active_for_user(user_id, sess) + if state is not None and pane_busy and state.stall_watch_active: + state.stall_watch_active = False + state.last_stall_pane_refresh_ts = 0.0 + if not active and bg_status.update_status(user_id, sess.id, "working"): + await _legacy("refresh_panel")(bot, user_id) + if pane_busy or interactive_waiting or (in_menu and active): + return False + if state is None or not state.events: + return False + if (active and state.in_menu_view) or state.in_kb_mode: + return False + # Already finalized — nothing frozen to rescue. + tail_type = state.events[-1].type + if tail_type in ("final_text", "error"): + return False + if state.last_event_ts <= 0: + return False + when = now if now is not None else time.time() + threshold = ( + STALL_FINALIZE_TOOL_USE_SECONDS + if tail_type == "tool_use" + else STALL_FINALIZE_AFTER_SECONDS + ) + if (when - state.last_event_ts) < threshold: + return False + if not state.stall_watch_active: + logger.warning( + "stall_watch user=%d sess=%s wid=%s idle=%.0fs tail=%s threshold=%.0fs", + user_id, + sess.id, + sess.window_id, + when - state.last_event_ts, + tail_type, + threshold, + ) + state.stall_watch_active = True + state.turn_phase = TurnPhase.RUNNING + if not active: + if bg_status.update_status(user_id, sess.id, "stalled"): + await _legacy("refresh_panel")(bot, user_id) + return True + if state.msg_id is None: + return False + refresh_now = time.monotonic() + if refresh_now - state.last_stall_pane_refresh_ts >= _STALL_PANE_REFRESH_SECONDS: + text = _legacy("_render_card")(sess, state, user_id=user_id) + if await _legacy("_edit_card")(bot, user_id, state, text=text): + state.last_rendered = text + state.last_edit_ts = refresh_now + state.last_stall_pane_refresh_ts = refresh_now + return True + + +def is_active_for_user(user_id: int, sess: Session) -> bool: + active = session_manager.get_active_session(user_id) + return active is not None and active.id == sess.id + + +async def repost_card(bot: Bot, user_id: int, sess: Session) -> None: + """Send a fresh live-card below the user's latest message, and drop + the previous one if it exists. + + Called from text_handler on every user-msg dispatch (the legacy + ``card_position`` setting was retired; always-repost is now the + single canonical behaviour). The user always sees a bot-side card + immediately below the message they just typed instead of having + to wait for claude's first event — which may come seconds later + when the model spends a while in thinking before any tool call. + + No-op only when the card is paused (Menu / sub-screen open). In all + other cases — including the post-finalize_task state where the + previous live card was already pinned + reset — we seed a fresh + card so it lands below the user's typed line. When claude's first + event arrives it will edit *this* card (state.msg_id is now set) + instead of spawning a separate one above the user msg. + """ + state = _cards.get((user_id, sess.id)) + if state is not None and state.in_menu_view: + return + state = get_card_state(user_id, sess) + # Seed history from JSONL on first call after a bot restart so the + # reposted card lands with full context, not an empty body. + await _legacy("_ensure_seeded")(user_id, sess, state) + if state.pending_edit is not None and not state.pending_edit.done(): + state.pending_edit.cancel() + state.pending_edit = None + + # Lock the msg_id mutation + spawn so a parallel + # ``update_session_card`` (for a claude event arriving mid-typing) + # can't see the brief ``msg_id is None`` window and spawn its own + # card too — Task #50. + async with _card_lock(user_id, sess.id): + old_binding = snapshot_carrier(state) + old_msg_id = clear_carrier(state) # force a fresh Telegram message + + text = _legacy("_render_card")(sess, state, user_id=user_id) + sent = await _legacy("_send_card")(bot, user_id, sess, state, text=text) + if sent is False or state.msg_id is None: + restore_carrier(state, old_binding) + state.last_rendered = text + state.last_edit_ts = time.monotonic() + # A freshly (re)posted card is brand new — reset the freshness + # clock so the first arriving claude event can't misjudge it as + # stale and spawn a SECOND card ~1-2s later (the delete+resend + # flicker). ``repost_card`` previously updated last_rendered / + # last_edit_ts but left ``last_event_ts`` pinned to the previous + # turn; on a card idle >= STALE_CARD_SECONDS that tripped + # ``_is_stale`` on the very next event. A repost is itself user + # activity, so "now" is the correct freshness stamp. + state.last_event_ts = time.time() + new_msg_id = state.msg_id + logger.info( + "repost_card user=%s sess=%s old_msg=%s new_msg=%s events=%d", + user_id, + sess.id, + old_msg_id, + new_msg_id, + len(state.events), + ) + if old_msg_id and new_msg_id and new_msg_id != old_msg_id: + try: + await bot.delete_message(chat_id=user_id, message_id=old_msg_id) + logger.info( + "repost_card deleted_old user=%s sess=%s msg=%s", + user_id, + sess.id, + old_msg_id, + ) + except Exception as e: + logger.warning( + "repost_card delete_old_failed user=%s sess=%s msg=%s err=%s", + user_id, + sess.id, + old_msg_id, + e, + ) diff --git a/src/ccbot/handlers/card_surface.py b/src/ccbot/handlers/card_surface.py new file mode 100644 index 00000000..8d4fae7c --- /dev/null +++ b/src/ccbot/handlers/card_surface.py @@ -0,0 +1,275 @@ +"""Schedule receipt surfaces, panel refreshes, shutdown, and timer ticks.""" + +from __future__ import annotations + +import asyncio +import logging +import time + +from telegram import Bot + +from ..session import Session, session_manager +from .card_binding import clear_carrier, restore_carrier, snapshot_carrier +from .card_model import ( + _latest_inflight_idx, + _resolved_page_idx, + paginate_events_for_card, +) + +from .card_registry import ( + _cards, + _card_surface_tasks, + _card_lock, + _carrier_edit_lock, + _legacy, +) +from .card_seed import get_card_state + +logger = logging.getLogger(__name__) + + +__all__ = [ + "surface_card_after_message", + "schedule_card_after_message", + "shutdown_card_surface_tasks", + "refresh_panel", + "CARD_TIMER_TICK_SECONDS", + "card_timer_loop", +] + + +async def surface_card_after_message( + bot: Bot, + user_id: int, + sess: Session, + message_id: int, +) -> bool: + """Make the active session card the only receipt for an inbound message. + + Telegram message ids are monotonic within a chat. The card therefore + acknowledges queue admission simply by sitting below ``message_id``. The + position check and repost are serialized so several messages arriving in + one burst converge on one newest card instead of spawning one per task. + """ + state = get_card_state(user_id, sess) + await _legacy("_ensure_seeded")(user_id, sess, state) + old_msg_id: int | None = None + new_msg_id: int | None = None + + async with _card_lock(user_id, sess.id): + async with _carrier_edit_lock(user_id): + if not _legacy("is_active_for_user")(user_id, sess): + return False + if state.msg_id is not None and state.msg_id > message_id: + return True + + if state.pending_edit is not None and not state.pending_edit.done(): + state.pending_edit.cancel() + state.pending_edit = None + # Sending content is an explicit return from a menu/sub-screen to + # the live conversation. Otherwise the old menu card would stay + # above the message and acceptance would remain invisible. + state.in_menu_view = False + old_binding = snapshot_carrier(state) + old_msg_id = clear_carrier(state) + text = _legacy("_render_card")(sess, state, user_id=user_id) + await _legacy("_send_card")(bot, user_id, sess, state, text=text) + new_msg_id = state.msg_id + if new_msg_id is None: + # Telegram send failed: retain the existing carrier binding so + # later events can recover instead of orphaning a valid card. + restore_carrier(state, old_binding) + return False + state.last_rendered = text + state.last_edit_ts = time.monotonic() + state.last_event_ts = time.time() + + logger.info( + "card_surface user=%s sess=%s after=%s old_msg=%s new_msg=%s", + user_id, + sess.id, + message_id, + old_msg_id, + new_msg_id, + ) + if old_msg_id and new_msg_id != old_msg_id: + try: + await bot.delete_message(chat_id=user_id, message_id=old_msg_id) + except Exception as exc: + logger.warning( + "card_surface delete_old_failed user=%s sess=%s msg=%s err=%s", + user_id, + sess.id, + old_msg_id, + exc, + ) + return True + + +def schedule_card_after_message( + bot: Bot, + user_id: int, + sess: Session, + message_id: int, +) -> asyncio.Task[bool]: + """Schedule a non-blocking card receipt for Telegram intake.""" + task = asyncio.create_task( + surface_card_after_message(bot, user_id, sess, message_id), + name=f"card-surface:{user_id}:{sess.id}:{message_id}", + ) + _card_surface_tasks.add(task) + + def _finished(done: asyncio.Task[bool]) -> None: + _card_surface_tasks.discard(done) + if done.cancelled(): + return + try: + exc = done.exception() + except asyncio.CancelledError: + return + if exc is not None: + logger.warning( + "card_surface failed user=%s sess=%s after=%s err=%s", + user_id, + sess.id, + message_id, + exc, + ) + + task.add_done_callback(_finished) + return task + + +async def shutdown_card_surface_tasks() -> None: + """Cancel outstanding intake card moves during application shutdown.""" + tasks = list(_card_surface_tasks) + for task in tasks: + if not task.done(): + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + _card_surface_tasks.clear() + + +async def refresh_panel(bot: Bot, user_id: int, *, immediate: bool = False) -> bool: + """Re-render the active session's live card so the bg-status panel + (and active quota glyph) reflects the latest bg_status state. + + No-op when: + - the user has no active session + - the active session has no live card yet + - the card is paused (menu/sub-screen open) + - a deferred edit is already queued, unless ``immediate`` is True + + Interactive actions such as pagination pass ``immediate=True``. That + cancels the live-update debounce and paints the requested page now, + instead of making the button appear stuck until ``live_lag`` expires. + """ + active = session_manager.get_active_session(user_id) + if active is None: + return False + state = _cards.get((user_id, active.id)) + if state is None or state.msg_id is None or state.in_menu_view: + return False + original_msg_id = state.msg_id + if state.pending_edit is not None and not state.pending_edit.done(): + if not immediate: + return True + pending = state.pending_edit + if not state.pending_edit_in_flight: + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + state.pending_edit = None + # The awaited task may have yielded long enough for a carrier or + # active-session hand-off. Never paint the old page onto a new owner. + current_active = session_manager.get_active_session(user_id) + if ( + current_active is None + or current_active.id != active.id + or _cards.get((user_id, active.id)) is not state + or state.msg_id != original_msg_id + or state.in_menu_view + ): + return False + text = _legacy("_render_card")(active, state, user_id=user_id) + if text == state.last_rendered: + return True + if await _legacy("_edit_card")( + bot, + user_id, + state, + text=text, + refresh_pane=not immediate, + ): + state.last_rendered = text + state.last_edit_ts = time.monotonic() + return True + return False + + +# ─── Tool-timer tick ────────────────────────────────────────────────── + +# How often to re-render the active card to advance the ⏳ M:SS counter +# on the latest in-flight tool/thinking entry. Matches the +# session_monitor poll cadence (2 s) so the card feels as responsive as +# Telegram's own "typing…" indicator — per-user feedback on pivot #39. +# Inline-screenshot cards are additionally throttled by +# ``_PHOTO_EDIT_MIN_INTERVAL`` (2.5 s) so editMessageMedia bursts stay +# within Telegram's limits. +CARD_TIMER_TICK_SECONDS = 2.0 + + +async def card_timer_loop(bot: Bot) -> None: + """Background task that ticks the elapsed timer on the latest + in-flight tool/thinking entry of each user's active card. + + Skips: + - cards with no msg_id + - paused cards (in_menu_view) + - users whose pagination puts them on a non-latest page (timer + only ticks on the page where the in-flight event lives, i.e. + the latest page) + - cards with a pending deferred edit (the deferred edit picks up + the updated timer when it fires) + """ + logger.info("card_timer_loop started tick=%.1fs", CARD_TIMER_TICK_SECONDS) + while True: + try: + await asyncio.sleep(CARD_TIMER_TICK_SECONDS) + for (uid, sid), state in list(_cards.items()): + try: + if state.msg_id is None or state.in_menu_view: + continue + sess = session_manager.get_session(sid) + if sess is None: + continue + # Only the user's currently-active session ticks. + active = session_manager.get_active_session(uid) + if active is None or active.id != sid: + continue + pages = paginate_events_for_card(state, uid) + idx = _resolved_page_idx(state, len(pages)) + # Timer renders only on the latest page. + if idx != len(pages) - 1: + continue + if _latest_inflight_idx(pages[idx]) is None: + continue + # Skip when an edit is already queued — it'll pick + # up the fresh timer value when it fires. + if state.pending_edit is not None and not state.pending_edit.done(): + continue + text = _legacy("_render_card")(sess, state, user_id=uid) + if text == state.last_rendered: + continue + if await _legacy("_edit_card")(bot, uid, state, text=text): + state.last_rendered = text + state.last_edit_ts = time.monotonic() + except asyncio.CancelledError: + raise + except Exception as e: + logger.debug("card_timer tick failed for sess=%s: %s", sid, e) + except asyncio.CancelledError: + logger.info("card_timer_loop cancelled") + break + except Exception as e: + logger.warning("card_timer_loop error: %s", e) diff --git a/src/ccbot/handlers/card_text.py b/src/ccbot/handlers/card_text.py new file mode 100644 index 00000000..821fe29e --- /dev/null +++ b/src/ccbot/handlers/card_text.py @@ -0,0 +1,191 @@ +"""Parse and sanitize transcript text before it becomes a card event.""" + +from __future__ import annotations + +import re +import time + +__all__ = [ + "_trim", + "_EXPQUOTE_BLOCK_RE", + "_EXPQUOTE_ANY_RE", + "_EXPQUOTE_INNER_RE", + "_extract_expquote_inner", + "_strip_for_card", + "_parse_timestamp", + "_TOOL_HEAD_RE", + "_split_tool_text", +] + + +def _trim(s: str, limit: int = 200) -> str: + s = s.replace("\n", " ").strip() + if len(s) > limit: + return s[: limit - 1] + "…" + return s + + +_EXPQUOTE_BLOCK_RE = re.compile( + r"\x02EXPQUOTE_START\x02.*?\x02EXPQUOTE_END\x02", + re.DOTALL, +) +# Drop residual EXPQUOTE_START / EXPQUOTE_END sentinels that didn't +# pair up (transcript_format builds tool blocks with nested sentinels; +# the outer pair gets stripped but the inner one can leak in body). +_EXPQUOTE_ANY_RE = re.compile(r"\x02EXPQUOTE_(?:START|END)\x02") +# Pull the inner content out of an EXPQUOTE_START / END pair. +_EXPQUOTE_INNER_RE = re.compile( + r"\x02EXPQUOTE_START\x02(.*?)\x02EXPQUOTE_END\x02", + re.DOTALL, +) + + +def _extract_expquote_inner(text: str) -> str: + """Return the content between the FIRST EXPQUOTE_START / END pair.""" + m = _EXPQUOTE_INNER_RE.search(text or "") + return m.group(1) if m else "" + + +def _strip_for_card(text: str) -> str: + """Strip residue that would render literally in MarkdownV2 mode. + + Card text is now sent with ``parse_mode=MarkdownV2`` (via + ``send_with_fallback`` / ``_send_card_md``), so MarkdownV2 markers + like ``**bold**`` get rendered properly. We only strip: + + * The full ``EXPQUOTE_START … EXPQUOTE_END`` block when it appears + INSIDE a head line (heads are one-liners; the embedded quote + belongs in the body, not the head). + * Any orphan ``EXPQUOTE_*`` sentinel that escaped pair-matching. + * ``$HOME`` → ``~`` so long Mac paths don't waste 30+ chars. + + The MarkdownV2 ``convert_markdown`` step inside ``send_with_fallback`` + handles escaping special chars and expanding paired EXPQUOTE blocks + into expandable blockquote syntax. + """ + import os + + out = _EXPQUOTE_BLOCK_RE.sub("", text) + out = _EXPQUOTE_ANY_RE.sub("", out) + home = os.path.expanduser("~") + if home and home != "/": + out = out.replace(home, "~") + return out + + +def _parse_timestamp(ts: str) -> float: + """Parse ISO-8601 timestamp from a JSONL entry into epoch seconds. + + Returns ``time.time()`` when the input is empty or unparseable so + callers can use the result unconditionally as an ``started_at``. + """ + if not ts: + return time.time() + try: + import datetime as _dt + + # Tolerate trailing Z + offset forms; fromisoformat handles "+HH:MM" + # natively but historically chokes on "Z". + return _dt.datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp() + except (ValueError, TypeError): + return time.time() + + +_TOOL_HEAD_RE = re.compile( + r"^\s*\**(?P[A-Za-z][\w-]*)\**\s*\((?P.*)\)\s*$", re.DOTALL +) + + +def _split_tool_text(raw: str) -> tuple[str, str, str, str]: + """Split transcript_format's tool text into name / args / summary / content. + + ``raw`` reaches us in the shape:: + + **ToolName**(args) ← head_block (can span multiple + lines if args = bash heredoc / + multi-line edit diff) + ⎿ Output N lines ← summary line (optional) + \\x02EXPQUOTE_START\\x02\\x02EXPQUOTE_END\\x02 + + The summary line starts with whitespace + ``⎿``. Everything before + that marker (or before the EXPQUOTE_START sentinel, whichever comes + first) is the head_block — possibly multiple lines when args + contains literal newlines (bash heredoc). + + Returns ``(name, args, summary, content)`` where: + + * ``name`` = bare tool name (``Bash``, ``Read``, ``Edit``). + * ``args`` = whatever was between the outermost parens — pushed + under the spoiler so long commands don't blow up the head line. + * ``summary`` = ``⎿`` line content (``Output 5 lines``). + * ``content`` = inside the EXPQUOTE block, minus duplicate head / + summary lines that transcript_parser sometimes re-embeds. + + When the head doesn't parse as ``Name(args)`` (orphan tool_result + fallback or weird format), the full head_block lands in ``name`` + and ``args`` is empty. + """ + if not raw: + return "", "", "", "" + + # Locate end-of-head: the first ``\n ⎿`` summary marker OR the + # first ``\x02EXPQUOTE_START\x02`` content marker, whichever comes + # earlier. Whatever's BEFORE that boundary is the head_block (may + # span multiple lines when args is a bash heredoc). + summary_marker_re = re.compile(r"\n\s*⎿") + summary_match = summary_marker_re.search(raw) + quote_idx = raw.find("\x02EXPQUOTE_START\x02") + head_end = len(raw) + if summary_match is not None: + head_end = min(head_end, summary_match.start()) + if quote_idx >= 0: + head_end = min(head_end, quote_idx) + head_block = raw[:head_end].rstrip("\n") + + name = _strip_for_card(head_block) + args = "" + m = _TOOL_HEAD_RE.match(head_block) + if m: + name = m.group("name").strip() + args = m.group("args").strip() + # The first-line ``Name(`` prefix being matched means the regex + # already used DOTALL — args may legitimately contain newlines. + + summary = "" + after_head = raw[head_end:] + if after_head.startswith("\n"): + after_head = after_head[1:] + # Pull the summary line if it's first. + if after_head.lstrip(" ").startswith("⎿"): + nl = after_head.find("\n") + if nl == -1: + summary_line = after_head + after_head = "" + else: + summary_line = after_head[:nl] + after_head = after_head[nl + 1 :] + summary = _strip_for_card(summary_line.lstrip(" ").lstrip("⎿").strip()) + + # ``after_head`` is now either an EXPQUOTE block or plain rest. + inner = _extract_expquote_inner(after_head) if after_head else "" + content = inner if inner else after_head + # Drop duplicate head/summary rows that transcript_parser may + # re-embed at the top of the EXPQUOTE block. + if content: + content_lines = content.split("\n") + first_norm = _strip_for_card(content_lines[0]).strip() + head_norm = _strip_for_card(head_block).strip() + if ( + first_norm == head_norm + or (head_norm and first_norm.endswith(head_norm)) + or ( + first_norm.startswith(("✓ ", "▷ ", "✗ ")) + and head_norm + and head_norm in first_norm + ) + ): + content_lines = content_lines[1:] + if content_lines and content_lines[0].lstrip().startswith("⎿"): + content_lines = content_lines[1:] + content = "\n".join(content_lines).strip("\n") + return name, args, summary, content diff --git a/src/ccbot/handlers/card_transport.py b/src/ccbot/handlers/card_transport.py new file mode 100644 index 00000000..d1eeed0e --- /dev/null +++ b/src/ccbot/handlers/card_transport.py @@ -0,0 +1,603 @@ +"""Send and edit text or photo-backed live cards through Telegram.""" + +from __future__ import annotations + +import asyncio +import logging +import time + +from telegram import Bot, InlineKeyboardMarkup +from telegram.error import BadRequest, RetryAfter + +from ..config import config +from ..session import Session, session_manager +from .card_binding import bind_carrier, carrier_kind, clear_carrier, snapshot_carrier +from .card_model import ( + CardState, + _card_is_busy, +) +from .card_types import CarrierKind, TurnPhase +from .card_rich_media import edit_rich_media_card, send_rich_media_card +from .kb_mode import _capture_pane_png +from .card_registry import ( + _carrier_edit_lock, + _user_send_lock, + _strip_stale_switchers, + _register_msg, + lookup_session_for_message, + _inline_screens_enabled, + _legacy, +) + +logger = logging.getLogger(__name__) + + +__all__ = [ + "_send_card", + "_send_card_locked", + "_edit_card", + "_edit_card_unlocked", + "_PHOTO_EDIT_MIN_INTERVAL", + "_edit_photo_card", + "_replace_legacy_photo_with_text", + "_deferred_edit", +] + + +async def _send_card( + bot: Bot, + user_id: int, + sess: Session, + state: CardState, + *, + text: str, + reply_markup: InlineKeyboardMarkup | None = None, +) -> bool: + """Send a brand-new card message and remember it as the live card. + + Serialized per user (``_user_send_lock``) so concurrent spawns from + two different sessions can't interleave the send / strip / pointer + update and desync which message carries the live switcher. + """ + async with _user_send_lock(user_id): + return await _send_card_locked( + bot, + user_id, + sess, + state, + text=text, + reply_markup=reply_markup, + ) + + +async def _send_card_locked( + bot: Bot, + user_id: int, + sess: Session, + state: CardState, + *, + text: str, + reply_markup: InlineKeyboardMarkup | None = None, +) -> bool: + """Body of :func:`_send_card`; call only with the user send-lock held. + + ``reply_markup`` overrides the default footer keyboard. Used by + ``finalize_task`` to attach the idle-state Kill row to a completed + result instead of the busy-state Stop row. + """ + if reply_markup is None: + # Default: a fresh card is being sent because a turn is in + # flight (update_session_card, repost_card, continuation + # overflow). ``_card_is_busy`` keys off ``state.msg_id`` which + # is still None at this point — the right signal here is + # "we're sending a card", which by definition means Stop is + # the user's intent. ``finalize_task`` overrides ``reply_markup`` + # explicitly with the Kill keyboard when a turn completes. + reply_markup = _legacy("build_footer_keyboard")( + user_id, screen="main", is_busy=True + ) + keyboard = reply_markup + sent_kind = CarrierKind.TEXT + sent_file_id = "" + sent_pane_hash = "" + sent_photo_ts = 0.0 + + # Inline screenshots ON: prefer a Rich Markdown card whose final block + # is the pane image. Older/rich-disabled servers retain photo+caption. + sent = None + if ( + state.turn_phase is TurnPhase.RUNNING + and _inline_screens_enabled(user_id) + and sess.window_id + ): + from ..markdown_v2 import convert_markdown + from .message_sender import PARSE_MODE, strip_sentinels + + png, pane_hash = await _capture_pane_png(sess.window_id) + if png is not None: + rich_sent = await send_rich_media_card( + bot, + user_id, + state, + text, + png, + reply_markup=keyboard, + ) + if rich_sent is not None: + sent = rich_sent.message + sent_kind = CarrierKind.RICH_MEDIA + sent_file_id = rich_sent.photo_file_id + sent_pane_hash = pane_hash + sent_photo_ts = time.monotonic() + + if png is not None and sent is None: + import io as _io + + caption = convert_markdown(text) + try: + sent = await bot.send_photo( + chat_id=user_id, + photo=_io.BytesIO(png), + caption=caption, + parse_mode=PARSE_MODE, + reply_markup=keyboard, + disable_notification=True, + ) + except RetryAfter: + raise + except Exception as e: + logger.debug("photo send failed, retry plain caption: %s", e) + try: + sent = await bot.send_photo( + chat_id=user_id, + photo=_io.BytesIO(png), + caption=strip_sentinels(text), + reply_markup=keyboard, + disable_notification=True, + ) + except Exception as e2: + logger.debug("photo send plain fallback failed: %s", e2) + if sent is not None: + sent_kind = CarrierKind.LEGACY_PHOTO + sent_pane_hash = pane_hash + sent_photo_ts = time.monotonic() + + # Text-mode card OR photo path failed → text fallback. + if sent is None: + from .message_sender import send_with_fallback + + try: + sent = await send_with_fallback( + bot, + user_id, + text, + reply_markup=keyboard, + disable_notification=True, + ) + except RetryAfter: + raise + except Exception as e: + logger.debug("card send failed: %s", e) + return False + if sent is None: + return False + # Exactly one message in the chat may carry a live switcher, and it is + # this one — strip every other card's keyboard, not just the pointer's. + bind_carrier( + state, + sent.message_id, + sent_kind, + rich_media_file_id=sent_file_id, + pane_hash=sent_pane_hash, + photo_edit_ts=sent_photo_ts, + ) + await _strip_stale_switchers(bot, user_id, sent.message_id, sess.id) + if keyboard is not None: + session_manager.set_last_switcher_msg(user_id, sent.message_id) + state.last_rendered = text + _register_msg(user_id, sent.message_id, sess.id) + session_manager.set_card_msg(user_id, sent.message_id) + return True + + +async def _edit_card( + bot: Bot, + user_id: int, + state: CardState, + *, + text: str, + reply_markup: InlineKeyboardMarkup | None = None, + refresh_pane: bool = True, +) -> bool: + """Serialize Telegram edits with cross-session carrier hand-offs.""" + async with _carrier_edit_lock(user_id): + return await _edit_card_unlocked( + bot, + user_id, + state, + text=text, + reply_markup=reply_markup, + refresh_pane=refresh_pane, + ) + + +async def _edit_card_unlocked( + bot: Bot, + user_id: int, + state: CardState, + *, + text: str, + reply_markup: InlineKeyboardMarkup | None = None, + refresh_pane: bool = True, +) -> bool: + """Edit the live card. Returns False if the edit failed permanently. + + Always sends a keyboard along with the text — relying on Telegram's + "preserve keyboard when reply_markup is omitted" semantics turned out + flaky (the buttons flickered between edits). Caller may pass an + explicit `reply_markup`; otherwise we rebuild from current busy state. + """ + if state.msg_id is None: + return False + # User is currently looking at a Menu / sub-screen on this card's + # message. Don't repaint — would clobber whatever they're navigating. + # State.lines keeps accumulating; resume_card_view will catch up. + if state.in_menu_view: + return True + if reply_markup is None: + reply_markup = _legacy("build_footer_keyboard")( + user_id, screen="main", is_busy=_card_is_busy(state) + ) + from ..markdown_v2 import convert_markdown + from .message_sender import ( + NO_LINK_PREVIEW, + PARSE_MODE, + strip_sentinels, + try_rich_edit, + ) + + kind = carrier_kind(state) + removing_rich_pane = ( + kind is CarrierKind.RICH_MEDIA and state.turn_phase is TurnPhase.IDLE + ) + + # Rich-media card: refresh it while RUNNING, or convert it back to a + # text carrier once the durable turn phase becomes IDLE. + if kind is CarrierKind.RICH_MEDIA: + if removing_rich_pane: + removed = await try_rich_edit( + bot, + user_id, + state.msg_id, + text, + reply_markup=reply_markup, + ) + if removed: + bind_carrier(state, state.msg_id, CarrierKind.TEXT) + return True + # Continue through the normal rich → MarkdownV2 → plain pipeline. + # A temporary rich failure must not leave the final answer stale. + else: + return await edit_rich_media_card( + bot, + user_id, + state, + text=text, + reply_markup=reply_markup, + min_photo_interval=_PHOTO_EDIT_MIN_INTERVAL, + refresh_pane=refresh_pane, + ) + + # A legacy photo message cannot be transformed into text by editing its + # caption. Replace it transactionally only after the text send succeeds. + if kind is CarrierKind.LEGACY_PHOTO: + if state.turn_phase is TurnPhase.IDLE: + return await _replace_legacy_photo_with_text( + bot, + user_id, + state, + text=text, + reply_markup=reply_markup, + ) + return await _edit_photo_card( + bot, + user_id, + state, + text=text, + formatted=convert_markdown(text), + reply_markup=reply_markup, + refresh_pane=refresh_pane, + ) + + # A text carrier left by finalization is promoted back to rich media on + # the next running turn. If capture/rich delivery is temporarily + # unavailable, retain the text carrier and continue with text editing. + if ( + state.turn_phase is TurnPhase.RUNNING + and _inline_screens_enabled(user_id) + and config.rich_messages + ): + promoted = await edit_rich_media_card( + bot, + user_id, + state, + text=text, + reply_markup=reply_markup, + min_photo_interval=_PHOTO_EDIT_MIN_INTERVAL, + refresh_pane=refresh_pane, + ) + if promoted: + bind_carrier( + state, + state.msg_id, + CarrierKind.RICH_MEDIA, + rich_media_file_id=state.rich_media_file_id, + pane_hash=state.last_pane_hash, + photo_edit_ts=state.last_photo_edit_ts, + ) + return True + if snapshot_carrier(state).msg_id is None: + return False + + # Rich-first (Bot API 10.1): keeps the card's native rendering (GFM + # tables, headings,
) consistent with the rich _send_card + # path — otherwise the first edit would visibly downgrade the card + # to MarkdownV2. On failure (rich off, API error, lost carrier) fall + # through to the MarkdownV2 pipeline below, which also owns the + # lost-carrier detection. + if await try_rich_edit(bot, user_id, state.msg_id, text, reply_markup=reply_markup): + if removing_rich_pane: + bind_carrier(state, state.msg_id, CarrierKind.TEXT) + return True + + formatted = convert_markdown(text) + + try: + await bot.edit_message_text( + chat_id=user_id, + message_id=state.msg_id, + text=formatted, + parse_mode=PARSE_MODE, + reply_markup=reply_markup, + link_preview_options=NO_LINK_PREVIEW, + ) + if removing_rich_pane: + bind_carrier(state, state.msg_id, CarrierKind.TEXT) + return True + except BadRequest as e: + err = str(e) + if "Message is not modified" in err: + return True + if ( + "Message to edit not found" in err + or "message can't be edited" in err.lower() + or "MESSAGE_ID_INVALID" in err + ): + # Carrier is genuinely gone — reset msg_id so the next event + # opens a fresh card. + logger.info("card edit lost-carrier msg_id=%s err=%s", state.msg_id, err) + clear_carrier(state) + return False + # Parse error / can't render — fall back to stripped plain text + # on the SAME carrier. Keep the card alive. + logger.warning("card edit MarkdownV2 failed msg=%s err=%s", state.msg_id, err) + try: + await bot.edit_message_text( + chat_id=user_id, + message_id=state.msg_id, + text=strip_sentinels(text), + reply_markup=reply_markup, + link_preview_options=NO_LINK_PREVIEW, + ) + if removing_rich_pane: + bind_carrier(state, state.msg_id, CarrierKind.TEXT) + return True + except BadRequest as e2: + err2 = str(e2) + if "Message is not modified" in err2: + return True + logger.warning( + "card edit plain fallback failed msg=%s err=%s", state.msg_id, err2 + ) + except RetryAfter: + raise + except Exception as e2: + logger.warning( + "card edit plain fallback exc msg=%s err=%s", state.msg_id, e2 + ) + except RetryAfter: + raise + except Exception as e: + logger.warning("card edit failed (other): %s", e) + return False + + +_PHOTO_EDIT_MIN_INTERVAL = 2.5 # seconds — per-session throttle on editMessageMedia + + +async def _replace_legacy_photo_with_text( + bot: Bot, + user_id: int, + state: CardState, + *, + text: str, + reply_markup: InlineKeyboardMarkup | None, +) -> bool: + """Transactionally replace a photo carrier with a text-only message.""" + from .message_sender import send_with_fallback + + old = snapshot_carrier(state) + if old.msg_id is None: + return False + sess_id = lookup_session_for_message(user_id, old.msg_id) + if not sess_id: + logger.warning("photo replacement has no session msg=%s", old.msg_id) + return False + + async with _user_send_lock(user_id): + try: + sent = await send_with_fallback( + bot, + user_id, + text, + reply_markup=reply_markup, + disable_notification=True, + ) + except RetryAfter: + raise + except Exception as exc: + logger.warning("photo replacement send failed msg=%s: %s", old.msg_id, exc) + return False + if sent is None: + return False + + bind_carrier(state, sent.message_id, CarrierKind.TEXT) + _register_msg(user_id, sent.message_id, sess_id) + session_manager.set_card_msg(user_id, sent.message_id) + if reply_markup is not None: + session_manager.set_last_switcher_msg(user_id, sent.message_id) + await _strip_stale_switchers(bot, user_id, sent.message_id, sess_id) + + try: + await bot.delete_message(chat_id=user_id, message_id=old.msg_id) + except Exception as exc: + logger.warning("photo replacement delete failed msg=%s: %s", old.msg_id, exc) + return True + + +async def _edit_photo_card( + bot: Bot, + user_id: int, + state: CardState, + *, + text: str, + formatted: str, + reply_markup: InlineKeyboardMarkup | None, + refresh_pane: bool = True, +) -> bool: + """Edit a photo+caption card msg. + + Refresh strategy: + * Pane unchanged since last edit → editMessageCaption only. + * Pane changed AND ≥3s since last photo edit → editMessageMedia + with new photo + new caption + keyboard. + * Pane changed but throttled → editMessageCaption only. Next render + after the throttle window will pick up the freshest pane. + """ + import io as _io + + from telegram import InputMediaPhoto + + from ..markdown_v2 import convert_markdown + from .message_sender import PARSE_MODE, strip_sentinels + + # Resolve session from msg_id lookup (we don't have it here directly). + # Find by reverse mapping (user_id, msg_id) → session_id. + sess_id = lookup_session_for_message(user_id, state.msg_id or 0) + sess = session_manager.get_session(sess_id) if sess_id else None + window_id = sess.window_id if sess is not None else "" + + pane_changed = False + pane_png: bytes | None = None + pane_hash = state.last_pane_hash + elapsed = time.monotonic() - state.last_photo_edit_ts + if refresh_pane and window_id and elapsed >= _PHOTO_EDIT_MIN_INTERVAL: + png, h = await _capture_pane_png(window_id) + if png is not None and h: + if h != state.last_pane_hash: + pane_changed = True + pane_png = png + pane_hash = h + + try: + if pane_changed and pane_png is not None: + media = InputMediaPhoto( + media=_io.BytesIO(pane_png), + caption=convert_markdown(text), + parse_mode=PARSE_MODE, + ) + await bot.edit_message_media( + chat_id=user_id, + message_id=state.msg_id, + media=media, + reply_markup=reply_markup, + ) + bind_carrier( + state, + state.msg_id, + CarrierKind.LEGACY_PHOTO, + pane_hash=pane_hash, + photo_edit_ts=time.monotonic(), + ) + return True + # Pane unchanged or throttled — caption-only refresh. + await bot.edit_message_caption( + chat_id=user_id, + message_id=state.msg_id, + caption=formatted, + parse_mode=PARSE_MODE, + reply_markup=reply_markup, + ) + return True + except BadRequest as e: + err = str(e) + if "Message is not modified" in err: + return True + if ( + "Message to edit not found" in err + or "message can't be edited" in err.lower() + or "MESSAGE_ID_INVALID" in err + ): + logger.info("photo card edit lost-carrier msg=%s err=%s", state.msg_id, err) + clear_carrier(state) + return False + logger.warning( + "photo card edit MarkdownV2 failed msg=%s err=%s", state.msg_id, err + ) + # Plain-text caption fallback. + try: + await bot.edit_message_caption( + chat_id=user_id, + message_id=state.msg_id, + caption=strip_sentinels(text), + reply_markup=reply_markup, + ) + return True + except Exception as e2: + logger.warning( + "photo card plain fallback failed msg=%s err=%s", state.msg_id, e2 + ) + except RetryAfter: + raise + except Exception as e: + logger.warning("photo card edit failed (other): %s", e) + return False + + +async def _deferred_edit( + bot: Bot, user_id: int, sess: Session, state: CardState, delay: float +) -> None: + """Sleep `delay` then render the latest card state and edit once. + + The deferred task always picks up the latest `state.events`, so multiple + events arriving during the sleep collapse into a single edit. + """ + try: + await asyncio.sleep(delay) + # Stale guard: card may have been reset (finalize_task) while we slept. + if state.msg_id is None: + return + text = _legacy("_render_card")(sess, state, user_id=user_id) + if text == state.last_rendered: + return + state.pending_edit_in_flight = True + if await _legacy("_edit_card")(bot, user_id, state, text=text): + state.last_rendered = text + state.last_edit_ts = time.monotonic() + except asyncio.CancelledError: + return + except Exception as e: + logger.debug("deferred card edit failed: %s", e) + finally: + state.pending_edit_in_flight = False + state.pending_edit = None diff --git a/src/ccbot/handlers/card_types.py b/src/ccbot/handlers/card_types.py new file mode 100644 index 00000000..587e2595 --- /dev/null +++ b/src/ccbot/handlers/card_types.py @@ -0,0 +1,189 @@ +"""State-only dataclasses and constants for live cards.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from enum import Enum + +__all__ = [ + "CARD_HARD_LIMIT", + "CARD_MAX_EVENTS", + "STALE_CARD_SECONDS", + "SPOILER_MAX_LINES", + "CARD_PAGE_BUDGET", + "CARD_PAGE_LINES_DEFAULT", + "CARD_PAGE_LINES_OVERSHOOT", + "CARD_SEED_TURNS", + "Event", + "CardState", + "CarrierKind", + "TurnPhase", +] + +# Hard cap for rendered card text — Telegram limit is 4096; leave headroom. +CARD_HARD_LIMIT = 3800 +# Number of accumulated events kept; older events still live in state.events +# but only the last N participate in pagination (FIFO eviction beyond this). +CARD_MAX_EVENTS = 5000 +# After this much idleness, the next event opens a fresh card. +STALE_CARD_SECONDS = 5 * 60 +# Max lines of body shown inside each tool/thinking spoiler. Overflow is +# truncated with a "… (+N more lines)" trailer. Env-tunable. +SPOILER_MAX_LINES = 5 + +# Char budget for one rendered card page — kept as a hard ceiling for +# the Telegram-level 4096-char limit. Headroom for header / divider / +# bg-panel. The user-facing budget is in LINES (see ``card_page_lines`` +# user-setting / ``_resolve_line_budget``); chars budget here is only +# a sanity-cap when the page-by-lines result would still overflow TG. +CARD_PAGE_BUDGET = 3500 + +# Default page-size budget in LINES (logical \n-delimited rows in the +# MarkdownV2 source — close enough to visual lines on a phone for ±5 +# tolerance the user explicitly accepted). User overrides via +# Settings → Page size (10 / 20 / 40 / 70). +CARD_PAGE_LINES_DEFAULT = 20 + +# Allowed overshoot (in lines) when trimming a page or chunking an +# anchor so a sentence / paragraph isn't broken mid-content. +CARD_PAGE_LINES_OVERSHOOT = 5 + +# Number of trailing end_turn boundaries to pull from JSONL when seeding +# an empty ``state.events`` (e.g. after a bot restart). Each end_turn +# becomes a page boundary, so this caps the "scrollback depth" of the +# card without re-reading the full transcript on every event. +CARD_SEED_TURNS = 20 + + +@dataclass +class Event: + """One unit of conversation rendered on the card. + + ``type`` discriminates render behaviour: + + - ``user_msg`` — user's typed text echoed via ``👤`` + - ``thinking`` — claude thinking block (``∴``) + - ``tool_use`` — tool invocation (``▷``); on tool_result the + same Event's ``completed_at`` flips and ``body`` becomes the + result text. ``tool_use_id`` matches assistant→user pairing. + - ``text`` — mid-stream assistant text (stop_reason=tool_use) + - ``final_text`` — end-of-turn assistant answer; ``is_page_break`` + - ``error`` — error-only event; ``is_page_break`` + - ``interactive``— AskUserQuestion / ExitPlanMode / Permission; + rendered as a separate Telegram message, NOT in card body, but + recorded here for page-break anchoring. + - ``divider`` — historical "Результат" divider line; legacy + """ + + type: str + text: str # one-line header content (args summary / first line) + started_at: float # epoch seconds; HH:MM in header is derived from this + body: str = "" # full content under expandable blockquote + completed_at: float | None = None # set when event completes + tool_use_id: str | None = None + tool_name: str | None = None + is_page_break: bool = False # this event starts a new page + is_error: bool = False + image_data: list[tuple[str, bytes]] | None = None # tool_result images + + +class TurnPhase(str, Enum): + """Whether the active assistant turn may expose live terminal media.""" + + IDLE = "idle" + RUNNING = "running" + + +class CarrierKind(str, Enum): + """Telegram representation currently bound to a live card.""" + + TEXT = "text" + RICH_MEDIA = "rich_media" + LEGACY_PHOTO = "legacy_photo" + + +@dataclass +class CardState: + msg_id: int | None = None + events: list[Event] = field(default_factory=list) + # Page the user is currently looking at. ``None`` = default focus + # (page with the latest answer-anchor). Set by pagination callbacks. + current_page_idx: int | None = None + last_event_ts: float = 0.0 + last_rendered: str = "" # last text we sent to TG; skips no-op edits + last_edit_ts: float = 0.0 # monotonic seconds; gate for CARD_EDIT_LAG coalescing + pending_edit: asyncio.Task[None] | None = None # one deferred edit task at most + pending_edit_in_flight: bool = False # Telegram request phase of pending_edit + is_continuation: bool = False # True after a stale-pause or overflow split + # User opened ≡ Menu / a sub-screen on the card's message. While set, + # session updates accumulate into ``events`` but are NOT rendered to + # Telegram — otherwise the next event would overwrite whatever menu + # screen the user is looking at. Cleared by ``resume_card_view`` + # (called from text_handler when the user types) or implicitly + # when the card is reset. + in_menu_view: bool = False + # kb-mode auto-persistence (Task #41). When claude shows an + # interactive prompt (AskUserQuestion / ExitPlanMode / Permission), + # the card msg is EDITED in place to show the prompt content + kb + # navigation keyboard (3×3 grid). One msg per session — no separate + # push. State machine: + # kb_prompt non-empty + in_kb_mode=True → card msg = kb-mode view + # kb_prompt non-empty + in_kb_mode=False → user tapped Back; card + # shows regular view but with [🔙 Resume action] on Shot slot + # kb_prompt empty → no pending action + kb_prompt: str = "" # current prompt content (snapshot from pane) + kb_ui_name: str = "" # AskUserQuestion / ExitPlanMode / Permission + in_kb_mode: bool = False + # Inline-screenshots mode (Task #48). On Bot API versions with rich + # media support, the pane render is the final media block of the Rich + # Markdown card. ``is_photo_msg`` remains the compatibility fallback + # for older Bot API servers / rich-disabled deployments. + is_rich_media_msg: bool = False + rich_media_file_id: str = "" + # Raw-text boundary recorded by ``_render_card`` immediately before the + # service tail (context row + background-session panel). Rich-media + # transport inserts the terminal image at this boundary. + media_anchor_offset: int = 0 + # Durable task lifecycle. Terminal media is allowed only while RUNNING; + # final, clear, and buffered completion paths leave the card IDLE until a + # new inbound turn or non-final event explicitly starts work again. + turn_phase: TurnPhase = TurnPhase.RUNNING + is_photo_msg: bool = False + last_pane_hash: str = "" # md5 of last captured pane text + last_photo_edit_ts: float = 0.0 # monotonic seconds; 3s throttle + # Cached context-window fill percentage for the active session, set by + # session_events whenever a new assistant turn lands. Rendered as a + # ``context: N%`` line above the bg-status panel. None = unknown. + context_pct: int | None = None + # JSONL-seed bookkeeping (A6). ``_ensure_seeded`` reads the recent + # transcript exactly once per (re)set so the live card lands with + # context after a restart. The wipe sites that empty ``events`` mid- + # session for a NON-destructive reason (stale-pause reset, carrier + # release on switcher tap) clear this flag so the next event re-seeds + # — otherwise the card rebuilds one event at a time and the footer + # page counter transiently collapses to ``1/1`` while the underlying + # transcript still spans many turn-pages. ``/clear`` leaves it True: + # that is an intentional wipe-to-zero. + seed_attempted: bool = False + # Transcript mtime (epoch seconds) at the last *empty* seed attempt, or + # -1.0 if never attempted. A freshly restored (``claude --resume``) + # session creates its card before claude has flushed the resumed + # transcript, so an early seed reads [] and must retry on a later event. + # ``_ensure_seeded`` only re-parses the (possibly multi-MB) JSONL once + # this advances, so a burst of events during the resume window costs one + # stat() each, not a full re-parse. Reset alongside ``seed_attempted`` + # at the non-destructive re-seed sites. + seed_mtime: float = -1.0 + # A silent unfinished turn stays RUNNING instead of being replaced by a + # warning. Status polling refreshes its live pane for the active session. + stall_watch_active: bool = False + last_stall_pane_refresh_ts: float = 0.0 + # Set by voice_handler right when a voice message is pinned to this + # session, before download/transcribe (which can take many seconds). + # Rendered as a synthetic trailing ``user_msg`` row so an immediate + # repost_card shows "yes, your voice landed here" in the same place + # typed prompts appear, rather than adding transient state to the card + # header. Cleared once the transcribed text is actually dispatched (or + # transcription fails). + voice_pending: bool = False diff --git a/src/ccbot/handlers/card_updates.py b/src/ccbot/handlers/card_updates.py new file mode 100644 index 00000000..c0dd587f --- /dev/null +++ b/src/ccbot/handlers/card_updates.py @@ -0,0 +1,493 @@ +"""Apply session events, finalize tasks, and deliver card attachments.""" + +from __future__ import annotations + +import asyncio +import logging +import time + +from telegram import Bot + +from ..config import config +from ..session import Session, session_manager +from ..session_monitor import NewMessage +from .card_model import ( + CARD_MAX_EVENTS, + CardState, + Event, + _apply_tool_result, + _build_event, + _chunk_final_text, + _duplicate_of_seeded, + _is_stale, + _resolve_line_budget, + _strip_for_card, + paginate_events_for_card, +) +from .card_binding import clear_carrier +from .card_types import TurnPhase +from .switcher import session_emoji +from .tg_format import Attachment, split_overflow + +from .card_registry import ( + _card_lock, + _register_msg, + _should_buffer, + _legacy, +) +from .card_seed import get_card_state +from .card_transport import _deferred_edit + +logger = logging.getLogger(__name__) + + +__all__ = [ + "update_session_card", + "_update_session_card_locked", + "finalize_task", + "_send_attachments", + "push_event", +] + + +async def update_session_card( + bot: Bot, user_id: int, sess: Session, msg: NewMessage +) -> None: + """Append `msg` to the session's live card, or open a new one if needed. + + Triggers a fresh card on long pause and on hard-limit overflow. + """ + # Fire a (throttled) background prewarm of the pages cache so the + # live-card's ◀ Older N/N counter has a value to render on the + # next event. The first event after session start may still paint + # without the counter — the background task lands within a second. + if sess.window_id: + from .history import kick_prewarm + + kick_prewarm(sess.window_id) + + state = get_card_state(user_id, sess) + state.turn_phase = TurnPhase.RUNNING + state.stall_watch_active = False + state.last_stall_pane_refresh_ts = 0.0 + # First event after a bot restart: pull JSONL history into events + # so the card shows context, not a single 1/1 page. + await _legacy("_ensure_seeded")(user_id, sess, state) + + # Should we buffer this event instead of rendering it now? Reasons: + # - the user is on a Menu / sub-screen (state.in_menu_view set by + # pause_card_view or transfer_card_to_carrier); + # - the session isn't the user's currently-active one (live check — + # bg sessions must stay silent in chat). + # The check is in ``_should_buffer`` so future buffering reasons + # converge on the same predicate. Previously the bg branch was + # implemented by force-setting ``state.in_menu_view = True`` here; + # the flag was sticky and outlived the bg phase, leaving the card + # permanently paused — until a typed message woke + # ``resume_card_view``. Computing the bg check live fixes that. + must_buffer = _should_buffer(user_id, sess.id, state) + + msg_id_in = state.msg_id + in_menu_view_in = state.in_menu_view + + new_event = _build_event(msg) + # tool_result: fold into the matching tool_use Event in place. + # If no match (race / restart), append the placeholder as a row. + replaced = False + if msg.content_type == "tool_result": + replaced = _apply_tool_result(state, new_event) + + # Buffer-only path: user is on a menu/sub-screen OR session is bg. + # Buffer the event into ``state.events`` so resume / next switcher + # tap can catch up; do NOT trigger stale-card resets, overflow + # continuations, or any rendering. + if must_buffer: + if not replaced and not _duplicate_of_seeded(state.events, new_event): + # Same dedup guard as the live path: a prior seed (line ~1371) + # may already hold this turn; don't buffer a second copy. + state.events.append(new_event) + state.last_event_ts = time.time() + logger.info( + "card_update buffered sess=%s msg_id=%s ctype=%s lines=%d", + sess.id, + msg_id_in, + msg.content_type, + len(state.events), + extra={ + "event": "card_update_buffered", + "user_id": user_id, + "session_id": sess.id, + "msg_id": msg_id_in, + "content_type": msg.content_type, + "lines": len(state.events), + "in_menu_view": in_menu_view_in, + }, + ) + return + + # Spawn-serialization (Task #50): hold the per-session lock from + # the stale-check through the actual send/edit. Otherwise two + # concurrent ``update_session_card`` calls (or one + # ``update_session_card`` racing with ``resume_card_view`` / + # ``repost_card`` / ``finalize_task``) can both see ``msg_id is + # None`` and both spawn — produces "2 messages in wrong order". + async with _card_lock(user_id, sess.id): + return await _update_session_card_locked( + bot, user_id, sess, msg, state, new_event, replaced + ) + + +async def _update_session_card_locked( + bot: Bot, + user_id: int, + sess: Session, + msg: NewMessage, + state: CardState, + new_event: Event, + replaced: bool, +) -> None: + # Trigger: long pause → fresh card. + if _is_stale(state): + clear_carrier(state) + state.events = [] + state.current_page_idx = None + state.is_continuation = True + state.last_rendered = "" + # A6: re-seed the recent transcript so the fresh card lands with + # its full turn-history. Without this the card rebuilds one event + # at a time and the footer page counter shows ``1/1`` until a + # second turn completes — even though the transcript is long. + state.seed_attempted = False + state.seed_mtime = -1.0 + await _legacy("_ensure_seeded")(user_id, sess, state) + + if not replaced and not _duplicate_of_seeded(state.events, new_event): + # Dedup guard: if the stale-branch re-seed above (or an earlier + # release_card_message wipe) already pulled this turn in from + # JSONL, don't append it a second time — otherwise the user's own + # message renders twice in the card body. + state.events.append(new_event) + # User-action-anchor: when on the latest page, every new event + # keeps the user there. Done as None (=stick-to-latest) so the + # render layer picks the latest page automatically. + # Page idx is recalibrated by paginate-aware callbacks. + + # Cap event log to avoid unbounded memory; FIFO evicts oldest. + if len(state.events) > CARD_MAX_EVENTS: + del state.events[: len(state.events) - CARD_MAX_EVENTS] + + state.last_event_ts = time.time() + + # Pagination handles size: the latest page is always within + # CARD_HARD_LIMIT chars (paginate splits before the boundary). + # No continuation-card path. + + text = _legacy("_render_card")(sess, state, user_id=user_id) + + if state.msg_id is None: + await _legacy("_send_card")(bot, user_id, sess, state, text=text) + state.last_edit_ts = time.monotonic() + logger.info( + "card_update sent sess=%s msg_id=%s ctype=%s lines=%d", + sess.id, + state.msg_id, + msg.content_type, + len(state.events), + extra={ + "event": "card_update_sent", + "user_id": user_id, + "session_id": sess.id, + "msg_id": state.msg_id, + "content_type": msg.content_type, + "lines": len(state.events), + }, + ) + return + + # Coalesce edits — at most one editMessageText per live_lag seconds. + # User setting takes precedence over the env-var default. + user_lag = session_manager.get_user_settings(user_id).get("live_lag") + if user_lag is None: + user_lag = config.card_edit_lag + lag = max(0.0, float(user_lag)) + elapsed = time.monotonic() - state.last_edit_ts if state.last_edit_ts else lag + if lag <= 0 or elapsed >= lag: + edited = await _legacy("_edit_card")(bot, user_id, state, text=text) + if edited: + state.last_rendered = text + state.last_edit_ts = time.monotonic() + logger.info( + "card_update edit sess=%s msg_id=%s ctype=%s lines=%d", + sess.id, + state.msg_id, + msg.content_type, + len(state.events), + extra={ + "event": "card_update_edited", + "user_id": user_id, + "session_id": sess.id, + "msg_id": state.msg_id, + "content_type": msg.content_type, + "lines": len(state.events), + }, + ) + else: + # Edit failed AND we couldn't recover — DO NOT fall back to + # _send_card here. Sending a new message produces duplicate + # cards in chat (this was the "2 messages in a row" bug: + # Message_too_long → fallback send → stale card stays + new + # appears). Caller's next event retries the edit; if the + # carrier message is truly gone (deleted, too old), the + # ``Message to edit not found`` branch in _edit_card resets + # msg_id and a fresh card spawns on the next event. + state.last_edit_ts = time.monotonic() + logger.warning( + "card_update edit_failed sess=%s msg_id=%s — keeping " + "stale card; new render will retry on next event", + sess.id, + state.msg_id, + ) + return + + # Inside the coalescing window: ensure exactly one deferred edit is queued. + if state.pending_edit is None or state.pending_edit.done(): + delay = max(0.05, lag - elapsed) + state.pending_edit = asyncio.create_task( + _deferred_edit(bot, user_id, sess, state, delay) + ) + + +async def _drain_pending_edit(state: CardState) -> None: + """Cancel a sleeping edit, but wait for a Telegram request already in flight.""" + pending = state.pending_edit + if pending is None or pending.done(): + state.pending_edit = None + return + if not state.pending_edit_in_flight: + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + state.pending_edit = None + + +async def _retry_final_render( + bot: Bot, user_id: int, sess: Session, state: CardState +) -> None: + """Retry only final delivery; events were already committed exactly once.""" + current = asyncio.current_task() + try: + for delay in (1.0, 2.0, 4.0): + await asyncio.sleep(delay) + if state.turn_phase is not TurnPhase.IDLE: + return + async with _card_lock(user_id, sess.id): + if state.turn_phase is not TurnPhase.IDLE: + return + text = _legacy("_render_card")(sess, state, user_id=user_id) + keyboard = _legacy("build_footer_keyboard")( + user_id, screen="main", is_busy=False + ) + state.pending_edit_in_flight = True + try: + if state.msg_id is None: + sent = await _legacy("_send_card")( + bot, + user_id, + sess, + state, + text=text, + reply_markup=keyboard, + ) + delivered = sent is not False and state.msg_id is not None + else: + delivered = await _legacy("_edit_card")( + bot, + user_id, + state, + text=text, + reply_markup=keyboard, + ) + except Exception as exc: + logger.warning("final card retry failed sess=%s: %s", sess.id, exc) + delivered = False + finally: + state.pending_edit_in_flight = False + if delivered: + state.last_rendered = text + state.last_edit_ts = time.monotonic() + return + finally: + if state.pending_edit is current: + state.pending_edit = None + + +async def finalize_task(bot: Bot, user_id: int, sess: Session, final_text: str) -> None: + """Append the final assistant answer to the current live card. + + Appends a ``final_text`` Event with ``is_page_break=True`` so the + new answer anchors the top of the latest page; everything before + it (tool log, thinking, mid-stream text) lives on the previous page. + The user lands on the new latest page by default. Long answers + that exceed Telegram's 4096-char limit are sub-paginated by + ``paginate_events``. + """ + state = get_card_state(user_id, sess) + cleaned = (final_text or "").strip() + attachments: list[Attachment] = [] + final_events: list[Event] = [] + if cleaned: + formatted = split_overflow(cleaned) + cleaned = formatted.text + attachments = formatted.attachments + + now = time.time() + stripped_full = _strip_for_card(cleaned) + chunks = _chunk_final_text(stripped_full, _resolve_line_budget(user_id)) + final_events = [ + Event( + type="final_text", + text=chunk, + body=chunk, + started_at=now, + completed_at=now, + is_page_break=True, + ) + for chunk in chunks + ] + + buffered = False + delivered = True + async with _card_lock(user_id, sess.id): + # Recover and seed under the same lock as final mutation/render so a + # next-turn event cannot interleave a stale snapshot. + await _legacy("_ensure_seeded")(user_id, sess, state) + state.turn_phase = TurnPhase.IDLE + state.stall_watch_active = False + state.last_stall_pane_refresh_ts = 0.0 + await _drain_pending_edit(state) + buffered = _should_buffer(user_id, sess.id, state) + + if final_events: + state.events.extend(final_events) + if len(state.events) > CARD_MAX_EVENTS: + del state.events[: len(state.events) - CARD_MAX_EVENTS] + state.last_event_ts = final_events[0].started_at + if len(final_events) > 1: + pages_after = paginate_events_for_card(state, user_id) + state.current_page_idx = max(0, len(pages_after) - len(final_events)) + else: + state.current_page_idx = None + + if not buffered and sess.window_id: + try: + from .history import prewarm_pages_cache + + await prewarm_pages_cache(sess.window_id) + except Exception as exc: + logger.debug("finalize_task prewarm failed: %s", exc) + + if not buffered and (state.msg_id is not None or final_events): + done_kb = _legacy("build_footer_keyboard")( + user_id, screen="main", is_busy=False + ) + text = _legacy("_render_card")(sess, state, user_id=user_id) + state.pending_edit_in_flight = True + try: + if state.msg_id is None: + sent = await _legacy("_send_card")( + bot, + user_id, + sess, + state, + text=text, + reply_markup=done_kb, + ) + delivered = sent is not False and state.msg_id is not None + else: + delivered = await _legacy("_edit_card")( + bot, + user_id, + state, + text=text, + reply_markup=done_kb, + ) + except Exception as exc: + logger.warning("final card delivery failed sess=%s: %s", sess.id, exc) + delivered = False + finally: + state.pending_edit_in_flight = False + if delivered: + state.last_rendered = text + state.last_edit_ts = time.monotonic() + + if attachments: + await _legacy("_send_attachments")(bot, user_id, attachments) + if not buffered and not delivered: + state.pending_edit = asyncio.create_task( + _retry_final_render(bot, user_id, sess, state), + name=f"card-final-retry:{user_id}:{sess.id}", + ) + + +async def _send_attachments( + bot: Bot, user_id: int, attachments: list[Attachment] +) -> None: + """Send extracted overflow content. ``kind="photo"`` table extracts + are rasterised via ``screenshot.text_to_image`` so wide tables land + as inline images rather than `.md` files; everything else (oversized + code blocks) goes through ``send_document`` as before. + """ + import io as _io + + from ..screenshot import text_to_image + from .tg_format import pretty_pad_table + + for att in attachments: + try: + if att.kind == "photo": + source = att.content.decode("utf-8", errors="replace") + rendered = pretty_pad_table(source) + png = await text_to_image(rendered, with_ansi=False) + await bot.send_photo( + chat_id=user_id, + photo=_io.BytesIO(png), + ) + else: + await bot.send_document( + chat_id=user_id, + document=_io.BytesIO(att.content), + filename=att.filename, + ) + except Exception as e: + logger.debug("attachment %s send failed: %s", att.filename, e) + + +async def push_event( + bot: Bot, + user_id: int, + sess: Session, + *, + text: str, + is_error: bool = False, +) -> None: + """Bg-session push — a bare one-line notification. + + Format is strictly `` ``: no markdown brackets, + no inline keyboard, no switcher migration. Hijacking the active + card's footer buttons (the previous behaviour) confused users — + bg pushes are status pings, not navigation surfaces. Use the + switcher on the active card to actually visit the session. + """ + emoji = "🟥" if is_error else session_emoji(sess) + name = sess.name or sess.id + body = f"{emoji} {name} {text}" + if len(body) > 3500: + body = body[:3497] + "…" + try: + sent = await _legacy("safe_send")(bot, user_id, body) + except Exception as e: + logger.debug("push_event failed: %s", e) + return + # Register the msg→session map so a reply-quote to this push still + # routes back to the originating session. + if sent is not None: + _register_msg(user_id, sent.message_id, sess.id) diff --git a/src/ccbot/handlers/history.py b/src/ccbot/handlers/history.py index a12403c9..6533d98e 100644 --- a/src/ccbot/handlers/history.py +++ b/src/ccbot/handlers/history.py @@ -10,12 +10,10 @@ """ import asyncio -import json import logging from pathlib import Path from typing import Any -import aiofiles from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup from ..config import config @@ -24,6 +22,10 @@ from ..telegram_sender import split_message from ..transcript_parser import TranscriptParser from .callback_data import CB_HISTORY_NEXT, CB_HISTORY_PREV +from .history_archive import ( + render_archived_card_pages_impl, + render_archived_history_pages_impl, +) from .message_sender import safe_edit, safe_reply, safe_send logger = logging.getLogger(__name__) @@ -90,229 +92,28 @@ def _window_file_path(window_id: str) -> Path | None: async def render_archived_card_pages( sess: Session, user_id: int | None = None ) -> tuple[list[str], int] | None: - """Render an archived session's transcript with the live-card engine. - - Unlike :func:`render_archived_history_pages` — which flattens every - message into one page and strips the expandable-quote sentinels, so - long thinking / tool outputs dump inline as an unreadable wall — this - reuses ``card_model``'s event pipeline (``_build_event`` + - ``_apply_tool_result`` + ``paginate_events_for_card`` + ``render_page``). - Thinking blocks and tool bodies collapse into ``
`` spoilers - exactly like the active session card, and pagination follows answer - boundaries + the user's line budget. - - Returns ``(pages, event_count)`` or ``None`` when no transcript - resolves (no claude_session_id, missing file, empty transcript). - """ - sid = sess.claude_session_id - if not sid or not sess.workdir: - return None - fp = _session_file_path(sess) - if fp is None or not fp.exists(): - if sess.backend == "codex": - return None - # Glob fallback — cwd on the record may have shifted since archival. - pattern = f"*/{sid}.jsonl" - matches = list(config.claude_projects_path.glob(pattern)) - if not matches: - return None - fp = matches[0] - - try: - st = fp.stat() - except OSError: - return None - - cached = _archived_card_cache.get(sid) - if cached is not None and cached[0] == st.st_mtime and cached[1] == st.st_size: - return list(cached[2]), cached[3] - - # Lazy imports — card_model pulls in the whole notification model layer; - # keep it off history.py's import-time path (and avoid any cycle). - import time as _time - - from ..session_monitor import NewMessage - from .card_model import ( - CardState, - _apply_tool_result, - _build_event, - paginate_events_for_card, - render_page, + """Render an archived transcript through the live-card event pipeline.""" + return await render_archived_card_pages_impl( + sess, + user_id, + session_file_path=_session_file_path, + archived_card_cache=_archived_card_cache, + config=config, + logger=logger, ) - try: - raw = fp.read_text(encoding="utf-8", errors="replace") - except OSError as e: - logger.debug("archived card read failed for %s: %s", fp, e) - return None - raw_entries: list[dict[str, Any]] = [] - for line in raw.splitlines(): - line = line.strip() - if not line: - continue - try: - raw_entries.append(json.loads(line)) - except json.JSONDecodeError: - continue - try: - parsed_list, _ = TranscriptParser.parse_entries(raw_entries, pending_tools=None) - except Exception as e: - logger.debug("archived card parse failed for %s: %s", fp, e) - return None - if not parsed_list: - return None - - # ParsedEntry → NewMessage → Event, folding tool_results into their - # matching tool_use (same loop the live-card JSONL seed uses). - state = CardState() - for p in parsed_list: - ct = getattr(p, "content_type", "text") - msg = NewMessage( - session_id="archive", - text=getattr(p, "text", "") or "", - is_complete=True, - content_type=ct, - tool_use_id=getattr(p, "tool_use_id", None), - role=getattr(p, "role", "assistant"), - tool_name=getattr(p, "tool_name", None), - image_data=getattr(p, "image_data", None), - stop_reason=getattr(p, "stop_reason", None), - timestamp=getattr(p, "timestamp", "") or "", - is_error=getattr(p, "is_error", False), - ) - ev = _build_event(msg) - if ct == "tool_result" and _apply_tool_result(state, ev): - continue - state.events.append(ev) - if not state.events: - return None - - # Every event in an archived transcript is finished — nothing is - # streaming. Stamp ``completed_at`` so ``_is_in_flight`` never flags - # the terminal event of a page as live and renders a bogus ``⏳ - # 3968:24`` elapsed against ``now`` instead of the entry's HH:MM. - for ev in state.events: - if ev.completed_at is None: - ev.completed_at = ev.started_at - - now = _time.time() - label = sess.name or sess.id - header = f"📦 [{label}]" - pages_events = paginate_events_for_card(state, user_id) - pages: list[str] = [] - for pe in pages_events: - body = render_page(pe, now) - pages.append(f"{header}\n\n{body}" if body.strip() else header) - total = len(state.events) - _archived_card_cache[sid] = (st.st_mtime, st.st_size, list(pages), total) - return list(pages), total - async def render_archived_history_pages( sess: Session, ) -> tuple[list[str], int] | None: - """Read ``sess``'s on-disk JSONL transcript and return Telegram-ready - pages + total message count. Returns ``None`` when there's no - resolvable transcript (no claude_session_id, missing file, etc.). - - Used by the Archive → Inspect view to surface what the session - actually did, without requiring a live tmux window. - """ - sid = sess.claude_session_id - if not sid or not sess.workdir: - return None - fp = _session_file_path(sess) - if fp is None or not fp.exists(): - if sess.backend == "codex": - return None - # Glob fallback — the cwd column on the Session may have shifted - # since archival (rare, but cheap to handle). - pattern = f"*/{sid}.jsonl" - matches = list(config.claude_projects_path.glob(pattern)) - if not matches: - return None - fp = matches[0] - - try: - st = fp.stat() - except OSError: - return None - - cached = _archived_pages_cache.get(sid) - if cached is not None and cached[0] == st.st_mtime and cached[1] == st.st_size: - return list(cached[2]), cached[3] - - entries: list[dict[str, Any]] = [] - try: - async with aiofiles.open(fp, "r", encoding="utf-8") as f: - async for line in f: - line = line.strip() - if not line: - continue - try: - data = TranscriptParser.parse_line(line) - except (json.JSONDecodeError, ValueError): - continue - if data: - entries.append(data) - except OSError as e: - logger.debug("archived history read failed for %s: %s", fp, e) - return None - - parsed_entries, _ = TranscriptParser.parse_entries(entries) - messages = [ - { - "role": e.role, - "text": e.text, - "content_type": e.content_type, - "timestamp": e.timestamp, - } - for e in parsed_entries - ] - if not config.show_user_messages: - messages = [m for m in messages if m["role"] == "assistant"] - # Drop tool_use rows — same rationale as ``prewarm_pages_cache``: - # the parser emits both tool_use (header only) and tool_result - # (header + body) for each call, so the bare tool_use rows are pure - # duplicates in the rendered view. - messages = [m for m in messages if m.get("content_type") != "tool_use"] - total = len(messages) - if total == 0: - return None - - _qstart = TranscriptParser.EXPANDABLE_QUOTE_START - _qend = TranscriptParser.EXPANDABLE_QUOTE_END - label = sess.name or sess.id - lines: list[str] = [f"📦 [{label}] Archived transcript ({total} msgs)"] - for msg in messages: - ts = msg.get("timestamp") - hh_mm = "" - if ts: - try: - time_part = ts.split("T")[1] if "T" in ts else ts - hh_mm = time_part[:5] - except (IndexError, TypeError): - hh_mm = "" - lines.append(f"───── {hh_mm} ─────" if hh_mm else "─────────────") - msg_text = (msg.get("text") or "").replace(_qstart, "").replace(_qend, "") - fence_lines = sum( - 1 for ln in msg_text.split("\n") if ln.strip().startswith("```") - ) - if fence_lines % 2 == 1: - msg_text = msg_text + "\n```" - role = msg.get("role", "assistant") - ctype = msg.get("content_type", "text") - if role == "user": - lines.append(f"👤 {msg_text}") - elif ctype == "thinking": - lines.append(f"∴ Thinking…\n{msg_text}") - else: - lines.append(msg_text) - - full = "\n\n".join(lines) - pages = split_message(full, max_length=4096) - _archived_pages_cache[sid] = (st.st_mtime, st.st_size, list(pages), total) - return list(pages), total + """Render an archived transcript as flat Telegram history pages.""" + return await render_archived_history_pages_impl( + sess, + session_file_path=_session_file_path, + archived_pages_cache=_archived_pages_cache, + config=config, + logger=logger, + ) _last_prewarm_attempt: dict[str, float] = {} diff --git a/src/ccbot/handlers/history_archive.py b/src/ccbot/handlers/history_archive.py new file mode 100644 index 00000000..0564b10a --- /dev/null +++ b/src/ccbot/handlers/history_archive.py @@ -0,0 +1,258 @@ +"""Archived transcript rendering helpers for history. + +Caches and path resolution are supplied by handlers.history so its mutable +state identity and monkeypatch-visible path seam remain unchanged. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import aiofiles + +from ..session import Session +from ..telegram_sender import split_message +from ..transcript_parser import TranscriptParser + + +async def render_archived_card_pages_impl( + sess: Session, + user_id: int | None, + *, + session_file_path: Callable[[Session], Path | None], + archived_card_cache: dict[str, tuple[float, int, list[str], int]], + config: Any, + logger: logging.Logger, +) -> tuple[list[str], int] | None: + """Render an archived session's transcript with the live-card engine. + + Unlike :func:`render_archived_history_pages` — which flattens every + message into one page and strips the expandable-quote sentinels, so + long thinking / tool outputs dump inline as an unreadable wall — this + reuses ``card_model``'s event pipeline (``_build_event`` + + ``_apply_tool_result`` + ``paginate_events_for_card`` + ``render_page``). + Thinking blocks and tool bodies collapse into ``
`` spoilers + exactly like the active session card, and pagination follows answer + boundaries + the user's line budget. + + Returns ``(pages, event_count)`` or ``None`` when no transcript + resolves (no claude_session_id, missing file, empty transcript). + """ + sid = sess.claude_session_id + if not sid or not sess.workdir: + return None + fp = session_file_path(sess) + if fp is None or not fp.exists(): + if sess.backend == "codex": + return None + # Glob fallback — cwd on the record may have shifted since archival. + pattern = f"*/{sid}.jsonl" + matches = list(config.claude_projects_path.glob(pattern)) + if not matches: + return None + fp = matches[0] + + try: + st = fp.stat() + except OSError: + return None + + cached = archived_card_cache.get(sid) + if cached is not None and cached[0] == st.st_mtime and cached[1] == st.st_size: + return list(cached[2]), cached[3] + + # Lazy imports — card_model pulls in the whole notification model layer; + # keep it off history.py's import-time path (and avoid any cycle). + import time as _time + + from ..session_monitor import NewMessage + from .card_model import ( + CardState, + _apply_tool_result, + _build_event, + paginate_events_for_card, + render_page, + ) + + try: + raw = fp.read_text(encoding="utf-8", errors="replace") + except OSError as e: + logger.debug("archived card read failed for %s: %s", fp, e) + return None + raw_entries: list[dict[str, Any]] = [] + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + try: + raw_entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + try: + parsed_list, _ = TranscriptParser.parse_entries(raw_entries, pending_tools=None) + except Exception as e: + logger.debug("archived card parse failed for %s: %s", fp, e) + return None + if not parsed_list: + return None + + # ParsedEntry → NewMessage → Event, folding tool_results into their + # matching tool_use (same loop the live-card JSONL seed uses). + state = CardState() + for p in parsed_list: + ct = getattr(p, "content_type", "text") + msg = NewMessage( + session_id="archive", + text=getattr(p, "text", "") or "", + is_complete=True, + content_type=ct, + tool_use_id=getattr(p, "tool_use_id", None), + role=getattr(p, "role", "assistant"), + tool_name=getattr(p, "tool_name", None), + image_data=getattr(p, "image_data", None), + stop_reason=getattr(p, "stop_reason", None), + timestamp=getattr(p, "timestamp", "") or "", + is_error=getattr(p, "is_error", False), + ) + ev = _build_event(msg) + if ct == "tool_result" and _apply_tool_result(state, ev): + continue + state.events.append(ev) + if not state.events: + return None + + # Every event in an archived transcript is finished — nothing is + # streaming. Stamp ``completed_at`` so ``_is_in_flight`` never flags + # the terminal event of a page as live and renders a bogus ``⏳ + # 3968:24`` elapsed against ``now`` instead of the entry's HH:MM. + for ev in state.events: + if ev.completed_at is None: + ev.completed_at = ev.started_at + + now = _time.time() + label = sess.name or sess.id + header = f"📦 [{label}]" + pages_events = paginate_events_for_card(state, user_id) + pages: list[str] = [] + for pe in pages_events: + body = render_page(pe, now) + pages.append(f"{header}\n\n{body}" if body.strip() else header) + total = len(state.events) + archived_card_cache[sid] = (st.st_mtime, st.st_size, list(pages), total) + return list(pages), total + + +async def render_archived_history_pages_impl( + sess: Session, + *, + session_file_path: Callable[[Session], Path | None], + archived_pages_cache: dict[str, tuple[float, int, list[str], int]], + config: Any, + logger: logging.Logger, +) -> tuple[list[str], int] | None: + """Read ``sess``'s on-disk JSONL transcript and return Telegram-ready + pages + total message count. Returns ``None`` when there's no + resolvable transcript (no claude_session_id, missing file, etc.). + + Used by the Archive → Inspect view to surface what the session + actually did, without requiring a live tmux window. + """ + sid = sess.claude_session_id + if not sid or not sess.workdir: + return None + fp = session_file_path(sess) + if fp is None or not fp.exists(): + if sess.backend == "codex": + return None + # Glob fallback — the cwd column on the Session may have shifted + # since archival (rare, but cheap to handle). + pattern = f"*/{sid}.jsonl" + matches = list(config.claude_projects_path.glob(pattern)) + if not matches: + return None + fp = matches[0] + + try: + st = fp.stat() + except OSError: + return None + + cached = archived_pages_cache.get(sid) + if cached is not None and cached[0] == st.st_mtime and cached[1] == st.st_size: + return list(cached[2]), cached[3] + + entries: list[dict[str, Any]] = [] + try: + async with aiofiles.open(fp, "r", encoding="utf-8") as f: + async for line in f: + line = line.strip() + if not line: + continue + try: + data = TranscriptParser.parse_line(line) + except (json.JSONDecodeError, ValueError): + continue + if data: + entries.append(data) + except OSError as e: + logger.debug("archived history read failed for %s: %s", fp, e) + return None + + parsed_entries, _ = TranscriptParser.parse_entries(entries) + messages = [ + { + "role": e.role, + "text": e.text, + "content_type": e.content_type, + "timestamp": e.timestamp, + } + for e in parsed_entries + ] + if not config.show_user_messages: + messages = [m for m in messages if m["role"] == "assistant"] + # Drop tool_use rows — same rationale as ``prewarm_pages_cache``: + # the parser emits both tool_use (header only) and tool_result + # (header + body) for each call, so the bare tool_use rows are pure + # duplicates in the rendered view. + messages = [m for m in messages if m.get("content_type") != "tool_use"] + total = len(messages) + if total == 0: + return None + + _qstart = TranscriptParser.EXPANDABLE_QUOTE_START + _qend = TranscriptParser.EXPANDABLE_QUOTE_END + label = sess.name or sess.id + lines: list[str] = [f"📦 [{label}] Archived transcript ({total} msgs)"] + for msg in messages: + ts = msg.get("timestamp") + hh_mm = "" + if ts: + try: + time_part = ts.split("T")[1] if "T" in ts else ts + hh_mm = time_part[:5] + except (IndexError, TypeError): + hh_mm = "" + lines.append(f"───── {hh_mm} ─────" if hh_mm else "─────────────") + msg_text = (msg.get("text") or "").replace(_qstart, "").replace(_qend, "") + fence_lines = sum( + 1 for ln in msg_text.split("\n") if ln.strip().startswith("```") + ) + if fence_lines % 2 == 1: + msg_text = msg_text + "\n```" + role = msg.get("role", "assistant") + ctype = msg.get("content_type", "text") + if role == "user": + lines.append(f"👤 {msg_text}") + elif ctype == "thinking": + lines.append(f"∴ Thinking…\n{msg_text}") + else: + lines.append(msg_text) + + full = "\n\n".join(lines) + pages = split_message(full, max_length=4096) + archived_pages_cache[sid] = (st.st_mtime, st.st_size, list(pages), total) + return list(pages), total diff --git a/src/ccbot/handlers/menu.py b/src/ccbot/handlers/menu.py index 22dbe46d..7201c1ff 100644 --- a/src/ccbot/handlers/menu.py +++ b/src/ccbot/handlers/menu.py @@ -1,28 +1,16 @@ -"""Footer + More menu + Settings — inline keyboards under the last bot message. +"""Footer and Menu keyboard composition with compatibility re-exports. -Three layers, all rendered together onto the same message: - - - Top row: Stop (only when an active session exists) + ⋯ More - - Optional: More menu grid (List / Status / History / Shot / New / ⚙) - - Optional: Settings toggles (when user is inside ⚙) - - Bottom row: A8 session switcher (`+ new`) - -`build_footer_keyboard(user_id, screen=...)` returns the right combination -based on which "screen" the user is currently viewing. - -Public API: - build_footer_keyboard(user_id, screen) -> InlineKeyboardMarkup | None - build_more_keyboard(user_id) -> InlineKeyboardMarkup - build_settings_keyboard(user_id) -> InlineKeyboardMarkup +Settings metadata, keyboard builders, and screen text live in leaf modules; +this stable module keeps the historical import surface and composes the final +inline keyboard attached to bot messages. """ from __future__ import annotations -from typing import Literal from telegram import InlineKeyboardButton, InlineKeyboardMarkup -from ..i18n import LANGUAGES, t +from ..i18n import t from ..session import session_manager from .callback_data import ( CB_FT_CLEAR, @@ -30,9 +18,6 @@ CB_FT_MORE, CB_FT_STOP, CB_FT_TERM, - CB_PG_JUMP, - CB_PG_NEXT, - CB_PG_PREV, CB_MM_ARCHIVE, CB_MM_BACK, CB_MM_LIST, @@ -40,182 +25,82 @@ CB_MM_SETTINGS, CB_MM_SHOT, CB_MM_STATUS, - CB_ST_APPROVE, - CB_ST_AGENT, - CB_ST_BACK, - CB_ST_BGNOTIFY, - CB_ST_HAIKU, - CB_ST_IDLE, - CB_ST_CAT, - CB_ST_CHIST, - CB_ST_PAGESIZE, - CB_ST_SCREENS, - CB_ST_GRP, - CB_ST_LAG, - CB_ST_LANG, - CB_ST_LCLAUDE, - CB_ST_LOCAL, - CB_ST_LTERM, - CB_ST_VOICE, - CB_ST_WDAY, + CB_PG_JUMP, + CB_PG_NEXT, + CB_PG_PREV, CB_SW_NEW, CB_SW_NOOP, ) -from .switcher import build_switcher_keyboard - -Screen = Literal[ - "main", - "more", - "settings", - # Category sub-screens (group selector → category contents). - "settings_cat_card", - "settings_cat_notifications", - "settings_cat_voice", - "settings_cat_terminal", - "settings_cat_behavior", - # Individual setting sub-screens. - "settings_lag", - "settings_voice", - "settings_language", - "settings_agent", - "settings_weeklyday", - "settings_approve", - "settings_local", - "settings_cardhist", - "settings_pagesize", - "settings_screens", - "settings_bg_notify_finished", - "settings_bg_notify_error", - "settings_bg_notify_needs_action", - "settings_haiku", - "settings_idle_archive", -] - -# Group key -> (label translation key, sub-screen name, settings-dict key) -_SETTINGS_GROUPS: tuple[tuple[str, str, str, str], ...] = ( - ("agent_backend", "settings.group.agent", "settings_agent", "agent_backend"), - ("language", "settings.group.language", "settings_language", "language"), - ("live_lag", "settings.group.live_lag", "settings_lag", "live_lag"), - ("voice", "settings.group.voice", "settings_voice", "voice"), - ( - "weekly_reset_day", - "settings.group.weekly_reset_day", - "settings_weeklyday", - "weekly_reset_day", - ), - ( - "auto_approve", - "settings.group.auto_approve", - "settings_approve", - "auto_approve", - ), - ( - "session_idle_hours", - "settings.group.session_idle_hours", - "settings_idle_archive", - "session_idle_hours", - ), - ( - "local_terminal", - "settings.group.local_terminal", - "settings_local", - "local_terminal", - ), - ( - "card_history", - "settings.group.card_history", - "settings_cardhist", - "card_history", - ), - ( - "card_page_lines", - "settings.group.card_page_lines", - "settings_pagesize", - "card_page_lines", - ), - ( - "card_inline_screenshots", - "settings.group.card_inline_screenshots", - "settings_screens", - "card_inline_screenshots", - ), - ( - "bg_notify_finished", - "settings.group.bg_notify_finished", - "settings_bg_notify_finished", - "bg_notify_finished", - ), - ( - "bg_notify_error", - "settings.group.bg_notify_error", - "settings_bg_notify_error", - "bg_notify_error", - ), - ( - "bg_notify_needs_action", - "settings.group.bg_notify_needs_action", - "settings_bg_notify_needs_action", - "bg_notify_needs_action", - ), - ( - "haiku_naming", - "settings.group.haiku_naming", - "settings_haiku", - "haiku_naming", - ), +from .menu_settings import ( + _format_setting_value, + _highlight, + _parent_cat_cb, + _settings_agent_grid, + _settings_approve_grid, + _settings_bg_notify_grid, + _settings_cardhist_grid, + _settings_category_grid, + _settings_haiku_grid, + _settings_idle_archive_grid, + _settings_language_grid, + _settings_lag_grid, + _settings_local_grid, + _settings_main_grid, + _settings_pagesize_grid, + _settings_screens_grid, + _settings_voice_grid, + _settings_weeklyday_grid, ) - - -# Category taxonomy — main Settings screen renders categories; tapping -# a category shows its members. Settings became too many for a flat -# list (user feedback). The first entry in each tuple is the i18n -# label key; the second is the category sub-screen name; the third is -# the ordered tuple of setting keys (must match ``_SETTINGS_GROUPS``). -SETTINGS_CATEGORIES: tuple[tuple[str, str, tuple[str, ...]], ...] = ( - ( - "settings.cat.card", - "settings_cat_card", - ( - "live_lag", - "card_history", - "card_page_lines", - "card_inline_screenshots", - ), - ), - ( - "settings.cat.notifications", - "settings_cat_notifications", - ( - "bg_notify_finished", - "bg_notify_error", - "bg_notify_needs_action", - "weekly_reset_day", - ), - ), - ( - "settings.cat.voice", - "settings_cat_voice", - ("voice",), - ), - ( - "settings.cat.terminal", - "settings_cat_terminal", - ("local_terminal",), - ), - ( - "settings.cat.behavior", - "settings_cat_behavior", - ( - "agent_backend", - "auto_approve", - "session_idle_hours", - "haiku_naming", - "language", - ), - ), +from .menu_settings_data import ( + SETTINGS_CATEGORIES, + WEEKDAYS, + Screen, + _GROUP_TEXT_KEYS, + _SETTINGS_GROUPS, +) +from .menu_text import ( + render_more_text, + render_settings_group_text, + render_settings_text, ) +from .switcher import build_switcher_keyboard -WEEKDAYS: tuple[str, ...] = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") +__all__ = [ + "Screen", + "_SETTINGS_GROUPS", + "SETTINGS_CATEGORIES", + "WEEKDAYS", + "_has_active_session", + "can_offer_terminal", + "_has_pending_kb_action", + "_footer_top_row", + "_footer_bottom_row", + "_MM_BUTTONS", + "_more_grid", + "_highlight", + "_parent_cat_cb", + "_format_setting_value", + "_settings_main_grid", + "_settings_category_grid", + "_settings_lag_grid", + "_settings_voice_grid", + "_settings_language_grid", + "_settings_agent_grid", + "_settings_approve_grid", + "_settings_idle_archive_grid", + "_settings_local_grid", + "_settings_cardhist_grid", + "_settings_screens_grid", + "_settings_haiku_grid", + "_settings_bg_notify_grid", + "_settings_pagesize_grid", + "_settings_weeklyday_grid", + "build_footer_keyboard", + "render_settings_text", + "_GROUP_TEXT_KEYS", + "render_settings_group_text", + "render_more_text", +] def _has_active_session(user_id: int) -> bool: @@ -389,458 +274,6 @@ def _more_grid( return rows -def _highlight(label: str, active: bool) -> str: - return f"• {label}" if active else label - - -def _parent_cat_cb(group_key: str) -> str: - """Callback the Back row of an individual setting points at — the - CATEGORY sub-screen that contains ``group_key`` (per pivot #53 - feedback: tapping Back was dumping users at the top-level Settings - instead of the relevant category). - """ - for _label, cat_screen, members in SETTINGS_CATEGORIES: - if group_key in members: - return f"{CB_ST_CAT}{cat_screen}" - return CB_MM_SETTINGS - - -def _format_setting_value(user_id: int, value_key: str, cur: object) -> str: - """Format a single setting's current value for display in buttons.""" - if value_key == "live_lag": - return f"{int(cur)}s" if cur is not None else "?" # type: ignore[arg-type] - if value_key == "weekly_reset_day": - return t(user_id, f"day.{cur}") if cur else "?" - if value_key == "auto_approve": - return t(user_id, f"approve.{cur}") if cur else "?" - if value_key == "session_idle_hours": - try: - hours = int(str(cur)) - except (TypeError, ValueError): - return "?" - return t(user_id, "settings.value.hours", value=hours) - if value_key == "local_terminal": - return t(user_id, f"local.{cur}") if cur else "?" - if value_key == "card_history": - return f"{int(cur)} turns" if cur else "?" # type: ignore[arg-type] - if value_key == "card_page_lines": - return f"{int(cur)} lines" if cur else "?" # type: ignore[arg-type] - if value_key == "card_inline_screenshots": - return t(user_id, "screens.on") if cur else t(user_id, "screens.off") - if value_key in ("bg_notify_finished", "bg_notify_error", "bg_notify_needs_action"): - return t(user_id, "screens.on") if cur else t(user_id, "screens.off") - if value_key == "haiku_naming": - return t(user_id, "screens.on") if cur else t(user_id, "screens.off") - if value_key == "agent_backend": - return str(cur).capitalize() - return str(cur) if cur is not None else "?" - - -def _settings_main_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - """Top-level Settings screen — category selector. - - Settings became too many for a flat list (user feedback). Each - category opens a sub-screen listing its members. Languages / - auto-approve land in the 'Behavior' category for now. - """ - rows: list[list[InlineKeyboardButton]] = [] - for label_key, screen_name, _members in SETTINGS_CATEGORIES: - label = t(user_id, label_key) - rows.append( - [ - InlineKeyboardButton( - label, - callback_data=f"{CB_ST_CAT}{screen_name}", - ) - ] - ) - rows.append( - [InlineKeyboardButton(t(user_id, "btn.back"), callback_data=CB_ST_BACK)] - ) - return rows - - -def _settings_category_grid( - user_id: int, screen_name: str -) -> list[list[InlineKeyboardButton]]: - """Sub-screen for one category: its member settings as buttons.""" - members: tuple[str, ...] = () - for _label_key, sname, m in SETTINGS_CATEGORIES: - if sname == screen_name: - members = m - break - s = session_manager.get_user_settings(user_id) - groups_by_key = {key: (lk, sc, vk) for key, lk, sc, vk in _SETTINGS_GROUPS} - rows: list[list[InlineKeyboardButton]] = [] - for member_key in members: - if member_key not in groups_by_key: - continue - label_key, _sub_screen, value_key = groups_by_key[member_key] - cur = ( - session_manager.agent_backend - if value_key == "agent_backend" - else s.get(value_key, "") - ) - label = t(user_id, label_key) - value_str = _format_setting_value(user_id, value_key, cur) - rows.append( - [ - InlineKeyboardButton( - f"{label}: {value_str}", - callback_data=f"{CB_ST_GRP}{member_key}", - ) - ] - ) - rows.append( - [ - InlineKeyboardButton( - t(user_id, "btn.back"), callback_data=f"{CB_ST_CAT}settings" - ) - ] - ) - return rows - - -def _settings_lag_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - cur = int(session_manager.get_user_settings(user_id).get("live_lag", 4)) - return [ - [ - InlineKeyboardButton( - _highlight(f"{v}s", cur == v), - callback_data=f"{CB_ST_LAG}{v}", - ) - for v in (0, 2, 4, 8) - ], - [ - InlineKeyboardButton( - t(user_id, "btn.back"), callback_data=_parent_cat_cb("live_lag") - ) - ], - ] - - -def _settings_voice_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - cur = session_manager.get_user_settings(user_id).get("voice", "auto") - return [ - [ - InlineKeyboardButton( - _highlight(v, cur == v), - callback_data=f"{CB_ST_VOICE}{v}", - ) - for v in ("auto", "whisper", "apple", "off") - ], - [ - InlineKeyboardButton( - t(user_id, "btn.back"), callback_data=_parent_cat_cb("voice") - ) - ], - ] - - -def _settings_language_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - cur = session_manager.get_user_settings(user_id).get("language", "en") - return [ - [ - InlineKeyboardButton( - _highlight(f"{label}", cur == code), - callback_data=f"{CB_ST_LANG}{code}", - ) - for code, label in LANGUAGES - ], - [ - InlineKeyboardButton( - t(user_id, "btn.back"), callback_data=_parent_cat_cb("language") - ) - ], - ] - - -def _settings_agent_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - cur = session_manager.agent_backend - return [ - [ - InlineKeyboardButton( - _highlight(name.capitalize(), cur == name), - callback_data=f"{CB_ST_AGENT}{name}", - ) - for name in ("claude", "codex") - ], - [ - InlineKeyboardButton( - t(user_id, "btn.back"), - callback_data=_parent_cat_cb("agent_backend"), - ) - ], - ] - - -def _settings_approve_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - cur = session_manager.get_user_settings(user_id).get("auto_approve", "off") - return [ - [ - InlineKeyboardButton( - _highlight(t(user_id, f"approve.{v}"), cur == v), - callback_data=f"{CB_ST_APPROVE}{v}", - ) - for v in ("off", "on") - ], - [ - InlineKeyboardButton( - t(user_id, "btn.back"), callback_data=_parent_cat_cb("auto_approve") - ) - ], - ] - - -def _settings_idle_archive_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - from ..session import DEFAULT_IDLE_ARCHIVE_HOURS, IDLE_ARCHIVE_HOUR_CHOICES - - raw = session_manager.get_user_settings(user_id).get( - "session_idle_hours", DEFAULT_IDLE_ARCHIVE_HOURS - ) - try: - cur = int(raw) - except (TypeError, ValueError): - cur = DEFAULT_IDLE_ARCHIVE_HOURS - return [ - [ - InlineKeyboardButton( - _highlight(t(user_id, "settings.value.hours", value=v), cur == v), - callback_data=f"{CB_ST_IDLE}{v}", - ) - for v in IDLE_ARCHIVE_HOUR_CHOICES - ], - [ - InlineKeyboardButton( - t(user_id, "btn.back"), - callback_data=_parent_cat_cb("session_idle_hours"), - ) - ], - ] - - -def _settings_local_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - import platform - - from ..local_terminal import LINUX_TEMPLATES, detect_linux_emulators - - settings = session_manager.get_user_settings(user_id) - cur = settings.get("local_terminal", "off") - cur_cmd = settings.get("local_terminal_cmd", "") - - rows: list[list[InlineKeyboardButton]] = [] - rows.append( - [ - InlineKeyboardButton( - _highlight(t(user_id, f"local.{v}"), cur == v), - callback_data=f"{CB_ST_LOCAL}{v}", - ) - for v in ("off", "manual", "auto") - ] - ) - - # Linux + a terminal-enabled mode: surface the emulator picker. - # Empty list → fall back to the claude-typed snippet flow. - if cur in ("manual", "auto") and platform.system() == "Linux": - detected = detect_linux_emulators() - if detected: - for i in range(0, len(detected), 2): - row: list[InlineKeyboardButton] = [] - for name in detected[i : i + 2]: - selected = cur_cmd == LINUX_TEMPLATES[name] - row.append( - InlineKeyboardButton( - _highlight(name, selected), - callback_data=f"{CB_ST_LTERM}{name}", - ) - ) - rows.append(row) - rows.append( - [ - InlineKeyboardButton( - t(user_id, "settings.local.claude_help"), - callback_data=CB_ST_LCLAUDE, - ) - ] - ) - - rows.append( - [ - InlineKeyboardButton( - t(user_id, "btn.back"), - callback_data=_parent_cat_cb("local_terminal"), - ) - ] - ) - return rows - - -def _settings_cardhist_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - """How many end_turn boundaries to seed into a fresh live card. - - Fixed row of values 10 / 20 / 50 / 100. Deep history beyond this is - always reachable via ``/history`` regardless of the chosen value. - """ - raw = session_manager.get_user_settings(user_id).get("card_history", 20) - try: - cur = int(raw) - except (TypeError, ValueError): - cur = 20 - return [ - [ - InlineKeyboardButton( - _highlight(str(v), cur == v), - callback_data=f"{CB_ST_CHIST}{v}", - ) - for v in (10, 20, 50, 100) - ], - [ - InlineKeyboardButton( - t(user_id, "btn.back"), - callback_data=_parent_cat_cb("card_history"), - ) - ], - ] - - -def _settings_screens_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - """Inline-screenshots on/off toggle. Settings body explains the - ~4x page-size shrinkage when ON (caption limit 1024 vs text 4096). - """ - cur = bool( - session_manager.get_user_settings(user_id).get("card_inline_screenshots", False) - ) - return [ - [ - InlineKeyboardButton( - _highlight(t(user_id, "screens.on"), cur), - callback_data=f"{CB_ST_SCREENS}on", - ), - InlineKeyboardButton( - _highlight(t(user_id, "screens.off"), not cur), - callback_data=f"{CB_ST_SCREENS}off", - ), - ], - [ - InlineKeyboardButton( - t(user_id, "btn.back"), - callback_data=_parent_cat_cb("card_inline_screenshots"), - ) - ], - ] - - -def _settings_haiku_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - """Lightweight-model auto-rename on/off toggle. - - When *off*, new sessions keep the directory-basename name forever - (``workdir``, ``workdir-2``, ...). When *on*, a one-shot backend-specific - model call on the first user message ≥20 chars renames the session. - """ - cur = bool(session_manager.get_user_settings(user_id).get("haiku_naming", True)) - return [ - [ - InlineKeyboardButton( - _highlight(t(user_id, "screens.on"), cur), - callback_data=f"{CB_ST_HAIKU}on", - ), - InlineKeyboardButton( - _highlight(t(user_id, "screens.off"), not cur), - callback_data=f"{CB_ST_HAIKU}off", - ), - ], - [ - InlineKeyboardButton( - t(user_id, "btn.back"), callback_data=_parent_cat_cb("haiku_naming") - ) - ], - ] - - -def _settings_bg_notify_grid( - user_id: int, key: str, back_to: str -) -> list[list[InlineKeyboardButton]]: - """Simple on/off toggle for one bg_notify_* setting. - - ``key`` is one of bg_notify_finished / _error / _needs_action. - ``back_to`` is the screen name to return to (the parent category). - """ - cur = bool(session_manager.get_user_settings(user_id).get(key, True)) - return [ - [ - InlineKeyboardButton( - _highlight(t(user_id, "screens.on"), cur), - callback_data=f"{CB_ST_BGNOTIFY}{key}:on", - ), - InlineKeyboardButton( - _highlight(t(user_id, "screens.off"), not cur), - callback_data=f"{CB_ST_BGNOTIFY}{key}:off", - ), - ], - [ - InlineKeyboardButton( - t(user_id, "btn.back"), callback_data=f"{CB_ST_CAT}{back_to}" - ) - ], - ] - - -def _settings_pagesize_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - """Max page size in logical \\n-delimited lines. - - Fixed row 10 / 20 / 40 / 70. Smart anchor chunking with ±5 lines - overshoot handles single events that exceed the budget without - breaking mid-sentence / mid-word. - """ - raw = session_manager.get_user_settings(user_id).get("card_page_lines", 20) - try: - cur = int(raw) - except (TypeError, ValueError): - cur = 20 - return [ - [ - InlineKeyboardButton( - _highlight(str(v), cur == v), - callback_data=f"{CB_ST_PAGESIZE}{v}", - ) - for v in (10, 20, 40, 70) - ], - [ - InlineKeyboardButton( - t(user_id, "btn.back"), - callback_data=_parent_cat_cb("card_page_lines"), - ) - ], - ] - - -def _settings_weeklyday_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - cur = session_manager.get_user_settings(user_id).get("weekly_reset_day", "mon") - rows: list[list[InlineKeyboardButton]] = [] - # 4 + 3 layout fits comfortably on a phone. - week = list(WEEKDAYS) - for chunk_start in (0, 4): - chunk = week[chunk_start : chunk_start + 4] - rows.append( - [ - InlineKeyboardButton( - _highlight(t(user_id, f"day.{d}"), cur == d), - callback_data=f"{CB_ST_WDAY}{d}", - ) - for d in chunk - ] - ) - rows.append( - [ - InlineKeyboardButton( - t(user_id, "btn.back"), - callback_data=_parent_cat_cb("weekly_reset_day"), - ) - ] - ) - return rows - - def build_footer_keyboard( user_id: int, *, @@ -986,54 +419,3 @@ def build_footer_keyboard( if not rows: return None return InlineKeyboardMarkup(rows) - - -def render_settings_text(user_id: int) -> str: - """Body text shown on the top-level Settings screen.""" - s = session_manager.get_user_settings(user_id) - return t( - user_id, - "settings.body", - agent=session_manager.agent_backend.capitalize(), - language=s.get("language", "en"), - live_lag=int(s.get("live_lag", 4)), - voice=s.get("voice", "auto"), - ) - - -_GROUP_TEXT_KEYS: dict[str, str] = { - "settings_agent": "settings.agent.body", - "settings_lag": "settings.lag.body", - "settings_voice": "settings.voice.body", - "settings_language": "settings.lang.body", - "settings_weeklyday": "settings.weeklyday.body", - "settings_approve": "settings.approve.body", - "settings_local": "settings.local.body", - "settings_cardhist": "settings.cardhist.body", - "settings_pagesize": "settings.pagesize.body", - "settings_screens": "settings.screens.body", - "settings_cat_card": "settings.cat.card.body", - "settings_cat_notifications": "settings.cat.notifications.body", - "settings_cat_voice": "settings.cat.voice.body", - "settings_cat_terminal": "settings.cat.terminal.body", - "settings_cat_behavior": "settings.cat.behavior.body", - "settings_bg_notify_finished": "settings.bg_notify.finished.body", - "settings_bg_notify_error": "settings.bg_notify.error.body", - "settings_bg_notify_needs_action": "settings.bg_notify.needs_action.body", - "settings_haiku": "settings.haiku.body", - "settings_idle_archive": "settings.idle_archive.body", -} - - -def render_settings_group_text(user_id: int, screen: Screen) -> str: - """Body text for a settings group sub-screen.""" - key = _GROUP_TEXT_KEYS.get(screen, "settings.title") - return t(user_id, key) - - -def render_more_text(user_id: int) -> str: - """Body text shown above the menu grid.""" - sess = session_manager.get_active_session(user_id) - if sess is None: - return t(user_id, "menu.empty") - return t(user_id, "menu.active", name=sess.name or sess.id) diff --git a/src/ccbot/handlers/menu_settings.py b/src/ccbot/handlers/menu_settings.py new file mode 100644 index 00000000..33edaa36 --- /dev/null +++ b/src/ccbot/handlers/menu_settings.py @@ -0,0 +1,508 @@ +"""Inline keyboard builders for Telegram Settings screens. + +The functions are behavior-preserving extractions from ``handlers.menu`` and +continue to be re-exported by that compatibility facade. +""" + +from __future__ import annotations + +from telegram import InlineKeyboardButton + +from ..i18n import LANGUAGES, t +from ..session import session_manager +from .callback_data import ( + CB_MM_SETTINGS, + CB_ST_APPROVE, + CB_ST_AGENT, + CB_ST_BACK, + CB_ST_BGNOTIFY, + CB_ST_CAT, + CB_ST_CHIST, + CB_ST_GRP, + CB_ST_HAIKU, + CB_ST_IDLE, + CB_ST_LANG, + CB_ST_LAG, + CB_ST_LCLAUDE, + CB_ST_LOCAL, + CB_ST_LTERM, + CB_ST_PAGESIZE, + CB_ST_SCREENS, + CB_ST_VOICE, + CB_ST_WDAY, +) +from .menu_settings_data import SETTINGS_CATEGORIES, WEEKDAYS, _SETTINGS_GROUPS + + +__all__ = [ + "_highlight", + "_parent_cat_cb", + "_format_setting_value", + "_settings_main_grid", + "_settings_category_grid", + "_settings_lag_grid", + "_settings_voice_grid", + "_settings_language_grid", + "_settings_agent_grid", + "_settings_approve_grid", + "_settings_idle_archive_grid", + "_settings_local_grid", + "_settings_cardhist_grid", + "_settings_screens_grid", + "_settings_haiku_grid", + "_settings_bg_notify_grid", + "_settings_pagesize_grid", + "_settings_weeklyday_grid", +] + + +def _highlight(label: str, active: bool) -> str: + return f"• {label}" if active else label + + +def _parent_cat_cb(group_key: str) -> str: + """Callback the Back row of an individual setting points at — the + CATEGORY sub-screen that contains ``group_key`` (per pivot #53 + feedback: tapping Back was dumping users at the top-level Settings + instead of the relevant category). + """ + for _label, cat_screen, members in SETTINGS_CATEGORIES: + if group_key in members: + return f"{CB_ST_CAT}{cat_screen}" + return CB_MM_SETTINGS + + +def _format_setting_value(user_id: int, value_key: str, cur: object) -> str: + """Format a single setting's current value for display in buttons.""" + if value_key == "live_lag": + return f"{int(cur)}s" if cur is not None else "?" # type: ignore[arg-type] + if value_key == "weekly_reset_day": + return t(user_id, f"day.{cur}") if cur else "?" + if value_key == "auto_approve": + return t(user_id, f"approve.{cur}") if cur else "?" + if value_key == "session_idle_hours": + try: + hours = int(str(cur)) + except (TypeError, ValueError): + return "?" + return t(user_id, "settings.value.hours", value=hours) + if value_key == "local_terminal": + return t(user_id, f"local.{cur}") if cur else "?" + if value_key == "card_history": + return f"{int(cur)} turns" if cur else "?" # type: ignore[arg-type] + if value_key == "card_page_lines": + return f"{int(cur)} lines" if cur else "?" # type: ignore[arg-type] + if value_key == "card_inline_screenshots": + return t(user_id, "screens.on") if cur else t(user_id, "screens.off") + if value_key in ("bg_notify_finished", "bg_notify_error", "bg_notify_needs_action"): + return t(user_id, "screens.on") if cur else t(user_id, "screens.off") + if value_key == "haiku_naming": + return t(user_id, "screens.on") if cur else t(user_id, "screens.off") + if value_key == "agent_backend": + return str(cur).capitalize() + return str(cur) if cur is not None else "?" + + +def _settings_main_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + """Top-level Settings screen — category selector. + + Settings became too many for a flat list (user feedback). Each + category opens a sub-screen listing its members. Languages / + auto-approve land in the 'Behavior' category for now. + """ + rows: list[list[InlineKeyboardButton]] = [] + for label_key, screen_name, _members in SETTINGS_CATEGORIES: + label = t(user_id, label_key) + rows.append( + [ + InlineKeyboardButton( + label, + callback_data=f"{CB_ST_CAT}{screen_name}", + ) + ] + ) + rows.append( + [InlineKeyboardButton(t(user_id, "btn.back"), callback_data=CB_ST_BACK)] + ) + return rows + + +def _settings_category_grid( + user_id: int, screen_name: str +) -> list[list[InlineKeyboardButton]]: + """Sub-screen for one category: its member settings as buttons.""" + members: tuple[str, ...] = () + for _label_key, sname, m in SETTINGS_CATEGORIES: + if sname == screen_name: + members = m + break + s = session_manager.get_user_settings(user_id) + groups_by_key = {key: (lk, sc, vk) for key, lk, sc, vk in _SETTINGS_GROUPS} + rows: list[list[InlineKeyboardButton]] = [] + for member_key in members: + if member_key not in groups_by_key: + continue + label_key, _sub_screen, value_key = groups_by_key[member_key] + cur = ( + session_manager.agent_backend + if value_key == "agent_backend" + else s.get(value_key, "") + ) + label = t(user_id, label_key) + value_str = _format_setting_value(user_id, value_key, cur) + rows.append( + [ + InlineKeyboardButton( + f"{label}: {value_str}", + callback_data=f"{CB_ST_GRP}{member_key}", + ) + ] + ) + rows.append( + [ + InlineKeyboardButton( + t(user_id, "btn.back"), callback_data=f"{CB_ST_CAT}settings" + ) + ] + ) + return rows + + +def _settings_lag_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + cur = int(session_manager.get_user_settings(user_id).get("live_lag", 4)) + return [ + [ + InlineKeyboardButton( + _highlight(f"{v}s", cur == v), + callback_data=f"{CB_ST_LAG}{v}", + ) + for v in (0, 2, 4, 8) + ], + [ + InlineKeyboardButton( + t(user_id, "btn.back"), callback_data=_parent_cat_cb("live_lag") + ) + ], + ] + + +def _settings_voice_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + cur = session_manager.get_user_settings(user_id).get("voice", "auto") + return [ + [ + InlineKeyboardButton( + _highlight(v, cur == v), + callback_data=f"{CB_ST_VOICE}{v}", + ) + for v in ("auto", "whisper", "apple", "off") + ], + [ + InlineKeyboardButton( + t(user_id, "btn.back"), callback_data=_parent_cat_cb("voice") + ) + ], + ] + + +def _settings_language_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + cur = session_manager.get_user_settings(user_id).get("language", "en") + return [ + [ + InlineKeyboardButton( + _highlight(f"{label}", cur == code), + callback_data=f"{CB_ST_LANG}{code}", + ) + for code, label in LANGUAGES + ], + [ + InlineKeyboardButton( + t(user_id, "btn.back"), callback_data=_parent_cat_cb("language") + ) + ], + ] + + +def _settings_agent_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + cur = session_manager.agent_backend + return [ + [ + InlineKeyboardButton( + _highlight(name.capitalize(), cur == name), + callback_data=f"{CB_ST_AGENT}{name}", + ) + for name in ("claude", "codex") + ], + [ + InlineKeyboardButton( + t(user_id, "btn.back"), + callback_data=_parent_cat_cb("agent_backend"), + ) + ], + ] + + +def _settings_approve_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + cur = session_manager.get_user_settings(user_id).get("auto_approve", "off") + return [ + [ + InlineKeyboardButton( + _highlight(t(user_id, f"approve.{v}"), cur == v), + callback_data=f"{CB_ST_APPROVE}{v}", + ) + for v in ("off", "on") + ], + [ + InlineKeyboardButton( + t(user_id, "btn.back"), callback_data=_parent_cat_cb("auto_approve") + ) + ], + ] + + +def _settings_idle_archive_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + from ..session import DEFAULT_IDLE_ARCHIVE_HOURS, IDLE_ARCHIVE_HOUR_CHOICES + + raw = session_manager.get_user_settings(user_id).get( + "session_idle_hours", DEFAULT_IDLE_ARCHIVE_HOURS + ) + try: + cur = int(raw) + except (TypeError, ValueError): + cur = DEFAULT_IDLE_ARCHIVE_HOURS + return [ + [ + InlineKeyboardButton( + _highlight(t(user_id, "settings.value.hours", value=v), cur == v), + callback_data=f"{CB_ST_IDLE}{v}", + ) + for v in IDLE_ARCHIVE_HOUR_CHOICES + ], + [ + InlineKeyboardButton( + t(user_id, "btn.back"), + callback_data=_parent_cat_cb("session_idle_hours"), + ) + ], + ] + + +def _settings_local_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + import platform + + from ..local_terminal import LINUX_TEMPLATES, detect_linux_emulators + + settings = session_manager.get_user_settings(user_id) + cur = settings.get("local_terminal", "off") + cur_cmd = settings.get("local_terminal_cmd", "") + + rows: list[list[InlineKeyboardButton]] = [] + rows.append( + [ + InlineKeyboardButton( + _highlight(t(user_id, f"local.{v}"), cur == v), + callback_data=f"{CB_ST_LOCAL}{v}", + ) + for v in ("off", "manual", "auto") + ] + ) + + # Linux + a terminal-enabled mode: surface the emulator picker. + # Empty list → fall back to the claude-typed snippet flow. + if cur in ("manual", "auto") and platform.system() == "Linux": + detected = detect_linux_emulators() + if detected: + for i in range(0, len(detected), 2): + row: list[InlineKeyboardButton] = [] + for name in detected[i : i + 2]: + selected = cur_cmd == LINUX_TEMPLATES[name] + row.append( + InlineKeyboardButton( + _highlight(name, selected), + callback_data=f"{CB_ST_LTERM}{name}", + ) + ) + rows.append(row) + rows.append( + [ + InlineKeyboardButton( + t(user_id, "settings.local.claude_help"), + callback_data=CB_ST_LCLAUDE, + ) + ] + ) + + rows.append( + [ + InlineKeyboardButton( + t(user_id, "btn.back"), + callback_data=_parent_cat_cb("local_terminal"), + ) + ] + ) + return rows + + +def _settings_cardhist_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + """How many end_turn boundaries to seed into a fresh live card. + + Fixed row of values 10 / 20 / 50 / 100. Deep history beyond this is + always reachable via ``/history`` regardless of the chosen value. + """ + raw = session_manager.get_user_settings(user_id).get("card_history", 20) + try: + cur = int(raw) + except (TypeError, ValueError): + cur = 20 + return [ + [ + InlineKeyboardButton( + _highlight(str(v), cur == v), + callback_data=f"{CB_ST_CHIST}{v}", + ) + for v in (10, 20, 50, 100) + ], + [ + InlineKeyboardButton( + t(user_id, "btn.back"), + callback_data=_parent_cat_cb("card_history"), + ) + ], + ] + + +def _settings_screens_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + """Inline-screenshots on/off toggle. Settings body explains the + The screenshot is the final media block of the Rich Markdown card. + """ + cur = bool( + session_manager.get_user_settings(user_id).get("card_inline_screenshots", False) + ) + return [ + [ + InlineKeyboardButton( + _highlight(t(user_id, "screens.on"), cur), + callback_data=f"{CB_ST_SCREENS}on", + ), + InlineKeyboardButton( + _highlight(t(user_id, "screens.off"), not cur), + callback_data=f"{CB_ST_SCREENS}off", + ), + ], + [ + InlineKeyboardButton( + t(user_id, "btn.back"), + callback_data=_parent_cat_cb("card_inline_screenshots"), + ) + ], + ] + + +def _settings_haiku_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + """Lightweight-model auto-rename on/off toggle. + + When *off*, new sessions keep the directory-basename name forever + (``workdir``, ``workdir-2``, ...). When *on*, a one-shot backend-specific + model call on the first user message ≥20 chars renames the session. + """ + cur = bool(session_manager.get_user_settings(user_id).get("haiku_naming", True)) + return [ + [ + InlineKeyboardButton( + _highlight(t(user_id, "screens.on"), cur), + callback_data=f"{CB_ST_HAIKU}on", + ), + InlineKeyboardButton( + _highlight(t(user_id, "screens.off"), not cur), + callback_data=f"{CB_ST_HAIKU}off", + ), + ], + [ + InlineKeyboardButton( + t(user_id, "btn.back"), callback_data=_parent_cat_cb("haiku_naming") + ) + ], + ] + + +def _settings_bg_notify_grid( + user_id: int, key: str, back_to: str +) -> list[list[InlineKeyboardButton]]: + """Simple on/off toggle for one bg_notify_* setting. + + ``key`` is one of bg_notify_finished / _error / _needs_action. + ``back_to`` is the screen name to return to (the parent category). + """ + cur = bool(session_manager.get_user_settings(user_id).get(key, True)) + return [ + [ + InlineKeyboardButton( + _highlight(t(user_id, "screens.on"), cur), + callback_data=f"{CB_ST_BGNOTIFY}{key}:on", + ), + InlineKeyboardButton( + _highlight(t(user_id, "screens.off"), not cur), + callback_data=f"{CB_ST_BGNOTIFY}{key}:off", + ), + ], + [ + InlineKeyboardButton( + t(user_id, "btn.back"), callback_data=f"{CB_ST_CAT}{back_to}" + ) + ], + ] + + +def _settings_pagesize_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + """Max page size in logical \\n-delimited lines. + + Fixed row 10 / 20 / 40 / 70. Smart anchor chunking with ±5 lines + overshoot handles single events that exceed the budget without + breaking mid-sentence / mid-word. + """ + raw = session_manager.get_user_settings(user_id).get("card_page_lines", 20) + try: + cur = int(raw) + except (TypeError, ValueError): + cur = 20 + return [ + [ + InlineKeyboardButton( + _highlight(str(v), cur == v), + callback_data=f"{CB_ST_PAGESIZE}{v}", + ) + for v in (10, 20, 40, 70) + ], + [ + InlineKeyboardButton( + t(user_id, "btn.back"), + callback_data=_parent_cat_cb("card_page_lines"), + ) + ], + ] + + +def _settings_weeklyday_grid(user_id: int) -> list[list[InlineKeyboardButton]]: + cur = session_manager.get_user_settings(user_id).get("weekly_reset_day", "mon") + rows: list[list[InlineKeyboardButton]] = [] + # 4 + 3 layout fits comfortably on a phone. + week = list(WEEKDAYS) + for chunk_start in (0, 4): + chunk = week[chunk_start : chunk_start + 4] + rows.append( + [ + InlineKeyboardButton( + _highlight(t(user_id, f"day.{d}"), cur == d), + callback_data=f"{CB_ST_WDAY}{d}", + ) + for d in chunk + ] + ) + rows.append( + [ + InlineKeyboardButton( + t(user_id, "btn.back"), + callback_data=_parent_cat_cb("weekly_reset_day"), + ) + ] + ) + return rows diff --git a/src/ccbot/handlers/menu_settings_data.py b/src/ccbot/handlers/menu_settings_data.py new file mode 100644 index 00000000..3cf0891d --- /dev/null +++ b/src/ccbot/handlers/menu_settings_data.py @@ -0,0 +1,191 @@ +"""Settings screen names and immutable menu catalog metadata. + +This leaf module has no runtime state and is shared by keyboard and text +renderers. Names mirror the historical ``handlers.menu`` attributes. +""" + +from __future__ import annotations + +from typing import Literal + +__all__ = [ + "Screen", + "_SETTINGS_GROUPS", + "SETTINGS_CATEGORIES", + "WEEKDAYS", + "_GROUP_TEXT_KEYS", +] + +Screen = Literal[ + "main", + "more", + "settings", + # Category sub-screens (group selector → category contents). + "settings_cat_card", + "settings_cat_notifications", + "settings_cat_voice", + "settings_cat_terminal", + "settings_cat_behavior", + # Individual setting sub-screens. + "settings_lag", + "settings_voice", + "settings_language", + "settings_agent", + "settings_weeklyday", + "settings_approve", + "settings_local", + "settings_cardhist", + "settings_pagesize", + "settings_screens", + "settings_bg_notify_finished", + "settings_bg_notify_error", + "settings_bg_notify_needs_action", + "settings_haiku", + "settings_idle_archive", +] + + +_SETTINGS_GROUPS: tuple[tuple[str, str, str, str], ...] = ( + ("agent_backend", "settings.group.agent", "settings_agent", "agent_backend"), + ("language", "settings.group.language", "settings_language", "language"), + ("live_lag", "settings.group.live_lag", "settings_lag", "live_lag"), + ("voice", "settings.group.voice", "settings_voice", "voice"), + ( + "weekly_reset_day", + "settings.group.weekly_reset_day", + "settings_weeklyday", + "weekly_reset_day", + ), + ( + "auto_approve", + "settings.group.auto_approve", + "settings_approve", + "auto_approve", + ), + ( + "session_idle_hours", + "settings.group.session_idle_hours", + "settings_idle_archive", + "session_idle_hours", + ), + ( + "local_terminal", + "settings.group.local_terminal", + "settings_local", + "local_terminal", + ), + ( + "card_history", + "settings.group.card_history", + "settings_cardhist", + "card_history", + ), + ( + "card_page_lines", + "settings.group.card_page_lines", + "settings_pagesize", + "card_page_lines", + ), + ( + "card_inline_screenshots", + "settings.group.card_inline_screenshots", + "settings_screens", + "card_inline_screenshots", + ), + ( + "bg_notify_finished", + "settings.group.bg_notify_finished", + "settings_bg_notify_finished", + "bg_notify_finished", + ), + ( + "bg_notify_error", + "settings.group.bg_notify_error", + "settings_bg_notify_error", + "bg_notify_error", + ), + ( + "bg_notify_needs_action", + "settings.group.bg_notify_needs_action", + "settings_bg_notify_needs_action", + "bg_notify_needs_action", + ), + ( + "haiku_naming", + "settings.group.haiku_naming", + "settings_haiku", + "haiku_naming", + ), +) + + +SETTINGS_CATEGORIES: tuple[tuple[str, str, tuple[str, ...]], ...] = ( + ( + "settings.cat.card", + "settings_cat_card", + ( + "live_lag", + "card_history", + "card_page_lines", + "card_inline_screenshots", + ), + ), + ( + "settings.cat.notifications", + "settings_cat_notifications", + ( + "bg_notify_finished", + "bg_notify_error", + "bg_notify_needs_action", + "weekly_reset_day", + ), + ), + ( + "settings.cat.voice", + "settings_cat_voice", + ("voice",), + ), + ( + "settings.cat.terminal", + "settings_cat_terminal", + ("local_terminal",), + ), + ( + "settings.cat.behavior", + "settings_cat_behavior", + ( + "agent_backend", + "auto_approve", + "session_idle_hours", + "haiku_naming", + "language", + ), + ), +) + + +WEEKDAYS: tuple[str, ...] = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") + + +_GROUP_TEXT_KEYS: dict[str, str] = { + "settings_agent": "settings.agent.body", + "settings_lag": "settings.lag.body", + "settings_voice": "settings.voice.body", + "settings_language": "settings.lang.body", + "settings_weeklyday": "settings.weeklyday.body", + "settings_approve": "settings.approve.body", + "settings_local": "settings.local.body", + "settings_cardhist": "settings.cardhist.body", + "settings_pagesize": "settings.pagesize.body", + "settings_screens": "settings.screens.body", + "settings_cat_card": "settings.cat.card.body", + "settings_cat_notifications": "settings.cat.notifications.body", + "settings_cat_voice": "settings.cat.voice.body", + "settings_cat_terminal": "settings.cat.terminal.body", + "settings_cat_behavior": "settings.cat.behavior.body", + "settings_bg_notify_finished": "settings.bg_notify.finished.body", + "settings_bg_notify_error": "settings.bg_notify.error.body", + "settings_bg_notify_needs_action": "settings.bg_notify.needs_action.body", + "settings_haiku": "settings.haiku.body", + "settings_idle_archive": "settings.idle_archive.body", +} diff --git a/src/ccbot/handlers/menu_text.py b/src/ccbot/handlers/menu_text.py new file mode 100644 index 00000000..c954ed03 --- /dev/null +++ b/src/ccbot/handlers/menu_text.py @@ -0,0 +1,60 @@ +"""Text renderers for the Menu and Settings Telegram screens. + +The stable ``handlers.menu`` module re-exports these functions. +""" + +from __future__ import annotations + +from ..i18n import t +from ..session import session_manager +from .menu_settings_data import Screen, _GROUP_TEXT_KEYS + + +__all__ = [ + "render_settings_text", + "render_settings_group_text", + "render_more_text", +] + + +def _settings_hard_breaks(text: str) -> str: + """Make single newlines hard breaks while preserving blank paragraphs. + + CommonMark treats a bare newline as whitespace in Rich Markdown. Two + trailing spaces keep the intended settings layout; plain-text fallback + still displays the same newlines and merely carries invisible spaces. + """ + lines = text.split("\n") + for index, line in enumerate(lines[:-1]): + if line.strip() and lines[index + 1].strip(): + lines[index] = f"{line.rstrip()} " + return "\n".join(lines) + + +def render_settings_text(user_id: int) -> str: + """Body text shown on the top-level Settings screen.""" + s = session_manager.get_user_settings(user_id) + return _settings_hard_breaks( + t( + user_id, + "settings.body", + agent=session_manager.agent_backend.capitalize(), + language=s.get("language", "en"), + live_lag=int(s.get("live_lag", 4)), + voice=s.get("voice", "auto"), + ) + ) + + +def render_settings_group_text(user_id: int, screen: Screen) -> str: + """Body text for a settings group sub-screen.""" + key = _GROUP_TEXT_KEYS.get(screen, "settings.title") + return _settings_hard_breaks(t(user_id, key)) + + +def render_more_text(user_id: int) -> str: + """Body text shown above the menu grid.""" + sess = session_manager.get_active_session(user_id) + if sess is None: + return t(user_id, "menu.empty") + return t(user_id, "menu.active", name=sess.name or sess.id) diff --git a/src/ccbot/handlers/notifications.py b/src/ccbot/handlers/notifications.py index 6a512654..69237e5b 100644 --- a/src/ccbot/handlers/notifications.py +++ b/src/ccbot/handlers/notifications.py @@ -1,61 +1,12 @@ -"""Live-card notifications for the active session. +"""Compatibility facade for the decomposed live-card notification runtime. -A "card" is a single Telegram message that the bot keeps editMessageText- -updating as Claude emits tool calls, thinking blocks, and text chunks. -Only the **active** session paints its card to chat; background sessions -go through ``handlers.bg_status`` and surface as a compact panel at the -bottom of the active card. - -A fresh card opens (a new TG message is sent) on: - - - long pause: previous card sat idle for >= STALE_CARD_SECONDS - - inbound receipt (the card itself acknowledges queue admission below - the newest user message) - - ``repost_card`` for direct/legacy handler paths - - first event of a new session - -Within a single card, content is paginated. Each ``Event`` with -``is_page_break=True`` (currently end_turn assistant text) becomes -the top of a new page; everything preceding it goes on the previous -page. Default focus = the page anchored to the latest answer. - -The header line carries: - - `` ** [] · · HH:MM`` - -— where HH:MM is the time of the last claude event so the user can -tell at a glance whether the card is fresh or has been quiet. - -When a tool_result arrives matching a previous tool_use, the existing -tool_use Event is mutated in place (``completed_at`` set, body replaced -with the result), so the ``▷`` line flips to ``✓`` (or ``✗`` on error) -and the elapsed timer is replaced with the start-time HH:MM. - -Module layout -------------- -This module is the lifecycle / facade layer. The pure model + render -helpers live in ``handlers.card_model``; the stateless kb-mode keyboard -and pane-capture helpers live in ``handlers.kb_mode``. Both are -re-exported below so existing -``from ccbot.handlers.notifications import X`` and ``notifications.X`` -references resolve unchanged. The module-global card registries -(``_cards`` / ``_card_locks`` / ``_repost_intent`` / ``_msg_to_session``) -and all the stateful orchestration that mutates them stay here. +State and orchestration live in focused card_* modules. The historical import +path and mutable registry identities remain stable for callers and tests. """ -from __future__ import annotations - -import asyncio -import logging -import time -from pathlib import Path - -from telegram import Bot, InlineKeyboardMarkup -from telegram.error import BadRequest, RetryAfter - -from ..config import config -from ..session import Session, session_manager -from ..session_monitor import NewMessage +from ..session import session_manager +from .menu import build_footer_keyboard +from .message_sender import safe_send from .card_model import ( CARD_HARD_LIMIT, CARD_MAX_EVENTS, @@ -95,17 +46,92 @@ render_event, render_page, ) +from .card_registry import ( + _cards, + _card_surface_tasks, + _card_locks, + _card_lock, + _carrier_edit_locks, + _carrier_edit_lock, + _user_send_locks, + _user_send_lock, + _strip_stale_switchers, + _MSG_REGISTRY_LIMIT, + _msg_to_session, + _register_msg, + lookup_session_for_message, + reset_card_msg_id_for_user, + _inline_screens_enabled, + _should_buffer, + _repost_intent, + begin_repost_intent, + end_repost_intent, + reset_card, + _legacy, +) +from .card_kb_state import ( + has_pending_kb, + enter_kb_mode, + exit_kb_mode, + get_card_state, +) +from .card_seed import ( + _seed_events_from_jsonl, + _transcript_mtime, + _ensure_seeded, +) +from .card_carrier import ( + cancel_pending_card_edits, + close_card_view, + set_card_context_pct, + mark_card_paused, + pause_card_view, + transfer_card_to_carrier, + activate_card_on_carrier, + card_is_below, + detach_paused_cards_at_message, + release_card_message, + resume_card_view, + paint_card_on_carrier, + restore_card, + clear_card, +) +from .card_transport import ( + _send_card, + _send_card_locked, + _edit_card, + _edit_card_unlocked, + _PHOTO_EDIT_MIN_INTERVAL, + _edit_photo_card, + _deferred_edit, +) +from .card_updates import ( + update_session_card, + _update_session_card_locked, + finalize_task, + _send_attachments, + push_event, +) +from .card_stall import ( + is_card_in_menu_view, + is_card_finalized, + is_card_busy, + STALL_FINALIZE_AFTER_SECONDS, + STALL_FINALIZE_TOOL_USE_SECONDS, + maybe_finalize_stalled, + is_active_for_user, + repost_card, +) +from .card_surface import ( + surface_card_after_message, + schedule_card_after_message, + shutdown_card_surface_tasks, + refresh_panel, + CARD_TIMER_TICK_SECONDS, + card_timer_loop, +) from .kb_mode import _capture_pane_png, build_kb_mode_keyboard -from .menu import build_footer_keyboard -from .message_sender import safe_send -from .switcher import session_emoji -from .tg_format import Attachment, split_overflow - -logger = logging.getLogger(__name__) -# Re-export model / kb-mode names so existing -# ``from ccbot.handlers.notifications import X`` callers and the e2e -# tests' ``notifications.X`` references keep resolving unchanged. __all__ = [ "CARD_HARD_LIMIT", "CARD_MAX_EVENTS", @@ -149,2414 +175,72 @@ "schedule_card_after_message", "shutdown_card_surface_tasks", "surface_card_after_message", + "_cards", + "_card_surface_tasks", + "_card_locks", + "_card_lock", + "_carrier_edit_locks", + "_carrier_edit_lock", + "_user_send_locks", + "_user_send_lock", + "_strip_stale_switchers", + "_MSG_REGISTRY_LIMIT", + "_msg_to_session", + "_register_msg", + "lookup_session_for_message", + "reset_card_msg_id_for_user", + "_inline_screens_enabled", + "_should_buffer", + "_repost_intent", + "begin_repost_intent", + "end_repost_intent", + "reset_card", + "_legacy", + "has_pending_kb", + "enter_kb_mode", + "exit_kb_mode", + "get_card_state", + "_seed_events_from_jsonl", + "_transcript_mtime", + "_ensure_seeded", + "cancel_pending_card_edits", + "close_card_view", + "set_card_context_pct", + "mark_card_paused", + "pause_card_view", + "transfer_card_to_carrier", + "activate_card_on_carrier", + "card_is_below", + "detach_paused_cards_at_message", + "release_card_message", + "resume_card_view", + "paint_card_on_carrier", + "restore_card", + "clear_card", + "_send_card", + "_send_card_locked", + "_edit_card", + "_edit_card_unlocked", + "_PHOTO_EDIT_MIN_INTERVAL", + "_edit_photo_card", + "_deferred_edit", + "update_session_card", + "_update_session_card_locked", + "finalize_task", + "_send_attachments", + "push_event", + "is_card_in_menu_view", + "is_card_finalized", + "is_card_busy", + "STALL_FINALIZE_AFTER_SECONDS", + "STALL_FINALIZE_TOOL_USE_SECONDS", + "maybe_finalize_stalled", + "is_active_for_user", + "repost_card", + "refresh_panel", + "CARD_TIMER_TICK_SECONDS", + "card_timer_loop", + "session_manager", + "safe_send", + "build_footer_keyboard", ] - - -# Per-(user, session.id) card state. -_cards: dict[tuple[int, str], CardState] = {} - -# Fire-and-forget receipt surfaces started by Telegram intake. Keeping them in -# one registry lets shutdown cancel cleanly instead of leaving Telegram edits -# alive after the application has started closing its HTTP client. -_card_surface_tasks: set[asyncio.Task[bool]] = set() - -# Per-(user, session.id) async lock. Acquired by every code path that -# may decide to ``_send_card`` (spawn a fresh card msg) so two -# concurrent paths can't both observe ``state.msg_id is None`` and -# both spawn — the artefact behind Task #50 ("2 messages in wrong -# order after switcher / new card"). Edit-only paths that never spawn -# (refresh_panel, card_timer_loop ticks, _deferred_edit) don't take -# the lock — at worst they race a spawn and either succeed against -# the freshly-spawned msg or hit lost-carrier and reset msg_id, which -# is recovered on the next event. -_card_locks: dict[tuple[int, str], asyncio.Lock] = {} - - -def _card_lock(user_id: int, session_id: str) -> asyncio.Lock: - """Get-or-create the spawn-serialization lock for one card.""" - key = (user_id, session_id) - lock = _card_locks.get(key) - if lock is None: - lock = asyncio.Lock() - _card_locks[key] = lock - return lock - - -# Per-user barrier for Telegram edits of the shared live-card carrier. -# -# A switch reuses the same Telegram message for another session. The old -# session may already have an editMessageText request in flight when the user -# taps the switcher; cancelling its deferred task is then too late. Without a -# barrier that old request can finish after the target session is painted and -# overwrite the carrier with the previous session's text. -# -# Every ``_edit_card`` holds this lock for the whole Telegram request. The -# switch hand-off acquires it before pausing the old owner and flipping the -# active-session pointer, so all older edits finish first and all newer old- -# session edits observe ``in_menu_view=True`` before they can reach Telegram. -_carrier_edit_locks: dict[int, asyncio.Lock] = {} - - -def _carrier_edit_lock(user_id: int) -> asyncio.Lock: - """Get-or-create the cross-session carrier-edit lock for one user.""" - lock = _carrier_edit_locks.get(user_id) - if lock is None: - lock = asyncio.Lock() - _carrier_edit_locks[user_id] = lock - return lock - - -# Per-user spawn lock. ``_send_card`` holds it across the whole -# "send the message → strip every other card's keyboard → record the new -# switcher carrier" sequence, so two *different sessions* spawning cards -# concurrently (a voice repost racing a typed-message repost) can't -# interleave those steps and leave the per-user ``last_switcher_msg_id`` -# pointing at the older message — the desync behind "I tap the switcher -# on the last message but the previous one gets edited". -# -# ``_card_lock`` alone doesn't cover this: it is keyed per (user, -# session), so two sessions never contend on it. Lock order is always -# session-lock → user-lock; no path acquires them the other way round. -_user_send_locks: dict[int, asyncio.Lock] = {} - - -def _user_send_lock(user_id: int) -> asyncio.Lock: - """Get-or-create the cross-session spawn-serialization lock for a user.""" - lock = _user_send_locks.get(user_id) - if lock is None: - lock = asyncio.Lock() - _user_send_locks[user_id] = lock - return lock - - -async def _strip_stale_switchers( - bot: Bot, user_id: int, keep_msg_id: int, keep_session_id: str | None -) -> None: - """Leave exactly ONE message in the chat carrying a live footer / - switcher keyboard: ``keep_msg_id``. - - Stripping only ``last_switcher_msg_id`` (the previous behaviour) is - not enough — that pointer is a single per-user slot, while every - session owns its own card message and ``_edit_card`` re-attaches a - keyboard on every edit without moving the pointer. Two live cards - could therefore end up tappable at once, and a switcher tap would - repaint whichever message the tap came from rather than the newest. - - So: strip the pointer's message *and* every other known card message - for this user. Cards in kb-mode are skipped — their keyboard is the - AskUserQuestion / ExitPlanMode navigation grid the user still has to - act on, not a stale switcher. - """ - targets: list[int] = [] - prev = session_manager.get_last_switcher_msg(user_id) - if prev and prev != keep_msg_id: - targets.append(prev) - for (uid, sid), st in _cards.items(): - if uid != user_id or sid == keep_session_id or st.in_kb_mode: - continue - if st.msg_id is not None and st.msg_id != keep_msg_id: - if st.msg_id not in targets: - targets.append(st.msg_id) - for msg_id in targets: - try: - await bot.edit_message_reply_markup( - chat_id=user_id, message_id=msg_id, reply_markup=None - ) - except Exception: - # Already stripped / deleted / not editable — nothing to do. - pass - - -# Reverse lookup so reply-quote can route a one-shot user message to the -# session that owns the message being replied to. Capped via FIFO eviction. -_MSG_REGISTRY_LIMIT = 2000 -_msg_to_session: dict[tuple[int, int], str] = {} - - -def _register_msg(user_id: int, message_id: int, session_id: str) -> None: - """Remember which session a bot message belongs to for reply-quote routing.""" - key = (user_id, message_id) - # Best-effort eviction: drop ~10% of the oldest entries when the cap - # is hit. dict preserves insertion order in CPython 3.7+. - if len(_msg_to_session) >= _MSG_REGISTRY_LIMIT and key not in _msg_to_session: - drop = max(1, _MSG_REGISTRY_LIMIT // 10) - for k in list(_msg_to_session.keys())[:drop]: - _msg_to_session.pop(k, None) - _msg_to_session[key] = session_id - - -def lookup_session_for_message(user_id: int, message_id: int) -> str | None: - """Resolve a Telegram message id back to the Session.id it represents.""" - return _msg_to_session.get((user_id, message_id)) - - -def reset_card_msg_id_for_user(user_id: int) -> None: - """Drop the msg_id for every card of ``user_id`` so the next event - creates a fresh msg of the (possibly changed) correct type. - - Called when the user toggles ``card_inline_screenshots`` — the new - msg type (photo+caption vs text) cannot be reached via editMessage* - on the old msg, so we orphan the old artefact and spawn a new one. - """ - for (uid, _sid), state in _cards.items(): - if uid != user_id: - continue - state.msg_id = None - state.is_photo_msg = False - state.last_rendered = "" - state.last_pane_hash = "" - state.last_photo_edit_ts = 0.0 - - -def _inline_screens_enabled(user_id: int | None) -> bool: - """Read the ``card_inline_screenshots`` user-setting (default False).""" - if user_id is None: - return False - settings = session_manager.get_user_settings(user_id) - return bool(settings.get("card_inline_screenshots", False)) - - -def has_pending_kb(user_id: int, session_id: str) -> tuple[bool, bool]: - """Return (has_prompt, in_kb_mode) for the (user, session) card. - - Public alternative to peeking at ``_cards``. ``has_prompt=True`` means - a prompt is pending; ``in_kb_mode`` reflects whether the card msg is - currently displaying kb-mode view vs the regular card. - """ - state = _cards.get((user_id, session_id)) - if state is None: - return False, False - return bool(state.kb_prompt), state.in_kb_mode - - -async def enter_kb_mode( - bot: Bot, - user_id: int, - sess: Session, - prompt_content: str, - ui_name: str, -) -> None: - """Flip the active session's card msg into kb-mode view. - - Edits the existing card msg (or creates one if missing) so its body - shows the prompt content and its keyboard is the kb-mode 3×3 grid + - [Back][+ new][≡ Menu]. State is marked ``in_kb_mode=True`` and - ``kb_prompt`` snapshot so subsequent paints stay consistent. - - No-op if state is already in kb-mode with the same prompt — avoids - pointless edits when status_polling re-detects the prompt each poll. - """ - state = get_card_state(user_id, sess) - # Short-circuit ONLY when the kb-mode card is actually present in - # chat. After ``close_card_view`` (Shot tap) ``msg_id`` is None but - # ``in_kb_mode`` stays True — without the ``msg_id is not None`` - # check, subsequent status_polling re-detections of the same UI - # would no-op and the kb-mode card would never be re-spawned. - if ( - state.in_kb_mode - and state.kb_prompt == prompt_content - and state.msg_id is not None - ): - return - state.kb_prompt = prompt_content - state.kb_ui_name = ui_name - state.in_kb_mode = True - # kb-mode is an interrupt: claude is BLOCKED waiting for the user's - # answer. If the user happens to be on Menu / List / Settings / - # History on the same carrier (``in_menu_view=True``), ``_edit_card`` - # would short-circuit and the kb keyboard would never surface — the - # user only saw it appear after tapping Shot, which dropped - # ``msg_id=None`` and re-spawned a fresh card via ``_send_card``. - # Clearing the flag here lets ``_edit_card`` repaint the carrier - # with the kb prompt; the menu navigation is preempted because the - # session can't proceed without the user's input anyway. - state.in_menu_view = False - if not sess.window_id: - return - text = _render_card(sess, state, user_id=user_id) - kb = build_kb_mode_keyboard(user_id, sess.window_id, ui_name=ui_name) - # Spawn-serialization (Task #50): a parallel ``update_session_card`` - # could otherwise observe ``msg_id is None`` during ``_send_card`` - # and spawn its own card too. - async with _card_lock(user_id, sess.id): - if state.msg_id is None: - await _send_card(bot, user_id, sess, state, text=text, reply_markup=kb) - else: - await _edit_card(bot, user_id, state, text=text, reply_markup=kb) - state.last_rendered = text - state.last_edit_ts = time.monotonic() - logger.info( - "kb_mode entered user=%d sess=%s ui=%s prompt_len=%d", - user_id, - sess.id, - ui_name, - len(prompt_content), - extra={ - "event": "kb_mode_entered", - "user_id": user_id, - "session_id": sess.id, - "ui_name": ui_name, - "prompt_len": len(prompt_content), - }, - ) - - -async def exit_kb_mode( - bot: Bot, - user_id: int, - sess: Session, - *, - clear_pending: bool = False, -) -> None: - """Flip the card back from kb-mode to regular view. - - ``clear_pending=False`` (default) — user tapped Back. ``kb_prompt`` - is KEPT so the Resume button shows up in the footer. Tapping Resume - re-enters kb-mode with the same prompt. - - ``clear_pending=True`` — claude moved past the prompt (terminal_parser - no longer detects it, after double-poll confirm) OR user explicitly - acted via a kb key. Wipe both ``in_kb_mode`` and ``kb_prompt`` so - the Resume button disappears. - """ - state = _cards.get((user_id, sess.id)) - if state is None: - return - was_in_kb = state.in_kb_mode - state.in_kb_mode = False - if clear_pending: - state.kb_prompt = "" - state.kb_ui_name = "" - if state.msg_id is None or not was_in_kb: - return - text = _render_card(sess, state, user_id=user_id) - if await _edit_card(bot, user_id, state, text=text): - state.last_rendered = text - state.last_edit_ts = time.monotonic() - logger.info( - "kb_mode exited user=%d sess=%s cleared=%s", - user_id, - sess.id, - clear_pending, - extra={ - "event": "kb_mode_exited", - "user_id": user_id, - "session_id": sess.id, - "clear_pending": clear_pending, - }, - ) - - -def get_card_state(user_id: int, sess: Session) -> CardState: - return _cards.setdefault((user_id, sess.id), CardState()) - - -async def _seed_events_from_jsonl( - sess: Session, max_turns: int = CARD_SEED_TURNS -) -> list[Event]: - """Build a list[Event] from the session's JSONL transcript. - - Pulls the last ``max_turns`` end-of-turn boundaries so the card has - visible history after a bot restart (when in-memory ``state.events`` - is empty). Returns ``[]`` on any failure — caller just continues - with an empty card. - - ``max_turns`` defaults to the module constant but is overridden by - ``_ensure_seeded`` from the user's ``card_history`` setting. - """ - if not sess.window_id: - return [] - # Derive the transcript path by pure path math instead of - # ``resolve_session_for_window`` — the latter fully walks the JSONL - # just to refresh summary/token stats we don't use here, then we read - # the file again below. On a multi-MB resumed transcript that wasted - # walk costs >1s. Same fast-path the /history cache already uses. - state = session_manager.get_window_state(sess.window_id) - if not state.session_id or not state.cwd: - return [] - if state.transcript_path: - fp = Path(state.transcript_path) - elif sess.backend == "codex": - from ..codex_session_io import build_session_file_path - - fp = build_session_file_path(state.session_id, state.cwd) - else: - from ..session_claude_io import build_session_file_path - - fp = build_session_file_path(state.session_id, state.cwd) - if fp is None or not fp.exists(): - return [] - file_path = str(fp) - import json as _json - from pathlib import Path as _Path - - from ..transcript_parser import TranscriptParser - - try: - raw = _Path(file_path).read_text(encoding="utf-8", errors="replace") - except OSError as e: - logger.debug("seed: read JSONL %s failed: %s", file_path, e) - return [] - raw_entries: list[dict[str, object]] = [] - for line in raw.splitlines(): - if not line.strip(): - continue - try: - raw_entries.append(_json.loads(line)) - except Exception: - continue - try: - parsed_list, _ = TranscriptParser.parse_entries(raw_entries, pending_tools=None) - except Exception as e: - logger.debug("seed: parse_entries failed: %s", e) - return [] - - # Walk backwards collecting indices of end_turn boundaries (final - # assistant text). Keep only entries from the last CARD_SEED_TURNS - # boundaries — earlier history stays in JSONL for /screenshot or - # other history paths. - end_turn_idxs: list[int] = [] - for i in range(len(parsed_list) - 1, -1, -1): - p = parsed_list[i] - if ( - getattr(p, "role", "") == "assistant" - and getattr(p, "content_type", "") == "text" - and getattr(p, "stop_reason", "") - in ("end_turn", "stop_sequence", "max_tokens") - ): - end_turn_idxs.append(i) - if len(end_turn_idxs) >= max_turns: - break - if end_turn_idxs: - start_idx = end_turn_idxs[-1] - # Pull a few entries back from start_idx so the user message that - # triggered the oldest kept turn is visible at the top. - start_idx = max(0, start_idx - 4) - else: - start_idx = max(0, len(parsed_list) - 80) - tail = parsed_list[start_idx:] - - # Convert ParsedEntry → NewMessage → Event. tool_results fold into - # matching tool_use via _apply_tool_result; on miss they append. - pseudo_state = CardState() - events = pseudo_state.events - for p in tail: - ct = getattr(p, "content_type", "text") - msg = NewMessage( - session_id="seed", - text=getattr(p, "text", "") or "", - is_complete=True, - content_type=ct, - tool_use_id=getattr(p, "tool_use_id", None), - role=getattr(p, "role", "assistant"), - tool_name=getattr(p, "tool_name", None), - image_data=getattr(p, "image_data", None), - stop_reason=getattr(p, "stop_reason", None), - timestamp=getattr(p, "timestamp", "") or "", - ) - ev = _build_event(msg) - if ct == "tool_result" and _apply_tool_result(pseudo_state, ev): - continue - events.append(ev) - return events - - -def _transcript_mtime(sess: Session) -> float: - """Return the mtime (epoch seconds) of the session's JSONL transcript, - or -1.0 if the path can't be resolved / the file is missing. - - Cheap (single ``stat``) — used by ``_ensure_seeded`` to gate empty-seed - retries on a restored session without re-parsing the whole transcript. - """ - if not sess.window_id: - return -1.0 - state = session_manager.get_window_state(sess.window_id) - if not state.session_id or not state.cwd: - return -1.0 - if state.transcript_path: - fp = Path(state.transcript_path) - elif sess.backend == "codex": - from ..codex_session_io import build_session_file_path - - fp = build_session_file_path(state.session_id, state.cwd) - else: - from ..session_claude_io import build_session_file_path - - fp = build_session_file_path(state.session_id, state.cwd) - if fp is None: - return -1.0 - try: - return fp.stat().st_mtime - except OSError: - return -1.0 - - -async def _ensure_seeded(user_id: int, sess: Session, state: CardState) -> None: - """Seed ``state.events`` from JSONL on first access after restart. - - No-op when events already exist. Latches ``seed_attempted`` only on a - *successful* (non-empty) seed: a freshly restored (``claude --resume``) - session builds its card before claude has flushed the resumed transcript - to disk, so an early read returns [] — latching then would block the - seed forever and the history would never reach the card. An empty read - instead leaves the flag clear and retries on a later event, gated on the - transcript mtime advancing (``state.seed_mtime``) so a burst of events - during the resume window doesn't re-parse a multi-MB JSONL each time. A - wipe site that wants a re-seed clears ``seed_attempted`` + ``seed_mtime`` - (see ``CardState.seed_attempted``). - """ - if state.events: - return - if state.seed_attempted: - return - mtime = _transcript_mtime(sess) - if mtime >= 0.0 and mtime == state.seed_mtime: - # Nothing new on disk since the last empty attempt — skip the - # re-parse and wait for the transcript to grow. - return - state.seed_mtime = mtime - # User-settable depth — Settings → Card history (10/20/50/100). - try: - max_turns = int( - session_manager.get_user_settings(user_id).get( - "card_history", CARD_SEED_TURNS - ) - ) - except (TypeError, ValueError): - max_turns = CARD_SEED_TURNS - seeded = await _seed_events_from_jsonl(sess, max_turns=max_turns) - if seeded: - state.events = seeded - state.seed_attempted = True - logger.info( - "card_seeded user=%d sess=%s events=%d", - user_id, - sess.id, - len(seeded), - extra={ - "event": "card_seeded", - "user_id": user_id, - "session_id": sess.id, - "events": len(seeded), - }, - ) - - -def _should_buffer(user_id: int, session_id: str, state: CardState) -> bool: - """Return True when the live card must buffer events instead of - rendering. Four reasons: - - 1. The user has the carrier on a Menu / sub-screen - (``state.in_menu_view`` — set by ``pause_card_view`` / - ``transfer_card_to_carrier``, cleared by ``resume_card_view`` / - ``release_card_message`` / ``detach_paused_cards_at_message``). - 2. The session is currently a background one for this user - (``get_active_session(user_id).id != session_id``). Computed - live, NOT stored — a session that's briefly bg and then active - again recovers without help. (Earlier this was implemented as a - sticky ``state.in_menu_view = True`` inside update_session_card; - the flag never got cleared on becoming active again, so the card - stayed paused forever — silent until the next typed message - woke ``resume_card_view``. This helper makes the bg check live - so that class of bug can't reoccur.) - 3. ``text_handler`` has signalled an imminent ``repost_card`` for - this (user, session) via ``begin_repost_intent``. Without the - buffer, claude's first reply event after the user's typed text - races against the repost and both ``update_session_card`` and - ``repost_card`` end up calling ``_send_card`` — two cards land - in chat (or one survives + claude's first event is lost when - ``delete_message`` succeeds on a card that already had content). - Buffering defers the rendering until ``end_repost_intent`` - cleared the flag; events accumulate in ``state.events`` and - drain into the freshly-reposted card on the next render. - 4. The card is in kb-mode (``state.in_kb_mode``). Without this, - a stray streaming event (assistant text emitted right before the - AskUserQuestion lands, e.g.) would trigger ``_edit_card`` with - the default footer keyboard — overwriting the kb keyboard the - user needs to act on. Buffer until ``exit_kb_mode`` clears the - flag; the drained events land on the next post-prompt render. - """ - if state.in_menu_view: - return True - if state.in_kb_mode: - return True - if (user_id, session_id) in _repost_intent: - return True - active = session_manager.get_active_session(user_id) - return active is None or active.id != session_id - - -# (user_id, session_id) pairs for which ``text_handler`` is mid-dispatch -# and will call ``repost_card`` shortly. While the pair is in this set, -# ``update_session_card`` buffers events instead of spawning a fresh -# card — see ``_should_buffer`` reason 3. Populated/cleared by -# ``begin_repost_intent`` / ``end_repost_intent``. -_repost_intent: set[tuple[int, str]] = set() - - -def begin_repost_intent(user_id: int, session_id: str) -> None: - """Mark (user, session) as repost-in-progress so concurrent - claude events buffer instead of spawning their own card. - - Idempotent: re-marking a still-set pair is a no-op. Call - ``end_repost_intent`` AFTER ``repost_card`` (success or failure) - so the buffer drains. The buffer is the spawn-race fix's safety - net — even if ``repost_card`` itself fails, ``end_repost_intent`` - lets normal rendering resume on the next event. - """ - _repost_intent.add((user_id, session_id)) - - -def end_repost_intent(user_id: int, session_id: str) -> None: - """Clear the repost-in-progress flag set by ``begin_repost_intent``. - - Safe to call when no flag is set. - """ - _repost_intent.discard((user_id, session_id)) - - -def reset_card(user_id: int, session_id: str) -> None: - """Drop the cached card so the next event creates a fresh message.""" - _cards.pop((user_id, session_id), None) - - -def _recover_from_false_stall(state: CardState) -> None: - """Wipe the live-card binding after a false-positive stall_finalize. - - Set when a genuine assistant turn lands AFTER - ``maybe_finalize_stalled`` armed ``state.stall_finalized``. Clears - msg_id / events / pagination so the next render path goes through - ``_send_card`` (fresh message below the stalled stub) rather than - ``_edit_card`` (silent edit of the now-finalized card). The stalled - stub stays in chat history with its STALL_NOTE — we don't rewrite - it; the recovery message appears as a fresh card with - ``is_continuation=True`` so the header carries the ``…continued`` - marker. - """ - if state.pending_edit is not None and not state.pending_edit.done(): - state.pending_edit.cancel() - state.pending_edit = None - state.msg_id = None - state.events = [] - state.current_page_idx = None - state.is_continuation = True - state.last_rendered = "" - state.seed_attempted = False - state.seed_mtime = -1.0 - state.stall_finalized = False - - -async def cancel_pending_card_edits(timeout: float = 2.0) -> None: - """Cancel + drain every deferred ``_edit_card`` task across all cards. - - Called from ``post_shutdown`` so we don't leave ``_deferred_edit`` - tasks in the "pending" state when the event loop closes — asyncio - logs ``Task was destroyed but it is pending!`` for each one, and - any in-flight Telegram edit can race with the final state save. - """ - tasks: list[asyncio.Task[None]] = [] - for state in _cards.values(): - t = state.pending_edit - if t is not None and not t.done(): - t.cancel() - tasks.append(t) - state.pending_edit = None - if not tasks: - return - try: - await asyncio.wait_for( - asyncio.gather(*tasks, return_exceptions=True), timeout=timeout - ) - except asyncio.TimeoutError: - logger.warning( - "card-edit shutdown drain timed out after %ss with %d tasks pending", - timeout, - sum(1 for t in tasks if not t.done()), - ) - - -async def close_card_view(bot: Bot, user_id: int, session_id: str) -> None: - """Release the live card slot so the next event creates a fresh - message instead of editing the old carrier. - - Used by the Shot button (Task #51): the screenshot photo replaces - the live card visually, and when the user comes back from the - screenshot we want a NEW card message to appear (replacement of - one message by another), not an in-place edit of a now-stale - carrier far up the chat. - - Steps: - - Cancel any pending edit on the old carrier. - - **Delete** the old carrier message so the chat reads as a - clean replacement (per #52 follow-up — stripping the keyboard - was confusing, the orphaned message read like a frozen card). - - Drop ``msg_id`` so the next claude event / Shot Back spawns a - fresh card. - - Leave ``in_menu_view=True`` so events buffer until the user - actually navigates back (the Shot Back handler clears it). - """ - state = _cards.get((user_id, session_id)) - if state is None: - return - if state.pending_edit is not None and not state.pending_edit.done(): - state.pending_edit.cancel() - state.pending_edit = None - old_msg_id = state.msg_id - state.msg_id = None - state.is_photo_msg = False - state.last_rendered = "" - state.last_pane_hash = "" - state.last_photo_edit_ts = 0.0 - state.in_menu_view = True - if old_msg_id is not None: - try: - await bot.delete_message(chat_id=user_id, message_id=old_msg_id) - except Exception as e: - logger.debug( - "close_card_view: delete old msg failed msg_id=%s: %s", - old_msg_id, - e, - ) - logger.info( - "card_close user=%d sess=%s old_msg_id=%s", - user_id, - session_id, - old_msg_id, - extra={ - "event": "card_close", - "user_id": user_id, - "session_id": session_id, - "old_msg_id": old_msg_id, - }, - ) - - -def set_card_context_pct(user_id: int, session_id: str, pct: int) -> None: - """Stash the latest context-window fill percentage for this session's - live card. Read by ``_render_card`` to paint a ``context: N%`` line - above the bg-status panel. No-op when no state exists yet. - """ - state = _cards.setdefault((user_id, session_id), CardState()) - state.context_pct = pct - - -def mark_card_paused(user_id: int, session_id: str) -> None: - """Force a card to ``in_menu_view=True``, creating empty state if - none exists. Differs from :func:`pause_card_view` which silently - no-ops on a missing state — needed for the Shot → switcher path - where the user pivots onto a session whose card was never seeded. - """ - _cards.setdefault((user_id, session_id), CardState()).in_menu_view = True - - -def pause_card_view(user_id: int, session_id: str) -> None: - """Mark the live card paused so session updates buffer instead of - rendering. Called when the user opens a Menu / sub-screen on the - card's message — otherwise a stream of tool calls would overwrite - whatever they're looking at.""" - state = _cards.get((user_id, session_id)) - if state is None: - logger.info( - "card_pause skip user=%d sess=%s reason=no_state", - user_id, - session_id, - extra={ - "event": "card_pause_skip", - "user_id": user_id, - "session_id": session_id, - "reason": "no_state", - }, - ) - return - state.in_menu_view = True - logger.info( - "card_pause user=%d sess=%s msg_id=%s lines=%d", - user_id, - session_id, - state.msg_id, - len(state.events), - extra={ - "event": "card_pause", - "user_id": user_id, - "session_id": session_id, - "msg_id": state.msg_id, - "lines": len(state.events), - }, - ) - - -def transfer_card_to_carrier( - user_id: int, - from_session_id: str | None, - to_session_id: str, - target_message_id: int, -) -> int | None: - """Hand off ownership of ``target_message_id`` from one session's - live card to another's. Called when the switcher flips active. - - Returns the message id of the TO session's *previous* card when it - was a different message — that message is now orphaned (nothing will - ever edit it again) and the caller must strip its keyboard, or the - chat ends up with two tappable switchers. Returns None when there is - nothing to clean up. - - Effect: - - FROM session is paused (``in_menu_view=True``) so its events - buffer silently in ``state.events`` instead of editing the - carrier (which now belongs to the TO session). No new chat - message lands until the user switches back or types text. - - TO session claims the carrier (``msg_id=target_message_id``) - and its pause is released, so the next event for it renders - on the carrier — overlaying the preview that the callback - just painted. - - No-op when ``from_session_id == to_session_id`` (user tapped the - already-active session). The previous live-card behaviour — where - A's lingering ``msg_id`` clobbered B's preview every time A emitted - a tool call — falls out naturally because A is now paused. - """ - if from_session_id == to_session_id: - logger.info( - "card_transfer skip user=%d sess=%s reason=same_session", - user_id, - to_session_id, - extra={ - "event": "card_transfer_skip", - "user_id": user_id, - "session_id": to_session_id, - "reason": "same_session", - }, - ) - return None - from_msg_id_was: int | None = None - if from_session_id: - from_state = _cards.get((user_id, from_session_id)) - if from_state is not None: - from_msg_id_was = from_state.msg_id - if ( - from_state.pending_edit is not None - and not from_state.pending_edit.done() - ): - from_state.pending_edit.cancel() - from_state.pending_edit = None - from_state.in_menu_view = True - to_state = _cards.setdefault((user_id, to_session_id), CardState()) - to_msg_id_was = to_state.msg_id - if to_state.pending_edit is not None and not to_state.pending_edit.done(): - to_state.pending_edit.cancel() - to_state.pending_edit = None - to_state.msg_id = target_message_id - session_manager.set_card_msg(user_id, target_message_id) - # Pause the TO card across the switch window. The caller (CB_SW_USE) - # will paint history on this message_id next, and then call - # ``release_card_message`` which clears both ``msg_id`` and - # ``in_menu_view``. If we left ``in_menu_view=False`` here, any bg - # event arriving in the ~150 ms parse + edit window would trigger - # ``refresh_panel`` — that path sees - # ``msg_id=carrier`` + ``in_menu_view=False`` and rerenders the - # live-card body over the carrier, clobbering the history paint - # we're racing to land. Symptom: user sees "header + bg panel" - # instead of transcript after a switch. - to_state.in_menu_view = True - logger.info( - "card_transfer user=%d from=%s (from_msg=%s) to=%s (was_msg=%s) carrier=%s", - user_id, - from_session_id or "-", - from_msg_id_was, - to_session_id, - to_msg_id_was, - target_message_id, - extra={ - "event": "card_transfer", - "user_id": user_id, - "from_session_id": from_session_id, - "from_msg_id_was": from_msg_id_was, - "to_session_id": to_session_id, - "to_msg_id_was": to_msg_id_was, - "carrier_msg_id": target_message_id, - }, - ) - if to_msg_id_was is not None and to_msg_id_was != target_message_id: - return to_msg_id_was - return None - - -async def activate_card_on_carrier( - user_id: int, - from_session_id: str | None, - to_session_id: str, - target_message_id: int, -) -> int | None: - """Atomically hand the live carrier to a newly-active session. - - The carrier-edit lock is a barrier against an edit from the previous - active session that is already in flight. Once the barrier opens, pause - the old card, claim the carrier for the target, and flip ``active_sessions`` - before another card edit can start. The caller paints the target after - this returns; any queued old-session edit then sees the paused state and - becomes a no-op. - - Returns the target session's orphaned previous card message id, matching - :func:`transfer_card_to_carrier`. - """ - async with _carrier_edit_lock(user_id): - orphan_msg_id = transfer_card_to_carrier( - user_id, - from_session_id, - to_session_id, - target_message_id, - ) - session_manager.set_active_session(user_id, to_session_id) - return orphan_msg_id - - -def card_is_below(user_id: int, session_id: str, message_id: int) -> bool: - """True when the session's live card already sits *below* - ``message_id`` in the chat. - - Telegram message ids are monotonically increasing per chat, so a - card whose ``msg_id`` is greater than the user's message was posted - after it and is already "in front" — a repost would only churn. - Used by the voice flow: the card is reposted at voice-receipt, so - when whisper returns 30 s later there is nothing to move, just the - 🎙 marker to drop with an in-place edit. - """ - state = _cards.get((user_id, session_id)) - return state is not None and state.msg_id is not None and state.msg_id > message_id - - -def detach_paused_cards_at_message(user_id: int, message_id: int) -> None: - """Release card state bound to ``message_id`` when the carrier has - been repurposed for a different flow. - - The pause→resume design assumes the user eventually returns to the - live card via ``resume_card_view`` (typing text, etc.). But when - the carrier message gets hijacked for a different session — e.g. - user navigates ``Menu → + new`` and confirms a directory, the new - session's "Created" status now owns the message — the OLD session's - pause never gets released and its events buffer forever. Worse, - ``state.msg_id`` still points at a message that's no longer its - card, so a later edit would clobber whatever's there. - - This helper resets ``msg_id`` (so the next event opens a fresh - card) and clears the pause flags for every card on this user that - happened to be paused on the now-stolen message. - """ - detached: list[str] = [] - for (uid, sid), state in list(_cards.items()): - if uid != user_id or state.msg_id != message_id: - continue - if state.pending_edit is not None and not state.pending_edit.done(): - state.pending_edit.cancel() - state.pending_edit = None - state.msg_id = None - state.in_menu_view = False - # Mark continuation so the next card visually flags carry-over - # (``…continued`` in the header). - state.is_continuation = True - detached.append(sid) - if detached: - logger.info( - "card_detach user=%d msg=%s sessions=%s", - user_id, - message_id, - detached, - extra={ - "event": "card_detach", - "user_id": user_id, - "msg_id": message_id, - "sessions": detached, - }, - ) - - -def release_card_message(user_id: int, session_id: str) -> None: - """Drop the live-card binding to its current Telegram message_id - without touching the message itself. - - Called from the switcher-tap handler right after history is painted - on the carrier: the carrier now holds a frozen transcript view, and - the TO session's live card must NOT keep editing it. With ``msg_id`` - cleared, the next claude event opens a fresh card below (carrying - the bg-status panel and prior-context seed); the history carrier - stays put and remains paginable. - - Buffered ``lines`` are also wiped — they were destined for the - overwritten card; the fresh card starts empty on its next event. - """ - state = _cards.get((user_id, session_id)) - if state is None: - return - if state.pending_edit is not None and not state.pending_edit.done(): - state.pending_edit.cancel() - state.pending_edit = None - state.msg_id = None - state.in_menu_view = False - state.events = [] - state.last_rendered = "" - state.is_continuation = True - # A6: this is a non-destructive carrier hand-off — the session keeps - # running with a full transcript. Allow the next event's fresh card - # to re-seed so its footer page counter reflects the real recent - # turn-history instead of collapsing to ``1/1``. - state.seed_attempted = False - state.seed_mtime = -1.0 - logger.info( - "card_release user=%d sess=%s", - user_id, - session_id, - extra={ - "event": "card_release", - "user_id": user_id, - "session_id": session_id, - }, - ) - - -async def resume_card_view(bot: Bot, user_id: int, sess: Session) -> None: - """Drop the menu-pause so future events render again, and re-paint - the carrier with the buffered events. - - For the currently-active session, clears ``in_menu_view`` even when - ``msg_id`` was lost (carrier stale / deleted / not yet created). Earlier - this returned early without clearing the pause, leaving the active card - stuck in ``must_buffer=True`` forever. A background session is the one - exception: its pause and carrier binding must remain untouched so a late - voice dispatch cannot reclaim the newly-active session's carrier. - """ - # ``setdefault`` so a session with no card-state yet (just-switched - # bg session via Shot's switcher) still lands on a visible surface. - # Without this, resume_card_view silently bailed and Back left the - # user staring at empty chat. - state = _cards.setdefault((user_id, sess.id), CardState()) - - async def _spawn_fresh() -> None: - await _ensure_seeded(user_id, sess, state) - fresh_text = _render_card(sess, state, user_id=user_id) - fresh_kb = build_footer_keyboard(user_id, screen="main", is_busy=True) - await _send_card( - bot, user_id, sess, state, text=fresh_text, reply_markup=fresh_kb - ) - - # Spawn-serialization (Task #50): hold the per-session lock across - # the msg_id check + send/edit. Otherwise a claude event arriving - # during ``_ensure_seeded`` / ``_send_card`` can race and produce a - # duplicate card via ``update_session_card``. - async with _card_lock(user_id, sess.id): - # The active-session check and the Telegram edit share the same - # cross-session barrier as switcher hand-off. A slow voice dispatch - # may have started while this session was active and resumed after the - # carrier moved elsewhere; it must not clear the old card's pause or - # repaint the new owner's carrier. - async with _carrier_edit_lock(user_id): - if not is_active_for_user(user_id, sess): - logger.info( - "card_resume skip user=%d sess=%s reason=background", - user_id, - sess.id, - ) - return - state.in_menu_view = False - if state.pending_edit is not None and not state.pending_edit.done(): - state.pending_edit.cancel() - state.pending_edit = None - if state.msg_id is None: - # No carrier — spawn a fresh card now so the user lands on a - # visible surface immediately (used by Shot → Back after #51's - # ``close_card_view`` drops msg_id). Previously we waited for - # the next claude event; on quiet sessions that left the user - # staring at empty chat. - await _spawn_fresh() - return - text = _render_card(sess, state, user_id=user_id) - keyboard = build_footer_keyboard(user_id, screen="main", is_busy=True) - if await _edit_card_unlocked( - bot, user_id, state, text=text, reply_markup=keyboard - ): - state.last_rendered = text - state.last_edit_ts = time.monotonic() - return - # ``_edit_card_unlocked`` returned False — the carrier was lost - # (stale msg, already-deleted, or bot can't edit it) and already - # reset msg_id internally. Spawn a fresh card so the user still - # lands on a visible live surface. - await _spawn_fresh() - - -async def paint_card_on_carrier( - bot: Bot, - user_id: int, - sess: Session, - carrier_msg_id: int, -) -> None: - """Claim ``carrier_msg_id`` as ``sess``'s live card and paint it. - - Used by Menu → Sessions: the carrier is the menu message the user just - tapped, and we want it to become the live card (one unified surface - instead of a separate list rendering). The previous ``state.msg_id`` - is left as a frozen artifact in chat — the next claude event uses - the new carrier. - """ - state = _cards.setdefault((user_id, sess.id), CardState()) - # Menu → Sessions on a fresh post-restart state: seed history first - # so the user lands on a card with their conversation, not 1/1. - await _ensure_seeded(user_id, sess, state) - if state.pending_edit is not None and not state.pending_edit.done(): - state.pending_edit.cancel() - state.pending_edit = None - state.msg_id = carrier_msg_id - state.in_menu_view = False - state.last_rendered = "" - _register_msg(user_id, carrier_msg_id, sess.id) - session_manager.set_card_msg(user_id, carrier_msg_id) - text = _render_card(sess, state, user_id=user_id) - keyboard = build_footer_keyboard( - user_id, screen="main", is_busy=_card_is_busy(state) - ) - if await _edit_card(bot, user_id, state, text=text, reply_markup=keyboard): - state.last_rendered = text - state.last_edit_ts = time.monotonic() - # Migrate the switcher pointer onto the new carrier so previous - # switcher rows in chat stop being the canonical surface. - await _strip_stale_switchers(bot, user_id, carrier_msg_id, sess.id) - session_manager.set_last_switcher_msg(user_id, carrier_msg_id) - - -async def restore_card(bot: Bot, user_id: int, sess: Session, card_msg_id: int) -> bool: - """Repaint a persisted live card in place after a bot restart. - - ``_cards`` is in-memory only, so a restart loses every live card's - ``CardState`` and the chat is left with a frozen, orphaned card - message. The card's ``message_id`` is persisted per active session - (``session_manager.card_msg_id``); on startup we rebuild a fresh - ``CardState``, seed the recent transcript from JSONL, and edit the - original message in place so the live card resumes on the same - message instead of a new one appearing on the next event. - - Returns True if the in-place edit landed. On failure (message - deleted by the user, edit rejected) the stale pointer is cleared so - the next claude event spawns a fresh card normally. - """ - existing = _cards.get((user_id, sess.id)) - if existing is not None and existing.msg_id is not None: - # A claude event already raced ahead and established a live card - # for this session — leave it alone rather than fight it. - return True - state = _cards.setdefault((user_id, sess.id), CardState()) - state.msg_id = card_msg_id - state.last_rendered = "" - await _ensure_seeded(user_id, sess, state) - _register_msg(user_id, card_msg_id, sess.id) - text = _render_card(sess, state, user_id=user_id) - keyboard = build_footer_keyboard( - user_id, screen="main", is_busy=_card_is_busy(state) - ) - if await _edit_card(bot, user_id, state, text=text, reply_markup=keyboard): - state.last_rendered = text - state.last_edit_ts = time.monotonic() - return True - # The message is gone — drop both the cached state and the persisted - # pointer so the next event creates a fresh card cleanly. - _cards.pop((user_id, sess.id), None) - session_manager.clear_card_msg(user_id) - return False - - -async def clear_card(bot: Bot, user_id: int, sess: Session) -> None: - """Wipe the live card's body in response to a user-driven /clear. - - Edits the existing message to a header-only "(cleared)" snapshot - and keeps an empty, seed-latched state. Dropping the state here would let - ``resume_card_view`` immediately re-seed the old JSONL transcript and make - the cleared Telegram history reappear. - No-op when there is no live card. - """ - state = _cards.get((user_id, sess.id)) - if state is None or state.msg_id is None: - reset_card(user_id, sess.id) - return - if state.pending_edit is not None and not state.pending_edit.done(): - state.pending_edit.cancel() - state.pending_edit = None - state.events = [] - state.current_page_idx = None - state.context_pct = 0 - state.is_continuation = False - state.in_menu_view = False - state.kb_prompt = "" - state.kb_ui_name = "" - state.in_kb_mode = False - state.seed_attempted = True - state.seed_mtime = -1.0 - state.stall_finalized = False - state.last_rendered = "" - text = _render_card(sess, state, footer="(cleared)", user_id=user_id) - cleared_kb = build_footer_keyboard(user_id, screen="main", is_busy=False) - await _edit_card(bot, user_id, state, text=text, reply_markup=cleared_kb) - - -async def _send_card( - bot: Bot, - user_id: int, - sess: Session, - state: CardState, - *, - text: str, - reply_markup: InlineKeyboardMarkup | None = None, -) -> None: - """Send a brand-new card message and remember it as the live card. - - Serialized per user (``_user_send_lock``) so concurrent spawns from - two different sessions can't interleave the send / strip / pointer - update and desync which message carries the live switcher. - """ - async with _user_send_lock(user_id): - await _send_card_locked( - bot, user_id, sess, state, text=text, reply_markup=reply_markup - ) - - -async def _send_card_locked( - bot: Bot, - user_id: int, - sess: Session, - state: CardState, - *, - text: str, - reply_markup: InlineKeyboardMarkup | None = None, -) -> None: - """Body of :func:`_send_card`; call only with the user send-lock held. - - ``reply_markup`` overrides the default footer keyboard. Used by - ``finalize_task`` to attach the idle-state Kill row to a completed - result instead of the busy-state Stop row. - """ - if reply_markup is None: - # Default: a fresh card is being sent because a turn is in - # flight (update_session_card, repost_card, continuation - # overflow). ``_card_is_busy`` keys off ``state.msg_id`` which - # is still None at this point — the right signal here is - # "we're sending a card", which by definition means Stop is - # the user's intent. ``finalize_task`` overrides ``reply_markup`` - # explicitly with the Kill keyboard when a turn completes. - reply_markup = build_footer_keyboard(user_id, screen="main", is_busy=True) - keyboard = reply_markup - - # Inline screenshots ON + we have a window_id: send photo+caption. - sent = None - if _inline_screens_enabled(user_id) and sess.window_id: - from ..markdown_v2 import convert_markdown - from .message_sender import PARSE_MODE, strip_sentinels - - png, pane_hash = await _capture_pane_png(sess.window_id) - if png is not None: - import io as _io - - caption = convert_markdown(text) - try: - sent = await bot.send_photo( - chat_id=user_id, - photo=_io.BytesIO(png), - caption=caption, - parse_mode=PARSE_MODE, - reply_markup=keyboard, - disable_notification=True, - ) - except RetryAfter: - raise - except Exception as e: - logger.debug("photo send failed, retry plain caption: %s", e) - try: - sent = await bot.send_photo( - chat_id=user_id, - photo=_io.BytesIO(png), - caption=strip_sentinels(text), - reply_markup=keyboard, - disable_notification=True, - ) - except Exception as e2: - logger.debug("photo send plain fallback failed: %s", e2) - if sent is not None: - state.is_photo_msg = True - state.last_pane_hash = pane_hash - state.last_photo_edit_ts = time.monotonic() - - # Text-mode card OR photo path failed → text fallback. - if sent is None: - from .message_sender import send_with_fallback - - try: - sent = await send_with_fallback( - bot, - user_id, - text, - reply_markup=keyboard, - disable_notification=True, - ) - except RetryAfter: - raise - except Exception as e: - logger.debug("card send failed: %s", e) - return - if sent is None: - return - state.is_photo_msg = False - # Exactly one message in the chat may carry a live switcher, and it is - # this one — strip every other card's keyboard, not just the pointer's. - state.msg_id = sent.message_id - await _strip_stale_switchers(bot, user_id, sent.message_id, sess.id) - if keyboard is not None: - session_manager.set_last_switcher_msg(user_id, sent.message_id) - state.last_rendered = text - _register_msg(user_id, sent.message_id, sess.id) - session_manager.set_card_msg(user_id, sent.message_id) - - -async def _edit_card( - bot: Bot, - user_id: int, - state: CardState, - *, - text: str, - reply_markup: InlineKeyboardMarkup | None = None, -) -> bool: - """Serialize Telegram edits with cross-session carrier hand-offs.""" - async with _carrier_edit_lock(user_id): - return await _edit_card_unlocked( - bot, - user_id, - state, - text=text, - reply_markup=reply_markup, - ) - - -async def _edit_card_unlocked( - bot: Bot, - user_id: int, - state: CardState, - *, - text: str, - reply_markup: InlineKeyboardMarkup | None = None, -) -> bool: - """Edit the live card. Returns False if the edit failed permanently. - - Always sends a keyboard along with the text — relying on Telegram's - "preserve keyboard when reply_markup is omitted" semantics turned out - flaky (the buttons flickered between edits). Caller may pass an - explicit `reply_markup`; otherwise we rebuild from current busy state. - """ - if state.msg_id is None: - return False - # User is currently looking at a Menu / sub-screen on this card's - # message. Don't repaint — would clobber whatever they're navigating. - # State.lines keeps accumulating; resume_card_view will catch up. - if state.in_menu_view: - return True - if reply_markup is None: - reply_markup = build_footer_keyboard( - user_id, screen="main", is_busy=_card_is_busy(state) - ) - from ..markdown_v2 import convert_markdown - from .message_sender import ( - NO_LINK_PREVIEW, - PARSE_MODE, - strip_sentinels, - try_rich_edit, - ) - - # Photo-mode card: editMessageMedia when pane changed (≤1 per 3s), - # else editMessageCaption to refresh just the text. Captions have no - # rich-message equivalent, so this path stays MarkdownV2. - if state.is_photo_msg: - return await _edit_photo_card( - bot, - user_id, - state, - text=text, - formatted=convert_markdown(text), - reply_markup=reply_markup, - ) - - # Rich-first (Bot API 10.1): keeps the card's native rendering (GFM - # tables, headings,
) consistent with the rich _send_card - # path — otherwise the first edit would visibly downgrade the card - # to MarkdownV2. On failure (rich off, API error, lost carrier) fall - # through to the MarkdownV2 pipeline below, which also owns the - # lost-carrier detection. - if await try_rich_edit(bot, user_id, state.msg_id, text, reply_markup=reply_markup): - return True - - formatted = convert_markdown(text) - - try: - await bot.edit_message_text( - chat_id=user_id, - message_id=state.msg_id, - text=formatted, - parse_mode=PARSE_MODE, - reply_markup=reply_markup, - link_preview_options=NO_LINK_PREVIEW, - ) - return True - except BadRequest as e: - err = str(e) - if "Message is not modified" in err: - return True - if ( - "Message to edit not found" in err - or "message can't be edited" in err.lower() - or "MESSAGE_ID_INVALID" in err - ): - # Carrier is genuinely gone — reset msg_id so the next event - # opens a fresh card. - logger.info("card edit lost-carrier msg_id=%s err=%s", state.msg_id, err) - state.msg_id = None - return False - # Parse error / can't render — fall back to stripped plain text - # on the SAME carrier. Keep the card alive. - logger.warning("card edit MarkdownV2 failed msg=%s err=%s", state.msg_id, err) - try: - await bot.edit_message_text( - chat_id=user_id, - message_id=state.msg_id, - text=strip_sentinels(text), - reply_markup=reply_markup, - link_preview_options=NO_LINK_PREVIEW, - ) - return True - except BadRequest as e2: - err2 = str(e2) - if "Message is not modified" in err2: - return True - logger.warning( - "card edit plain fallback failed msg=%s err=%s", state.msg_id, err2 - ) - except RetryAfter: - raise - except Exception as e2: - logger.warning( - "card edit plain fallback exc msg=%s err=%s", state.msg_id, e2 - ) - except RetryAfter: - raise - except Exception as e: - logger.warning("card edit failed (other): %s", e) - return False - - -_PHOTO_EDIT_MIN_INTERVAL = 2.5 # seconds — per-session throttle on editMessageMedia - - -async def _edit_photo_card( - bot: Bot, - user_id: int, - state: CardState, - *, - text: str, - formatted: str, - reply_markup: InlineKeyboardMarkup | None, -) -> bool: - """Edit a photo+caption card msg. - - Refresh strategy: - * Pane unchanged since last edit → editMessageCaption only. - * Pane changed AND ≥3s since last photo edit → editMessageMedia - with new photo + new caption + keyboard. - * Pane changed but throttled → editMessageCaption only. Next render - after the throttle window will pick up the freshest pane. - """ - import io as _io - - from telegram import InputMediaPhoto - - from ..markdown_v2 import convert_markdown - from .message_sender import PARSE_MODE, strip_sentinels - - # Resolve session from msg_id lookup (we don't have it here directly). - # Find by reverse mapping (user_id, msg_id) → session_id. - sess_id = lookup_session_for_message(user_id, state.msg_id or 0) - sess = session_manager.get_session(sess_id) if sess_id else None - window_id = sess.window_id if sess is not None else "" - - pane_changed = False - pane_png: bytes | None = None - pane_hash = state.last_pane_hash - elapsed = time.monotonic() - state.last_photo_edit_ts - if window_id and elapsed >= _PHOTO_EDIT_MIN_INTERVAL: - png, h = await _capture_pane_png(window_id) - if png is not None and h: - if h != state.last_pane_hash: - pane_changed = True - pane_png = png - pane_hash = h - - try: - if pane_changed and pane_png is not None: - media = InputMediaPhoto( - media=_io.BytesIO(pane_png), - caption=convert_markdown(text), - parse_mode=PARSE_MODE, - ) - await bot.edit_message_media( - chat_id=user_id, - message_id=state.msg_id, - media=media, - reply_markup=reply_markup, - ) - state.last_pane_hash = pane_hash - state.last_photo_edit_ts = time.monotonic() - return True - # Pane unchanged or throttled — caption-only refresh. - await bot.edit_message_caption( - chat_id=user_id, - message_id=state.msg_id, - caption=formatted, - parse_mode=PARSE_MODE, - reply_markup=reply_markup, - ) - return True - except BadRequest as e: - err = str(e) - if "Message is not modified" in err: - return True - if ( - "Message to edit not found" in err - or "message can't be edited" in err.lower() - or "MESSAGE_ID_INVALID" in err - ): - logger.info("photo card edit lost-carrier msg=%s err=%s", state.msg_id, err) - state.msg_id = None - return False - logger.warning( - "photo card edit MarkdownV2 failed msg=%s err=%s", state.msg_id, err - ) - # Plain-text caption fallback. - try: - await bot.edit_message_caption( - chat_id=user_id, - message_id=state.msg_id, - caption=strip_sentinels(text), - reply_markup=reply_markup, - ) - return True - except Exception as e2: - logger.warning( - "photo card plain fallback failed msg=%s err=%s", state.msg_id, e2 - ) - except RetryAfter: - raise - except Exception as e: - logger.warning("photo card edit failed (other): %s", e) - return False - - -async def _deferred_edit( - bot: Bot, user_id: int, sess: Session, state: CardState, delay: float -) -> None: - """Sleep `delay` then render the latest card state and edit once. - - The deferred task always picks up the latest `state.events`, so multiple - events arriving during the sleep collapse into a single edit. - """ - try: - await asyncio.sleep(delay) - # Stale guard: card may have been reset (finalize_task) while we slept. - if state.msg_id is None: - return - text = _render_card(sess, state, user_id=user_id) - if text == state.last_rendered: - return - if await _edit_card(bot, user_id, state, text=text): - state.last_rendered = text - state.last_edit_ts = time.monotonic() - except asyncio.CancelledError: - return - except Exception as e: - logger.debug("deferred card edit failed: %s", e) - finally: - state.pending_edit = None - - -async def update_session_card( - bot: Bot, user_id: int, sess: Session, msg: NewMessage -) -> None: - """Append `msg` to the session's live card, or open a new one if needed. - - Triggers a fresh card on long pause and on hard-limit overflow. - """ - # Fire a (throttled) background prewarm of the pages cache so the - # live-card's ◀ Older N/N counter has a value to render on the - # next event. The first event after session start may still paint - # without the counter — the background task lands within a second. - if sess.window_id: - from .history import kick_prewarm - - kick_prewarm(sess.window_id) - - state = get_card_state(user_id, sess) - # First event after a bot restart: pull JSONL history into events - # so the card shows context, not a single 1/1 page. - await _ensure_seeded(user_id, sess, state) - - # Should we buffer this event instead of rendering it now? Reasons: - # - the user is on a Menu / sub-screen (state.in_menu_view set by - # pause_card_view or transfer_card_to_carrier); - # - the session isn't the user's currently-active one (live check — - # bg sessions must stay silent in chat). - # The check is in ``_should_buffer`` so future buffering reasons - # converge on the same predicate. Previously the bg branch was - # implemented by force-setting ``state.in_menu_view = True`` here; - # the flag was sticky and outlived the bg phase, leaving the card - # permanently paused — until a typed message woke - # ``resume_card_view``. Computing the bg check live fixes that. - must_buffer = _should_buffer(user_id, sess.id, state) - - msg_id_in = state.msg_id - in_menu_view_in = state.in_menu_view - - new_event = _build_event(msg) - # tool_result: fold into the matching tool_use Event in place. - # If no match (race / restart), append the placeholder as a row. - replaced = False - if msg.content_type == "tool_result": - replaced = _apply_tool_result(state, new_event) - - # Buffer-only path: user is on a menu/sub-screen OR session is bg. - # Buffer the event into ``state.events`` so resume / next switcher - # tap can catch up; do NOT trigger stale-card resets, overflow - # continuations, or any rendering. - if must_buffer: - if not replaced and not _duplicate_of_seeded(state.events, new_event): - # Same dedup guard as the live path: a prior seed (line ~1371) - # may already hold this turn; don't buffer a second copy. - state.events.append(new_event) - state.last_event_ts = time.time() - logger.info( - "card_update buffered sess=%s msg_id=%s ctype=%s lines=%d", - sess.id, - msg_id_in, - msg.content_type, - len(state.events), - extra={ - "event": "card_update_buffered", - "user_id": user_id, - "session_id": sess.id, - "msg_id": msg_id_in, - "content_type": msg.content_type, - "lines": len(state.events), - "in_menu_view": in_menu_view_in, - }, - ) - return - - # Spawn-serialization (Task #50): hold the per-session lock from - # the stale-check through the actual send/edit. Otherwise two - # concurrent ``update_session_card`` calls (or one - # ``update_session_card`` racing with ``resume_card_view`` / - # ``repost_card`` / ``finalize_task``) can both see ``msg_id is - # None`` and both spawn — produces "2 messages in wrong order". - async with _card_lock(user_id, sess.id): - return await _update_session_card_locked( - bot, user_id, sess, msg, state, new_event, replaced - ) - - -async def _update_session_card_locked( - bot: Bot, - user_id: int, - sess: Session, - msg: NewMessage, - state: CardState, - new_event: Event, - replaced: bool, -) -> None: - # Recover from a prior false-positive stall_finalize. Wipe the card - # binding so this real assistant turn lands on a fresh message - # below the stalled stub instead of being silently edited into it. - if state.stall_finalized: - _recover_from_false_stall(state) - await _ensure_seeded(user_id, sess, state) - # Trigger: long pause → fresh card. - if _is_stale(state): - state.msg_id = None - state.events = [] - state.current_page_idx = None - state.is_continuation = True - state.last_rendered = "" - # A6: re-seed the recent transcript so the fresh card lands with - # its full turn-history. Without this the card rebuilds one event - # at a time and the footer page counter shows ``1/1`` until a - # second turn completes — even though the transcript is long. - state.seed_attempted = False - state.seed_mtime = -1.0 - await _ensure_seeded(user_id, sess, state) - - if not replaced and not _duplicate_of_seeded(state.events, new_event): - # Dedup guard: if the stale-branch re-seed above (or an earlier - # release_card_message wipe) already pulled this turn in from - # JSONL, don't append it a second time — otherwise the user's own - # message renders twice in the card body. - state.events.append(new_event) - # User-action-anchor: when on the latest page, every new event - # keeps the user there. Done as None (=stick-to-latest) so the - # render layer picks the latest page automatically. - # Page idx is recalibrated by paginate-aware callbacks. - - # Cap event log to avoid unbounded memory; FIFO evicts oldest. - if len(state.events) > CARD_MAX_EVENTS: - del state.events[: len(state.events) - CARD_MAX_EVENTS] - - state.last_event_ts = time.time() - - # Pagination handles size: the latest page is always within - # CARD_HARD_LIMIT chars (paginate splits before the boundary). - # No continuation-card path. - - text = _render_card(sess, state, user_id=user_id) - - if state.msg_id is None: - await _send_card(bot, user_id, sess, state, text=text) - state.last_edit_ts = time.monotonic() - logger.info( - "card_update sent sess=%s msg_id=%s ctype=%s lines=%d", - sess.id, - state.msg_id, - msg.content_type, - len(state.events), - extra={ - "event": "card_update_sent", - "user_id": user_id, - "session_id": sess.id, - "msg_id": state.msg_id, - "content_type": msg.content_type, - "lines": len(state.events), - }, - ) - return - - # Coalesce edits — at most one editMessageText per live_lag seconds. - # User setting takes precedence over the env-var default. - user_lag = session_manager.get_user_settings(user_id).get("live_lag") - if user_lag is None: - user_lag = config.card_edit_lag - lag = max(0.0, float(user_lag)) - elapsed = time.monotonic() - state.last_edit_ts if state.last_edit_ts else lag - if lag <= 0 or elapsed >= lag: - edited = await _edit_card(bot, user_id, state, text=text) - if edited: - state.last_rendered = text - state.last_edit_ts = time.monotonic() - logger.info( - "card_update edit sess=%s msg_id=%s ctype=%s lines=%d", - sess.id, - state.msg_id, - msg.content_type, - len(state.events), - extra={ - "event": "card_update_edited", - "user_id": user_id, - "session_id": sess.id, - "msg_id": state.msg_id, - "content_type": msg.content_type, - "lines": len(state.events), - }, - ) - else: - # Edit failed AND we couldn't recover — DO NOT fall back to - # _send_card here. Sending a new message produces duplicate - # cards in chat (this was the "2 messages in a row" bug: - # Message_too_long → fallback send → stale card stays + new - # appears). Caller's next event retries the edit; if the - # carrier message is truly gone (deleted, too old), the - # ``Message to edit not found`` branch in _edit_card resets - # msg_id and a fresh card spawns on the next event. - state.last_edit_ts = time.monotonic() - logger.warning( - "card_update edit_failed sess=%s msg_id=%s — keeping " - "stale card; new render will retry on next event", - sess.id, - state.msg_id, - ) - return - - # Inside the coalescing window: ensure exactly one deferred edit is queued. - if state.pending_edit is None or state.pending_edit.done(): - delay = max(0.05, lag - elapsed) - state.pending_edit = asyncio.create_task( - _deferred_edit(bot, user_id, sess, state, delay) - ) - - -async def finalize_task(bot: Bot, user_id: int, sess: Session, final_text: str) -> None: - """Append the final assistant answer to the current live card. - - Appends a ``final_text`` Event with ``is_page_break=True`` so the - new answer anchors the top of the latest page; everything before - it (tool log, thinking, mid-stream text) lives on the previous page. - The user lands on the new latest page by default. Long answers - that exceed Telegram's 4096-char limit are sub-paginated by - ``paginate_events``. - """ - state = get_card_state(user_id, sess) - # Recover from a prior false-positive stall_finalize: wipe the card - # binding so this real answer spawns a fresh card below the stalled - # stub. Must run before ``_ensure_seeded`` so the seed targets the - # cleared events list. NOT triggered by ``maybe_finalize_stalled``'s - # own call into ``finalize_task`` — the flag is set only AFTER that - # path returns. - if state.stall_finalized: - _recover_from_false_stall(state) - # First event after a bot restart: seed JSONL history before - # appending the final answer so the user sees their context. - await _ensure_seeded(user_id, sess, state) - - # Bg-session silence + menu-pause buffering. Same predicate as - # update_session_card — see ``_should_buffer`` for the rationale. - must_buffer = _should_buffer(user_id, sess.id, state) - - if state.pending_edit is not None and not state.pending_edit.done(): - state.pending_edit.cancel() - state.pending_edit = None - - cleaned = (final_text or "").strip() - if not cleaned: - # No final text from Claude (e.g. /clear with nothing else). - # Drop the card; no push — the previous "completion push" - # behaviour was removed when the result moved into the card body. - if must_buffer: - return - reset_card(user_id, sess.id) - return - - formatted = split_overflow(cleaned) - cleaned = formatted.text - attachments = formatted.attachments - - # Final answer = ONE OR MORE is_page_break Events: each chunk - # anchors a new page. ``_chunk_final_text`` keeps every chunk under - # ``card_page_lines`` user-setting (in LINES) so the rendered page - # respects what the user picked. Smart boundaries (paragraph / line - # / sentence / word) prevent mid-content breaks. Default focus lands - # on the FIRST chunk's page so the user reads the answer from the top. - now = time.time() - stripped_full = _strip_for_card(cleaned) - chunks = _chunk_final_text(stripped_full, _resolve_line_budget(user_id)) - final_events = [ - Event( - type="final_text", - text=chunk, - body=chunk, - started_at=now, - completed_at=now, - is_page_break=True, - ) - for chunk in chunks - ] - - # Buffer-only path: user is on a Menu view OR session is bg. - # Accumulate the answer Events into state. resume_card_view (next - # typed message) / switcher tap will render the catch-up. - # Attachments still go out (file delivery shouldn't wait on UI nav). - if must_buffer: - state.events.extend(final_events) - state.last_event_ts = now - if attachments: - await _send_attachments(bot, user_id, attachments) - return - - state.events.extend(final_events) - if len(state.events) > CARD_MAX_EVENTS: - del state.events[: len(state.events) - CARD_MAX_EVENTS] - state.last_event_ts = now - # Default focus: when the answer was split into N chunks, land on - # the FIRST chunk's page so the user starts at the top. When there's - # only one chunk, ``None`` = latest, which is that same page. - if len(final_events) > 1: - pages_after = paginate_events_for_card(state, user_id) - # The first chunk's page is at index (len(pages) - len(chunks)). - first_chunk_page = max(0, len(pages_after) - len(final_events)) - state.current_page_idx = first_chunk_page - else: - state.current_page_idx = None - - # Refresh the pages cache so the live-card's pagination counter - # reflects the final transcript length on the finalised message. - # This is the one place we await prewarm directly — finalize fires - # once per task, so ~1 s of parsing is OK to pay for a correct - # ◀ Older N/N counter on the artifact the user looks at most. - if sess.window_id: - try: - from .history import prewarm_pages_cache - - await prewarm_pages_cache(sess.window_id) - except Exception as e: - logger.debug("finalize_task prewarm failed: %s", e) - - # Final answer → ``is_busy=False`` keyboard so the user sees Kill, - # not Stop. State stays live (rolling card); the next turn's events - # keep editing the SAME message — no reset_card, no pin. - done_kb = build_footer_keyboard(user_id, screen="main", is_busy=False) - - text = _render_card(sess, state, user_id=user_id) - # Lock the spawn/edit decision so a parallel ``update_session_card`` - # for the next turn can't see ``msg_id is None`` simultaneously and - # spawn a second card (Task #50). - async with _card_lock(user_id, sess.id): - if state.msg_id is None: - await _send_card(bot, user_id, sess, state, text=text, reply_markup=done_kb) - elif await _edit_card(bot, user_id, state, text=text, reply_markup=done_kb): - state.last_rendered = text - state.last_edit_ts = time.monotonic() - - if attachments: - await _send_attachments(bot, user_id, attachments) - - -async def _send_attachments( - bot: Bot, user_id: int, attachments: list[Attachment] -) -> None: - """Send extracted overflow content. ``kind="photo"`` table extracts - are rasterised via ``screenshot.text_to_image`` so wide tables land - as inline images rather than `.md` files; everything else (oversized - code blocks) goes through ``send_document`` as before. - """ - import io as _io - - from ..screenshot import text_to_image - from .tg_format import pretty_pad_table - - for att in attachments: - try: - if att.kind == "photo": - source = att.content.decode("utf-8", errors="replace") - rendered = pretty_pad_table(source) - png = await text_to_image(rendered, with_ansi=False) - await bot.send_photo( - chat_id=user_id, - photo=_io.BytesIO(png), - ) - else: - await bot.send_document( - chat_id=user_id, - document=_io.BytesIO(att.content), - filename=att.filename, - ) - except Exception as e: - logger.debug("attachment %s send failed: %s", att.filename, e) - - -async def push_event( - bot: Bot, - user_id: int, - sess: Session, - *, - text: str, - is_error: bool = False, -) -> None: - """Bg-session push — a bare one-line notification. - - Format is strictly `` ``: no markdown brackets, - no inline keyboard, no switcher migration. Hijacking the active - card's footer buttons (the previous behaviour) confused users — - bg pushes are status pings, not navigation surfaces. Use the - switcher on the active card to actually visit the session. - """ - emoji = "🟥" if is_error else session_emoji(sess) - name = sess.name or sess.id - body = f"{emoji} {name} {text}" - if len(body) > 3500: - body = body[:3497] + "…" - try: - sent = await safe_send(bot, user_id, body) - except Exception as e: - logger.debug("push_event failed: %s", e) - return - # Register the msg→session map so a reply-quote to this push still - # routes back to the originating session. - if sent is not None: - _register_msg(user_id, sent.message_id, sess.id) - - -def is_card_in_menu_view(user_id: int, session_id: str) -> bool: - """True if the user is currently browsing a Menu / sub-screen on - this session's card. Used by ``status_polling`` to gate the TYPING - indicator — firing it while the user navigates menus is just noise. - """ - state = _cards.get((user_id, session_id)) - return state is not None and state.in_menu_view - - -def is_card_finalized(user_id: int, session_id: str) -> bool: - """True when the card's tail event is a terminal one (``final_text`` - or ``error``). Used by ``status_polling`` to suppress a stale pane - spinner (e.g. ``Sautéed for 11m 16s · 1 shell still running``) that - persists in scrollback after end-of-turn — without this check the - typing indicator stays on forever waiting for the user-visible - spinner string to scroll off. - """ - state = _cards.get((user_id, session_id)) - if state is None or not state.events: - return False - return state.events[-1].type in ("final_text", "error") - - -def is_card_busy(user_id: int, session_id: str) -> bool: - """True when the user's live card for ``session_id`` is currently in - flight AND visible (msg_id set, finalize_task hasn't run, and the - card is not paused for menu navigation). Used by the polling-based - typing-indicator path — TYPING should fire while a turn is mid- - stream during silent gaps between events, but NOT while the user - is browsing the inline ⋯ Menu / sub-screens. While ``in_menu_view`` - the card buffers events without rendering to chat, so a "typing…" - indicator there is just noise. - """ - state = _cards.get((user_id, session_id)) - if state is None or state.in_menu_view: - return False - return _card_is_busy(state) - - -# ─── Stalled-session detection (bug A4) ─────────────────────────────── -# -# When the upstream claude subprocess silently stalls or exits -# mid-iteration, the JSONL stops growing with renderable turns (it may -# still get ``last-prompt`` / ``ai-title`` metadata entries, which -# transcript_parser filters out — see transcript_parser.py:260). The -# session monitor therefore produces ZERO card updates and the live -# card freezes on its last "thinking"/tool_use frame with no signal to -# the user. ``maybe_finalize_stalled`` closes that gap: when an active -# card has sat with a non-terminal tail event AND the pane spinner has -# been idle (gone or frozen) for ``STALL_FINALIZE_AFTER_SECONDS``, it -# finalises the card with a clear note so the user knows the process -# may have stalled rather than the bot being broken. - -# How long an active card may sit with a non-terminal tail event and an -# idle (non-busy) pane before we declare it stalled and finalise it. -# Deliberately generous: a genuinely-busy claude keeps the pane spinner -# *changing* (``Working… (17s)`` → ``(18s)`` → …) so ``pane_busy`` stays -# True and this never fires during long thinking / a slow tool. We only -# trip when the spinner is gone or frozen AND no new renderable event -# arrived for this long — i.e. the subprocess produced nothing. -# -# Two-tier threshold by tail event type. Tools legitimately run for -# minutes (slow Bash, CHYT, Map-Reduce, network) and Claude routinely -# spends a comparable amount of time reasoning after the last tool -# result before emitting the final assistant turn — both produce a -# silent JSONL tail of ``tool_use``. Pre-textual silence (``text`` / -# ``thinking`` tail) is rarer and more suspicious because Claude is -# mid-emit, so the original threshold still applies there. -STALL_FINALIZE_AFTER_SECONDS = 90.0 -STALL_FINALIZE_TOOL_USE_SECONDS = 300.0 - -# Note appended to the card when a stall is detected. Card-body strings -# in this module are not localized (header / "context:" / "goal:" are -# all hard-coded English), so this note follows the same convention. -STALL_NOTE = ( - "⚠️ session went idle without a final reply — " - "the Claude process may have stalled or exited." -) - -# Editing the live card is not a Telegram notification: if the chat is not -# open, the user gets no push and a stalled heavy session can sit unnoticed -# for hours. Send a separate message after the card is finalized so the -# exceptional state is visible outside the chat as well. -STALL_ALERT = ( - "⚠️ {session_name}: no activity after an unfinished tool call. " - "The session may be stalled - open it, tap Stop, then retry the last step." -) - - -async def maybe_finalize_stalled( - bot: Bot, - user_id: int, - sess: Session, - *, - pane_busy: bool, - interactive_waiting: bool, - in_menu: bool, - now: float | None = None, -) -> bool: - """Finalise an ACTIVE session's frozen card when the subprocess stalled. - - Fires (returns True after finalising) ONLY when ALL hold: - - * a card exists for this (user, session) with at least one event; - * the card is NOT already finalized (tail event is non-terminal — - mid ``thinking`` / ``tool_use`` / ``text``); - * the pane spinner is NOT busy (``pane_busy=False`` — gone or - frozen, per ``_pane_status_is_changing``); - * no interactive UI is waiting for the user (``interactive_waiting`` - — AskUserQuestion / ExitPlanMode / Permission / RestoreCheckpoint, - or kb-mode) and the card is not in a Menu sub-screen - (``in_menu``); - * no new renderable event arrived for ``STALL_FINALIZE_AFTER_SECONDS`` - (measured from ``state.last_event_ts``). - - The last condition is what keeps this conservative: a long-thinking - turn keeps the pane spinner changing, so ``pane_busy`` is True and we - bail; a tool_use legitimately awaiting a slow result either keeps the - spinner alive or lands its result well before the window elapses. We - only trip when the spinner has died AND the transcript stopped - growing — the exact fingerprint of a stalled / exited subprocess. - - Reuses ``finalize_task``: the stall note is appended as the turn's - final answer, so the card flips to the finalized (Kill, not Stop) - keyboard via the same path a normal completion takes. - """ - if pane_busy or interactive_waiting or in_menu: - return False - state = _cards.get((user_id, sess.id)) - if state is None or state.msg_id is None or not state.events: - return False - if state.in_menu_view or state.in_kb_mode: - return False - # Already finalized — nothing frozen to rescue. - tail_type = state.events[-1].type - if tail_type in ("final_text", "error"): - return False - if state.last_event_ts <= 0: - return False - when = now if now is not None else time.time() - threshold = ( - STALL_FINALIZE_TOOL_USE_SECONDS - if tail_type == "tool_use" - else STALL_FINALIZE_AFTER_SECONDS - ) - if (when - state.last_event_ts) < threshold: - return False - logger.warning( - "stall_finalize user=%d sess=%s wid=%s idle=%.0fs tail=%s threshold=%.0fs", - user_id, - sess.id, - sess.window_id, - when - state.last_event_ts, - tail_type, - threshold, - extra={ - "event": "stall_finalize", - "user_id": user_id, - "session_id": sess.id, - "window_id": sess.window_id, - "idle_seconds": round(when - state.last_event_ts), - "tail_type": tail_type, - "threshold_seconds": round(threshold), - }, - ) - await finalize_task(bot, user_id, sess, STALL_NOTE) - try: - await safe_send( - bot, - user_id, - STALL_ALERT.format(session_name=sess.name or sess.id), - ) - except Exception as exc: - # The card was still finalized successfully. A transient Telegram - # failure must not make status_polling retry the whole transition and - # produce duplicate final events on the next tick. - logger.warning( - "stall alert failed user=%d sess=%s: %s", - user_id, - sess.id, - exc, - ) - # Arm the false-positive recovery: if a real assistant turn arrives - # after this, the next ``update_session_card`` / ``finalize_task`` - # spawns a fresh card below the stalled stub instead of silently - # editing it. ``finalize_task`` already ran and re-fetched ``state``, - # so re-read from ``_cards`` to set the flag on the same instance. - post_state = _cards.get((user_id, sess.id)) - if post_state is not None: - post_state.stall_finalized = True - return True - - -def is_active_for_user(user_id: int, sess: Session) -> bool: - active = session_manager.get_active_session(user_id) - return active is not None and active.id == sess.id - - -async def repost_card(bot: Bot, user_id: int, sess: Session) -> None: - """Send a fresh live-card below the user's latest message, and drop - the previous one if it exists. - - Called from text_handler on every user-msg dispatch (the legacy - ``card_position`` setting was retired; always-repost is now the - single canonical behaviour). The user always sees a bot-side card - immediately below the message they just typed instead of having - to wait for claude's first event — which may come seconds later - when the model spends a while in thinking before any tool call. - - No-op only when the card is paused (Menu / sub-screen open). In all - other cases — including the post-finalize_task state where the - previous live card was already pinned + reset — we seed a fresh - card so it lands below the user's typed line. When claude's first - event arrives it will edit *this* card (state.msg_id is now set) - instead of spawning a separate one above the user msg. - """ - state = _cards.get((user_id, sess.id)) - if state is not None and state.in_menu_view: - return - state = get_card_state(user_id, sess) - # Seed history from JSONL on first call after a bot restart so the - # reposted card lands with full context, not an empty body. - await _ensure_seeded(user_id, sess, state) - if state.pending_edit is not None and not state.pending_edit.done(): - state.pending_edit.cancel() - state.pending_edit = None - - # Lock the msg_id mutation + spawn so a parallel - # ``update_session_card`` (for a claude event arriving mid-typing) - # can't see the brief ``msg_id is None`` window and spawn its own - # card too — Task #50. - async with _card_lock(user_id, sess.id): - old_msg_id = state.msg_id - state.msg_id = None # force _send_card to create a fresh message - - text = _render_card(sess, state, user_id=user_id) - await _send_card(bot, user_id, sess, state, text=text) - state.last_rendered = text - state.last_edit_ts = time.monotonic() - # A freshly (re)posted card is brand new — reset the freshness - # clock so the first arriving claude event can't misjudge it as - # stale and spawn a SECOND card ~1-2s later (the delete+resend - # flicker). ``repost_card`` previously updated last_rendered / - # last_edit_ts but left ``last_event_ts`` pinned to the previous - # turn; on a card idle >= STALE_CARD_SECONDS that tripped - # ``_is_stale`` on the very next event. A repost is itself user - # activity, so "now" is the correct freshness stamp. - state.last_event_ts = time.time() - new_msg_id = state.msg_id - logger.info( - "repost_card user=%s sess=%s old_msg=%s new_msg=%s events=%d", - user_id, - sess.id, - old_msg_id, - new_msg_id, - len(state.events), - ) - if old_msg_id and new_msg_id and new_msg_id != old_msg_id: - try: - await bot.delete_message(chat_id=user_id, message_id=old_msg_id) - logger.info( - "repost_card deleted_old user=%s sess=%s msg=%s", - user_id, - sess.id, - old_msg_id, - ) - except Exception as e: - logger.warning( - "repost_card delete_old_failed user=%s sess=%s msg=%s err=%s", - user_id, - sess.id, - old_msg_id, - e, - ) - - -async def surface_card_after_message( - bot: Bot, - user_id: int, - sess: Session, - message_id: int, -) -> bool: - """Make the active session card the only receipt for an inbound message. - - Telegram message ids are monotonic within a chat. The card therefore - acknowledges queue admission simply by sitting below ``message_id``. The - position check and repost are serialized so several messages arriving in - one burst converge on one newest card instead of spawning one per task. - """ - state = get_card_state(user_id, sess) - await _ensure_seeded(user_id, sess, state) - old_msg_id: int | None = None - new_msg_id: int | None = None - - async with _card_lock(user_id, sess.id): - async with _carrier_edit_lock(user_id): - if not is_active_for_user(user_id, sess): - return False - if state.msg_id is not None and state.msg_id > message_id: - return True - - if state.pending_edit is not None and not state.pending_edit.done(): - state.pending_edit.cancel() - state.pending_edit = None - # Sending content is an explicit return from a menu/sub-screen to - # the live conversation. Otherwise the old menu card would stay - # above the message and acceptance would remain invisible. - state.in_menu_view = False - old_msg_id = state.msg_id - state.msg_id = None - text = _render_card(sess, state, user_id=user_id) - await _send_card(bot, user_id, sess, state, text=text) - new_msg_id = state.msg_id - if new_msg_id is None: - # Telegram send failed: retain the existing carrier binding so - # later events can recover instead of orphaning a valid card. - state.msg_id = old_msg_id - return False - state.last_rendered = text - state.last_edit_ts = time.monotonic() - state.last_event_ts = time.time() - - logger.info( - "card_surface user=%s sess=%s after=%s old_msg=%s new_msg=%s", - user_id, - sess.id, - message_id, - old_msg_id, - new_msg_id, - ) - if old_msg_id and new_msg_id != old_msg_id: - try: - await bot.delete_message(chat_id=user_id, message_id=old_msg_id) - except Exception as exc: - logger.warning( - "card_surface delete_old_failed user=%s sess=%s msg=%s err=%s", - user_id, - sess.id, - old_msg_id, - exc, - ) - return True - - -def schedule_card_after_message( - bot: Bot, - user_id: int, - sess: Session, - message_id: int, -) -> asyncio.Task[bool]: - """Schedule a non-blocking card receipt for Telegram intake.""" - task = asyncio.create_task( - surface_card_after_message(bot, user_id, sess, message_id), - name=f"card-surface:{user_id}:{sess.id}:{message_id}", - ) - _card_surface_tasks.add(task) - - def _finished(done: asyncio.Task[bool]) -> None: - _card_surface_tasks.discard(done) - if done.cancelled(): - return - try: - exc = done.exception() - except asyncio.CancelledError: - return - if exc is not None: - logger.warning( - "card_surface failed user=%s sess=%s after=%s err=%s", - user_id, - sess.id, - message_id, - exc, - ) - - task.add_done_callback(_finished) - return task - - -async def shutdown_card_surface_tasks() -> None: - """Cancel outstanding intake card moves during application shutdown.""" - tasks = list(_card_surface_tasks) - for task in tasks: - if not task.done(): - task.cancel() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - _card_surface_tasks.clear() - - -async def refresh_panel(bot: Bot, user_id: int) -> None: - """Re-render the active session's live card so the bg-status panel - (and active quota glyph) reflects the latest bg_status state. - - No-op when: - - the user has no active session - - the active session has no live card yet - - the card is paused (menu/sub-screen open) - - a deferred edit is already queued — that edit will pick up the - latest panel state on its own when it fires - """ - active = session_manager.get_active_session(user_id) - if active is None: - return - state = _cards.get((user_id, active.id)) - if state is None or state.msg_id is None or state.in_menu_view: - return - if state.pending_edit is not None and not state.pending_edit.done(): - return - text = _render_card(active, state, user_id=user_id) - if text == state.last_rendered: - return - if await _edit_card(bot, user_id, state, text=text): - state.last_rendered = text - state.last_edit_ts = time.monotonic() - - -# ─── Tool-timer tick ────────────────────────────────────────────────── - -# How often to re-render the active card to advance the ⏳ M:SS counter -# on the latest in-flight tool/thinking entry. Matches the -# session_monitor poll cadence (2 s) so the card feels as responsive as -# Telegram's own "typing…" indicator — per-user feedback on pivot #39. -# Inline-screenshot cards are additionally throttled by -# ``_PHOTO_EDIT_MIN_INTERVAL`` (2.5 s) so editMessageMedia bursts stay -# within Telegram's limits. -CARD_TIMER_TICK_SECONDS = 2.0 - - -async def card_timer_loop(bot: Bot) -> None: - """Background task that ticks the elapsed timer on the latest - in-flight tool/thinking entry of each user's active card. - - Skips: - - cards with no msg_id - - paused cards (in_menu_view) - - users whose pagination puts them on a non-latest page (timer - only ticks on the page where the in-flight event lives, i.e. - the latest page) - - cards with a pending deferred edit (the deferred edit picks up - the updated timer when it fires) - """ - logger.info("card_timer_loop started tick=%.1fs", CARD_TIMER_TICK_SECONDS) - while True: - try: - await asyncio.sleep(CARD_TIMER_TICK_SECONDS) - for (uid, sid), state in list(_cards.items()): - try: - if state.msg_id is None or state.in_menu_view: - continue - sess = session_manager.get_session(sid) - if sess is None: - continue - # Only the user's currently-active session ticks. - active = session_manager.get_active_session(uid) - if active is None or active.id != sid: - continue - pages = paginate_events_for_card(state, uid) - idx = _resolved_page_idx(state, len(pages)) - # Timer renders only on the latest page. - if idx != len(pages) - 1: - continue - if _latest_inflight_idx(pages[idx]) is None: - continue - # Skip when an edit is already queued — it'll pick - # up the fresh timer value when it fires. - if state.pending_edit is not None and not state.pending_edit.done(): - continue - text = _render_card(sess, state, user_id=uid) - if text == state.last_rendered: - continue - if await _edit_card(bot, uid, state, text=text): - state.last_rendered = text - state.last_edit_ts = time.monotonic() - except asyncio.CancelledError: - raise - except Exception as e: - logger.debug("card_timer tick failed for sess=%s: %s", sid, e) - except asyncio.CancelledError: - logger.info("card_timer_loop cancelled") - break - except Exception as e: - logger.warning("card_timer_loop error: %s", e) diff --git a/src/ccbot/handlers/status_approval.py b/src/ccbot/handlers/status_approval.py new file mode 100644 index 00000000..8dd6a5ca --- /dev/null +++ b/src/ccbot/handlers/status_approval.py @@ -0,0 +1,55 @@ +"""Pure parsing helpers for interactive auto-approval.""" + +from __future__ import annotations + +import re +from typing import Protocol + + +class InteractiveContent(Protocol): + """Subset of terminal-parser content used to build a signature.""" + + name: str + content: str + + +_OPTION_LINE_RE = re.compile(r"^[\s❯›>]*?(\d+)\.\s+(.+?)\s*$") +_DIGIT_RUN_RE = re.compile(r"\d+") +_DURABLE_YES_RE = re.compile( + r"during this session|don'?t ask again|allow all|always allow|for the rest of", + re.IGNORECASE, +) + + +def auto_approve_progress(pane_text: str) -> str: + """Return pane content with volatile numeric counters normalized.""" + return _DIGIT_RUN_RE.sub("#", pane_text) + + +def parse_best_yes_option(pane_text: str) -> str | None: + """Prefer a durable Yes menu option, falling back to the first Yes.""" + first_yes: str | None = None + for raw in pane_text.splitlines(): + match = _OPTION_LINE_RE.match(raw) + if not match: + continue + number, label = match.group(1), match.group(2) + if not label.lower().startswith("yes"): + continue + if first_yes is None: + first_yes = number + if _DURABLE_YES_RE.search(label): + return number + return first_yes + + +def auto_approve_signature(pane_text: str, content: InteractiveContent | None) -> str: + """Return a stable identity for one on-screen approval prompt.""" + if content is not None: + return f"{content.name}\x1f{content.content}" + options = [ + f"{match.group(1)}.{match.group(2)}" + for line in pane_text.splitlines() + if (match := _OPTION_LINE_RE.match(line)) + ] + return "\n".join(options) diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py index c1a05609..0849a781 100644 --- a/src/ccbot/handlers/status_polling.py +++ b/src/ccbot/handlers/status_polling.py @@ -16,7 +16,6 @@ import asyncio import logging -import re import time from typing import TYPE_CHECKING @@ -50,11 +49,13 @@ maybe_finalize_stalled, refresh_panel, ) +from .status_approval import ( + auto_approve_progress as _auto_approve_progress, + auto_approve_signature as _auto_approve_signature_impl, + parse_best_yes_option as _parse_best_yes_option, +) from .typing import fire_typing -# Match option lines like " 1. Yes" / " ❯ 2. Yes, and don't ask again". -_OPTION_LINE_RE = re.compile(r"^[\s❯›>]*?(\d+)\.\s+(.+?)\s*$") - # kb-mode teardown debounce. terminal_parser detection over a live TUI # flickers: a single redraw frame, a partial pane capture, or the cursor # moving onto a ``❯ Submit`` action line (multi-select AskUserQuestion) can @@ -110,83 +111,14 @@ # (user_id, window_id) -> (prompt_signature, progress_marker, attempts) _auto_approve_attempts: dict[tuple[int, str], tuple[str, str, int]] = {} -# Volatile per-frame counters (token totals, elapsed timers on the running -# agent rows) tick every poll even while a prompt sits unanswered. Normalising -# every run of digits to ``#`` keeps those ticks from reading as real progress, -# while genuine new output (tool results, day names, paths) still changes the -# marker. -_DIGIT_RUN_RE = re.compile(r"\d+") - - -def _auto_approve_progress(pane_text: str) -> str: - """Progress marker: the pane minus its volatile digit counters. - - Equal across two polls ⇒ nothing but timers ticked (no command ran). - Different ⇒ the transcript advanced (an approval landed and its tool - executed), so a same-looking follow-up prompt is a NEW one, not a stuck - repeat. Cheap and structural — no attempt to locate the prompt region. - """ - return _DIGIT_RUN_RE.sub("#", pane_text) - - -# A *durable* Yes label — approving it once suppresses the whole storm of -# follow-up prompts of the same scope: a directory-scoped read grant, a -# ``don't ask again`` for a command/domain, an allow-all-edits-this-session. -# Preferring these over the one-shot "Yes" turns a per-file whack-a-mole into -# a single approval, so the read-storm that used to trip the escalation -# counter (a distinct file each poll behind an identical-looking dialog) -# never recurs — the second read in that scope no longer prompts. -_DURABLE_YES_RE = re.compile( - r"during this session|don'?t ask again|allow all|always allow|for the rest of", - re.IGNORECASE, -) - - -def _parse_best_yes_option(pane_text: str) -> str | None: - """Number of the Yes option to auto-select. - - Prefers a *durable* Yes ("… during this session" / "… don't ask again" / - "allow all …") over the one-shot "Yes" so a single keystroke clears a - whole storm of same-scope prompts. Falls back to the first plain Yes when - no durable variant is offered (e.g. ExitPlanMode's two Yes rows, neither - of which is a scope grant). - """ - first_yes: str | None = None - for raw in pane_text.splitlines(): - m = _OPTION_LINE_RE.match(raw) - if not m: - continue - num, label = m.group(1), m.group(2) - if not label.lower().startswith("yes"): - continue - if first_yes is None: - first_yes = num - if _DURABLE_YES_RE.search(label): - return num - return first_yes - def _auto_approve_signature( pane_text: str, content: InteractiveUIContent | None = None ) -> str: - """Stable identity of the on-screen prompt for attempt-tracking. - - Prefers the extracted prompt body + name so distinct prompts (a - different fetch domain, a different file) don't share a counter, while - redraws of the SAME (spinner-free) permission dialog collapse to one - signature. Falls back to the numbered option block when extraction - returns None. - """ + """Build a signature through the old module's patchable extractor seam.""" if content is None: content = extract_interactive_content(pane_text) - if content is not None: - return f"{content.name}\x1f{content.content}" - opts = [ - f"{m.group(1)}.{m.group(2)}" - for line in pane_text.splitlines() - if (m := _OPTION_LINE_RE.match(line)) - ] - return "\n".join(opts) + return _auto_approve_signature_impl(pane_text, content) async def _maybe_auto_approve(user_id: int, window_id: str, pane_text: str) -> bool: @@ -481,16 +413,10 @@ async def _drive_typing_indicator( if pane_busy and sess is not None and is_card_finalized(user_id, sess.id): pane_busy = False - # Stalled-session rescue (bug A4). For the ACTIVE session only: if the - # card has a non-terminal tail event but the pane spinner is idle and - # no new event has arrived for STALL_FINALIZE_AFTER_SECONDS, the - # upstream claude process likely stalled/exited (it may still write - # ``last-prompt`` / ``ai-title`` metadata, which transcript_parser - # filters out, so the monitor produces no card update). Finalise the - # frozen card with a clear note instead of leaving it stuck forever. - # Excluded: a still-changing spinner, a waiting interactive UI / kb - # prompt, and menu navigation — all valid "idle" states, not stalls. - if sess is not None and not is_bg_session: + # Silent unfinished turn: keep the active session's live pane refreshing; + # for a background session expose only a ⚠️ row in the active card panel. + # No synthetic final answer and no separate Telegram push are emitted. + if sess is not None: from .notifications import has_pending_kb interactive_waiting = ( diff --git a/src/ccbot/i18n.py b/src/ccbot/i18n.py index fbeac1a0..c61777ad 100644 --- a/src/ccbot/i18n.py +++ b/src/ccbot/i18n.py @@ -1,22 +1,17 @@ -"""Lightweight i18n: per-user UI strings in English / Russian / Chinese. +"""Lightweight i18n service with per-user language selection. -The active language is stored in `user_settings[user_id]["language"]` and -toggled via the inline ⚙ Settings → Language sub-screen. Anything not in -this surface (forwarded slash output, log messages, error details from the -shell) stays English regardless of the user's pick. - -Public API: - t(user_id, key, **fmt) -> str - -The translation table is intentionally flat, dotted keys keep grouping -readable. Missing keys fall back to English; unknown languages fall back -to English as well. +The translation data lives in ``ccbot.i18n_locales``. This module preserves +the original public and de-facto API: ``LANGUAGES``, ``TRANSLATIONS``, the +``_EN``/``_RU``/``_ZH`` aliases, ``get_user_lang`` and ``t``. """ from __future__ import annotations from typing import Any +from .i18n_locales import EN as _EN +from .i18n_locales import RU as _RU +from .i18n_locales import ZH as _ZH from .session import session_manager LANGUAGES: tuple[tuple[str, str], ...] = ( @@ -25,1370 +20,6 @@ ("zh", "中文"), ) -# English source of truth — every key MUST be present here. -_EN: dict[str, str] = { - # Voice delivery - "voice.not_delivered": ( - "🎙 Voice didn't reach the session — it has an open prompt " - "(permission/question), so the transcribed text can't go in. " - "Answer the prompt and resend the voice message." - ), - "voice.download_failed": ( - "🎙 Voice didn't reach the session: Telegram couldn't provide the " - "audio file after {attempts} attempts. Please resend the voice message." - ), - "voice.transcription_failed": ( - "🎙 The voice message couldn't be recognized and didn't reach the " - "session. Please send it again." - ), - "voice.transcribing": "🎙 Voice message is being transcribed…", - "voice.queued_dropped": "Messages sent after it didn't reach the session either.", - # Footer buttons - "btn.stop": "⏹ Stop", - "btn.kill": "💀 Kill", - "btn.clear": "🧹 Clear", - "btn.menu": "≡ Menu", - "btn.term": "🖥 Term", - "btn.back": "← Back", - "btn.cancel": "× Cancel", - "btn.login": "🔐 Log in", - # Claude re-authentication (/login) - "auth.expired": ( - "🔐 *Claude authorization expired*\n\n" - "Every session on this host will keep failing until the login is " - "renewed. The bot itself is fine — it can walk you through it.\n\n" - "Send /login (or tap below): I hand you a link, you approve it in the " - "browser and send the code back here." - ), - "auth.login.starting": "🔐 Starting the login exchange…", - "auth.login.url": ( - "🔐 *Step 1/2* — open this and approve:\n\n" - "{url}\n\n" - "*Step 2/2* — the page shows a code. Send it here as a normal " - "message. The link is valid for 15 minutes." - ), - "auth.login.no_url": ( - "❌ Could not get a login URL from the CLI. Try /login again; if it " - "keeps failing, run `claude auth login` on the host." - ), - "auth.login.ok": ( - "✅ *Logged in.* Authorization renewed until {deadline}.\n\n" - "Sessions that were failing will work on the next message." - ), - "auth.login.failed": "❌ The code was not accepted: {detail}\n\nSend /login to retry.", - "auth.login.cancelled": "Login cancelled.", - "auth.codex.device": ( - "🔐 *Codex sign-in*\n\n" - "1. Open {url}\n" - "2. Enter this code: `{code}`\n\n" - "The bot will detect approval automatically; don't send the code here. " - "The code is valid for about 15 minutes." - ), - "auth.codex.no_device_code": ( - "❌ Codex did not provide a device code. Check that a current Codex CLI " - "is installed and run /login to retry." - ), - "auth.codex.ok": ( - "✅ *Codex is authorized.* You can create and resume sessions now." - ), - "auth.codex.failed": "❌ Codex sign-in failed: {detail}\n\nSend /login to retry.", - "auth.codex.waiting": ( - "🔐 Codex is still waiting for browser approval. Use the link and code above." - ), - "auth.codex.required": ( - "🔐 Authorize Codex using the link above, then retry creating the session." - ), - "auth.codex.check_failed": ( - "❌ Could not check Codex authorization. Verify `codex --version` and " - "`CODEX_COMMAND`, then send /login." - ), - "auth.codex.storage_mismatch": ( - "⚠ Codex found `auth.json`, but its effective credential storage does " - 'not read it. Set `cli_auth_credentials_store = "file"`; the bot ' - "will not replace the existing authorization." - ), - "btn.confirm": "✓ Confirm", - "btn.no": "× No", - "btn.yes_kill": "⚠ Yes, kill", - "btn.yes_delete": "⚠ Yes, delete", - "btn.yes_clear": "⚠ Yes, clear", - "btn.refresh": "🔄 Refresh", - "btn.save": "Saved", - "btn.cancelled": "Cancelled", - # Archive buttons - "btn.restore": "⤴ Restore", - "btn.restore_with_name": "⤴ Restore {name}", - "btn.inspect": "🔍 Inspect", - "btn.open_session": "📜 {name}", - "btn.delete": "🗑 Delete", - "btn.to_14d": "→ 14d", - "btn.to_72h": "→ 72h", - # More menu - "mm.sessions": "📋 Sessions", - "mm.status": "📊 Status", - "mm.history": "📜 History", - "mm.shot": "🧑‍💻 Shot", - "mm.new": "🆕 New", - "mm.archive": "🗄 Archive", - "mm.settings": "⚙ Settings", - # Menu screen body - "menu.title": "*Menu*", - "menu.empty": "*Menu*\n\nNo active session — pick one from the switcher or tap 🆕 New.", - "menu.active": "*Menu* · active: *{name}*", - # Settings — top - "settings.title": "*Settings*", - "settings.body": ( - "*Settings*\n\n" - "Agent: `{agent}`\n" - "Language: `{language}`\n" - "Live lag: `{live_lag}s`\n" - "Voice: `{voice}`\n\n" - "_Tap a group to change._" - ), - # Settings — group labels (in the main grid) - "settings.group.agent": "Agent", - "settings.group.language": "Language", - "settings.group.live_lag": "Live lag", - "settings.group.voice": "Voice", - # Settings — group sub-screen descriptions - "settings.lag.body": ( - "*Live preview lag*\n\n" - "Coalescing window for live-card edits.\n" - "`0s` = update on every event, higher = quieter chat." - ), - "settings.voice.body": ( - "*Voice transcription*\n\n" - "Backend used for voice messages.\n" - "• `auto` — Apple on macOS, whisper.cpp elsewhere\n" - "• `whisper` — force whisper.cpp\n" - "• `apple` — force Apple Speech (macOS only)\n" - "• `off` — drop voice messages" - ), - "settings.agent.body": ( - "*Agent*\n\n" - "Global backend for the entire bot. All new sessions use either " - "*Claude* or *Codex*.\n\n" - "Switching is blocked while sessions from the current backend are " - "still live. Archive or kill them first." - ), - "settings.lang.body": "*Language*\n\nUI language. Switches everything\nbut Claude's own output.", - # Sessions list — only ``list.empty`` is still used (Menu → Sessions - # empty-state when there's no active session). ``list.active`` / - # ``list.lost`` are legacy. - "list.empty": "No live sessions. Use 🆕 New to create one.", - # Confirm dialogs - "conf.kill": ( - "Kill *{name}*?\nTmux window dies, claude session id stored.\n" - "Restore via the archive list." - ), - "conf.done": "Mark *{name}* as done?\nGoal closed, session archived.", - "conf.delete": ( - "Delete *{name}* from archive?\nState record gone. JSONL kept on disk." - ), - "conf.clear": ( - "Clear *{name}*?\nSends Esc then /clear. Session context wiped — " - "cannot be undone (unlike Kill → Restore)." - ), - "conf.killed": "💀 Killed `{name}`", - "conf.done_ok": "🎉 Marked `{name}` as done.", - "conf.deleted": "🗑 Archive entry deleted.", - # Directory browser - "dir.title": "*Select Working Directory*", - "dir.current": "Current: `{path}`", - "dir.empty": "_(No subdirectories)_", - "dir.hint": "Tap a folder to enter, or select current directory", - "dir.btn.up": "..", - "dir.btn.select": "Select", - # Session picker - "picker.title": "*Resume Session?*", - "picker.summary": "page {page}/{pages} — {total} session(s) in this directory.", - "picker.btn.start_fresh": "🆕 Start fresh", - "picker.btn.back_to_dirs": "← Back to dirs", - # Inline toasts - "toast.no_session": "No active session", - "toast.window_gone": "Window gone", - "toast.esc_sent": "⎋ Esc sent", - "toast.cleared": "🧹 Context cleared", - "toast.killed": "Killed", - "toast.done": "Done", - "toast.deleted": "Deleted", - "toast.saved": "Saved", - "toast.agent_live": ( - "Archive or kill all live sessions before switching the global agent." - ), - "toast.restored": "Restored", - "toast.already_gone": "Already gone", - "toast.nothing_to_kill": "Nothing to kill", - "toast.term_opened": "🖥 Terminal opened", - "toast.invalid_page": "Invalid page", - "toast.session_not_found": "Session not found", - "toast.restore_failed": "Restore failed: {msg}", - "toast.range_14d": "→ 14d", - "toast.range_72h": "→ 72h", - # Archive screen - "archive.title": "Archived sessions", - "archive.range_72h": " (0–72h)", - "archive.range_14d": " (0–14d)", - "archive.empty": "No archived sessions in this window.", - "archive.page_line": "page {page}/{pages} — {total} total", - "archive.age.s": "{n}s ago", - "archive.age.m": "{n}m ago", - "archive.age.h": "{n}h ago", - "archive.age.d": "{n}d ago", - # /usage compact display - "usage.title": "*Claude Code*", - "usage.title.codex": "*OpenAI Codex*", - "usage.unavailable": "Live usage unavailable.", - "usage.auth_required": ( - "Codex authorization is required to load Usage. Complete the sign-in " - "sent above, then refresh this screen." - ), - "usage.5h": "5h", - "usage.week": "week", - "usage.week_sonnet": "week (Sonnet)", - "usage.not_reported": "not reported by Codex", - "usage.today": "Today", - "usage.today_left": "another", - "usage.today_overspent": "over by", - "usage.used": "Used", - "usage.reset": "Reset", - "usage.extra": "Extra", - "usage.on": "on", - "usage.off": "off", - "usage.fetching": "Fetching usage…", - # Settings group: weekly reset day - "settings.group.weekly_reset_day": "Weekly reset", - "settings.weeklyday.body": ( - "*Weekly reset day*\n\n" - "Day of week the Anthropic weekly window resets.\n" - "Used to compute the %/day burn rate on the weekly rows." - ), - "day.mon": "Mon", - "day.tue": "Tue", - "day.wed": "Wed", - "day.thu": "Thu", - "day.fri": "Fri", - "day.sat": "Sat", - "day.sun": "Sun", - # Settings group: auto-approve interactive prompts - "settings.group.auto_approve": "Auto-approve", - "settings.approve.body": ( - "*Auto-approve*\n\n" - "Bot's response to Claude Code's interactive Yes/No prompts\n" - "that --dangerously-skip-permissions doesn't already bypass\n" - "(e.g. WebFetch per-domain trust):\n" - "• `off` — surface in chat, you tap manually\n" - "• `on` — auto-Yes on every prompt" - ), - "approve.off": "off", - "approve.on": "on", - "settings.group.session_idle_hours": "Auto-archive after", - "settings.idle_archive.body": ( - "*Session auto-archive*\n\n" - "Archive a live session after this many hours without activity. " - "Archived sessions remain available through Menu → Archive and can be restored." - ), - "settings.value.hours": "{value}h", - # Local terminal — 3-state (off / manual / auto). - "local.off": "off", - "local.manual": "manual", - "local.auto": "auto", - # Settings group: how many recent end_turn boundaries to seed into a - # fresh live card from the JSONL transcript. - "settings.group.card_history": "Card history", - "settings.cardhist.body": ( - "*Card history*\n\n" - "How many recent end-of-turn boundaries to load into the live " - "card on first access (after a bot restart, switcher tap, or " - "Menu → Sessions). Deep history beyond this stays accessible " - "via /history regardless of the chosen value.\n\n" - "Higher = more scrollback in the card, more memory per session." - ), - "settings.group.card_page_lines": "Page size", - "settings.pagesize.body": ( - "*Page size*\n\n" - "Max lines on one card page. Older events drop to previous " - "pages (◀); a long final answer is chunked across multiple " - "pages with smart paragraph / sentence boundaries — no breaks " - "mid-word. ±5 lines tolerance.\n\n" - "Smaller = compact phone view. Larger = more context per page " - "but heavier message edits." - ), - "settings.group.card_inline_screenshots": "Inline screenshots", - "settings.screens.body": ( - "*Inline screenshots*\n\n" - "When *on*, the active session card is a photo + caption: the " - "photo is the live pane render, the caption holds the body " - "text. The photo refreshes only when the pane changes, with a " - "3 sec throttle. The Shot button disappears from the top row " - "(no need — it's already inline).\n\n" - "*Caveat:* Telegram caption is limited to 1024 chars vs 4096 " - "for text — page size effectively shrinks ~4×. Use Page size " - "setting to compensate.\n\n" - "When *off*, the card is a regular text msg and Shot lives " - "behind the 🧑‍💻 button in the top row." - ), - "screens.on": "on", - "screens.off": "off", - # Bg notifications (Task #42) — three independent toggles. - "settings.group.bg_notify_finished": "Bg: task complete", - "settings.group.bg_notify_error": "Bg: errors", - "settings.group.bg_notify_needs_action": "Bg: needs action", - "settings.bg_notify.finished.body": ( - "*Bg session: task complete*\n\n" - "When a background session reaches end-of-turn, push a quiet " - "notification ✅ [] task complete so you can switch in." - ), - "settings.bg_notify.error.body": ( - "*Bg session: errors*\n\n" - "Push ❌ [] error when a background session emits an " - "error event. (Currently fires only on explicit error events; " - "exception detection is being extended.)" - ), - "settings.bg_notify.needs_action.body": ( - "*Bg session: needs action*\n\n" - "Push ❓ [] needs your attention when a background session " - "shows an AskUserQuestion / ExitPlanMode / Permission prompt. " - "Otherwise only the ❓ badge in the bg-panel signals it — easy " - "to miss." - ), - "settings.group.haiku_naming": "AI session names", - "settings.haiku.body": ( - "*AI session names*\n\n" - "When *on*, every new session is renamed after the first user " - "message ≥20 chars via a one-shot lightweight-model call (Haiku " - "for Claude, `CODEX_NAMING_MODEL` for Codex) — yields a 1-3 " - "word kebab-case summary of the session's intent " - "(``token-budget-alerts``, ``archive-pagination-fix``). " - "Manually-renamed sessions (``/rename``, ``/new " - "``) are never overwritten.\n\n" - "When *off*, sessions keep the directory-basename name forever " - "(``workdir``, ``workdir-2``, ``ccbot``). Zero token cost." - ), - # Settings categories (top-level Settings is now a category selector). - "settings.cat.card": "🃏 Card / view", - "settings.cat.notifications": "🔔 Notifications", - "settings.cat.voice": "🎙 Voice", - "settings.cat.terminal": "🖥 Local terminal", - "settings.cat.behavior": "⚙ Agent, behavior & language", - "settings.cat.card.body": ( - "*Card / view*\n\nLayout, density and refresh of the live session card." - ), - "settings.cat.notifications.body": ( - "*Notifications*\n\n" - "Bg-session pushes (finished / errors / needs-action) and " - "the weekly-reset day for quota alerts." - ), - "settings.cat.voice.body": ( - "*Voice*\n\nSpeech-to-text backend for incoming voice messages." - ), - "settings.cat.terminal.body": ( - "*Local terminal*\n\n" - "Native Terminal / iTerm window attached to each new session." - ), - "settings.cat.behavior.body": ( - "*Behavior & language*\n\n" - "Global agent; auto-approve prompts; Haiku session names; UI language." - ), - # Settings group: pop a native Terminal/iTerm window per new session - "settings.group.local_terminal": "Local terminal", - "settings.local.body": ( - "*Local terminal*\n\n" - "Optional native desktop terminal attached to a session's " - "tmux window — useful for driving Claude by hand in parallel " - "with the Telegram UI.\n\n" - "*off* — never spawn, never offer.\n" - "*manual* — no auto-spawn; *🖥 Term* shows up next to *Stop / " - "Kill / Clear / Menu* whenever the active session has no " - "terminal attached.\n" - "*auto* — spawn one on every new session AND show the same " - "*🖥 Term* button whenever no terminal is attached.\n\n" - "macOS: Terminal.app or iTerm2 (auto-detected).\n" - "Linux: pick an emulator below. Tap *Configure via Claude* if " - "the auto-detected list is wrong for your setup." - ), - "settings.local.claude_help": "🪄 Configure via Claude", - # /help inline mini-doc - "help.home.body": ( - "*Help*\n\n" - "ccbot bridges this DM to N parallel Claude Code sessions running " - "in tmux. Tap a section below for a quick tour." - ), - "help.btn.overview": "Overview", - "help.btn.sessions": "Sessions", - "help.btn.menu": "Menu", - "help.btn.commands": "Commands", - "help.btn.voice": "Voice & files", - "help.btn.alerts": "Alerts", - "help.btn.terminal": "Local terminal", - "help.btn.tips": "Tips", - "help.body.overview": ( - "*Overview*\n\n" - "One private DM, many parallel Claude Code sessions. Send any " - "text — it goes to your *active* session. Each session lives in " - "its own tmux window with its own claude process; switching the " - "active session never pauses the others.\n\n" - "The inline keyboard under the most recent bot message hosts " - "the session switcher and the ≡ Menu surface." - ), - "help.body.sessions": ( - "*Sessions*\n\n" - "• *Create.* Send any text from an empty DM, or tap ≡ Menu → 🆕 " - "New, then pick a project directory.\n" - "• *Switch.* Tap a session button in the inline switcher under " - "the latest bot message.\n" - "• *Reply-quote.* Reply to a non-active session's bot message — " - "your text is routed there for that one message only.\n" - "• *Done.* `/done [name]` archives a session as completed.\n" - "• *Idle TTL.* Sessions auto-archive after the selected 6/12/24h without activity.\n" - "• *Restore.* ≡ Menu → 📦 Archive → tap *Restore*." - ), - "help.body.menu": ( - "*≡ Menu*\n\n" - "Open via /menu or the ≡ Menu inline button. Items:\n" - "• 📋 *Sessions* — jump to the active session's live card\n" - "• 📊 *Status* — Claude Code 5h / weekly / sonnet quotas\n" - "• 🧑‍💻 *Shot* — terminal snapshot of the active session\n" - "• 🆕 *New* — create a session from a directory browser\n" - "• 📦 *Archive* — restore / inspect / delete archived sessions\n" - "• ⚙ *Settings* — grouped by Card / Notifications / Voice / " - "Terminal / Behavior." - ), - "help.body.commands": ( - "*Slash commands*\n\n" - "Bot-side:\n" - "• `/menu` — open the inline menu\n" - "• `/help` — this help\n" - "• `/done [name]` — archive a session\n" - "• `/health` — uptime, queues, latency, counters\n\n" - "Claude Code passthrough — any other `/cmd` is forwarded:\n" - "• `/model` `/effort` `/clear` `/compact` `/cost` `/memory` …\n\n" - "Type a leading `!` to capture local shell output and forward." - ), - "help.body.voice": ( - "*Voice & files*\n\n" - "• *Voice.* Send a voice message — transcribed locally " - "(whisper.cpp / Apple Speech) and routed to the active session " - "as if you typed it.\n" - "• *Photo / document.* Lands in `/.ccbot-inbox/` and " - "Claude is told via the relative path (with your caption prefix " - "if you attached one). Files auto-clean after 24h; the Telegram " - "`file_id` is retained for 30d for `/restore-file`." - ), - "help.body.alerts": ( - "*Alerts*\n\n" - "*Quota alerts.* 5h / weekly / weekly-Sonnet quotas are sampled " - "from the live `/usage` modal every 10 min. Bot pushes when % " - "crosses 50, 75, or 90.\n\n" - "*Bg session pushes.* Settings → Notifications has three " - "toggles (all default on):\n" - "• ✅ task complete\n" - "• ❌ error\n" - "• ❓ needs your attention (interactive prompt)\n" - "Active session never pushes — it edits its live card instead.\n\n" - "*Context fill.* The card shows ``context: N%`` per session. " - "For Codex it uses exact token usage and model-window values from " - "the rollout. For Claude it is a JSONL estimate (latest assistant " - "input + cache reads vs the published model window)." - ), - "help.body.terminal": ( - "*Local terminal*\n\n" - "Settings → Local terminal: when *on*, every new session pops " - "a native window already attached to its tmux window — drive " - "the session by hand from the desktop in parallel.\n\n" - "macOS: Terminal.app / iTerm2 (auto, prefers iTerm tabs).\n" - "Linux: pick an emulator from the auto-detected list, or use " - "*Configure via Claude* for unusual setups.\n\n" - "Direct attach also works any time: `tmux attach -t ccbot`." - ), - "help.body.tips": ( - "*Tips*\n\n" - "• *Auto-approve.* Settings → Auto-approve auto-Yes's " - "interactive prompts that --dangerously-skip-permissions " - "doesn't already bypass (e.g. WebFetch domain trust).\n" - "• *Card edit lag.* Settings → Live lag controls how often the " - "live session card is re-edited (lower = snappier, higher = " - "less rate-limit pressure).\n" - "• *Languages.* Settings → Language: en / ru / zh.\n" - "• *Outbound proxy.* Set `TG_PROXY_URL` if the host can't reach " - "api.telegram.org directly.\n" - "• *Single instance.* Bot holds an exclusive flock on " - "`$CCBOT_DIR/ccbot.lock`; a second `uv run ccbot` refuses with " - "an error in stderr instead of fighting for Telegram updates.\n" - "• *Hook self-heal.* `SessionStart` + `UserPromptSubmit` hooks " - "both update `session_map.json` — a missed SessionStart is " - "fixed on the next prompt automatically." - ), -} - -_RU: dict[str, str] = { - "voice.not_delivered": ( - "🎙 Голос не попал в сессию — в ней открыт запрос (опрув/вопрос), " - "и распознанный текст туда не уходит. Ответь на запрос и перешли " - "голосовое ещё раз." - ), - "voice.download_failed": ( - "🎙 Голосовое не дошло до сессии: Telegram не отдал аудиофайл после " - "{attempts} попыток. Отправь голосовое ещё раз." - ), - "voice.transcription_failed": ( - "🎙 Голосовое не удалось распознать, и оно не дошло до сессии. " - "Отправь его ещё раз." - ), - "voice.transcribing": "🎙 Голосовое распознаётся…", - "voice.queued_dropped": "Последующие сообщения тоже не дошли до сессии.", - "btn.stop": "⏹ Стоп", - "btn.kill": "💀 Убить", - "btn.clear": "🧹 Очистить", - "btn.menu": "≡ Меню", - "btn.term": "🖥 Терминал", - "btn.back": "← Назад", - "btn.cancel": "× Отмена", - "btn.login": "🔐 Войти", - # Claude re-authentication (/login) - "auth.expired": ( - "🔐 *Авторизация Claude слетела*\n\n" - "Все сессии на этом хосте будут падать, пока логин не обновлён. Сам " - "бот при этом жив — он и проведёт тебя через процедуру.\n\n" - "Отправь /login (или нажми кнопку): я дам ссылку, ты подтверждаешь в " - "браузере и присылаешь код сюда." - ), - "auth.login.starting": "🔐 Запускаю процедуру логина…", - "auth.login.url": ( - "🔐 *Шаг 1/2* — открой и подтверди:\n\n" - "{url}\n\n" - "*Шаг 2/2* — на странице будет код. Пришли его сюда обычным " - "сообщением. Ссылка живёт 15 минут." - ), - "auth.login.no_url": ( - "❌ Не удалось получить ссылку логина от CLI. Попробуй /login ещё раз; " - "если повторяется — выполни `claude auth login` на хосте." - ), - "auth.login.ok": ( - "✅ *Готово.* Авторизация продлена до {deadline}.\n\n" - "Падавшие сессии заработают со следующего сообщения." - ), - "auth.login.failed": "❌ Код не принят: {detail}\n\nОтправь /login, чтобы повторить.", - "auth.login.cancelled": "Логин отменён.", - "auth.codex.device": ( - "🔐 *Авторизация Codex*\n\n" - "1. Открой {url}\n" - "2. Введи код: `{code}`\n\n" - "Бот сам увидит подтверждение; присылать код сюда не нужно. " - "Код действует около 15 минут." - ), - "auth.codex.no_device_code": ( - "❌ Codex не выдал device code. Проверь, что установлен актуальный " - "Codex CLI, и повтори /login." - ), - "auth.codex.ok": ( - "✅ *Codex авторизован.* Теперь можно создавать и возобновлять сессии." - ), - "auth.codex.failed": ( - "❌ Авторизация Codex не завершена: {detail}\n\nПовтори /login." - ), - "auth.codex.waiting": ( - "🔐 Codex всё ещё ждёт подтверждения в браузере. Используй ссылку и код выше." - ), - "auth.codex.required": ( - "🔐 Авторизуй Codex по ссылке выше, затем повтори создание сессии." - ), - "auth.codex.check_failed": ( - "❌ Не удалось проверить авторизацию Codex. Проверь `codex --version` " - "и `CODEX_COMMAND`, затем отправь /login." - ), - "auth.codex.storage_mismatch": ( - "⚠ Codex нашел `auth.json`, но effective credential storage его не " - 'читает. Установи `cli_auth_credentials_store = "file"`; бот не ' - "будет заменять существующую авторизацию." - ), - "btn.confirm": "✓ Подтвердить", - "btn.no": "× Нет", - "btn.yes_kill": "⚠ Да, убить", - "btn.yes_delete": "⚠ Да, удалить", - "btn.yes_clear": "⚠ Да, очистить", - "btn.refresh": "🔄 Обновить", - "btn.save": "Сохранено", - "btn.cancelled": "Отменено", - # Archive buttons - "btn.restore": "⤴ Восстановить", - "btn.restore_with_name": "⤴ Восстановить {name}", - "btn.inspect": "🔍 Просмотр", - "btn.open_session": "📜 {name}", - "btn.delete": "🗑 Удалить", - "btn.to_14d": "→ 14д", - "btn.to_72h": "→ 72ч", - "mm.sessions": "📋 Сессии", - "mm.status": "📊 Статус", - "mm.history": "📜 История", - "mm.shot": "🧑‍💻 Скрин", - "mm.new": "🆕 Новая", - "mm.archive": "🗄 Архив", - "mm.settings": "⚙ Настройки", - "menu.title": "*Меню*", - "menu.empty": "*Меню*\n\nАктивной сессии нет — выбери в свитчере или тапни 🆕 Новая.", - "menu.active": "*Меню* · активна: *{name}*", - "settings.title": "*Настройки*", - "settings.body": ( - "*Настройки*\n\n" - "Агент: `{agent}`\n" - "Язык: `{language}`\n" - "Лаг карточки: `{live_lag}с`\n" - "Голос: `{voice}`\n\n" - "_Тапни группу, чтобы изменить._" - ), - "settings.group.agent": "Агент", - "settings.group.language": "Язык", - "settings.group.live_lag": "Лаг карточки", - "settings.group.voice": "Голос", - "settings.lag.body": ( - "*Лаг карточки*\n\n" - "Окно сглаживания правок live-карточки.\n" - "`0с` = править на каждом событии, больше = тише в чате." - ), - "settings.voice.body": ( - "*Распознавание голоса*\n\n" - "Бэкенд для voice-сообщений.\n" - "• `auto` — Apple на macOS, whisper.cpp иначе\n" - "• `whisper` — форсить whisper.cpp\n" - "• `apple` — форсить Apple Speech (только macOS)\n" - "• `off` — игнорировать voice" - ), - "settings.agent.body": ( - "*Агент*\n\n" - "Глобальный backend для всего бота. Все новые сессии работают либо " - "через *Claude*, либо через *Codex*.\n\n" - "Переключение заблокировано, пока остаются живые сессии текущего " - "агента. Сначала заверши или архивируй их." - ), - "settings.lang.body": ( - "*Язык*\n\nЯзык интерфейса. Переключает всё,\nкроме самого вывода Claude." - ), - "list.empty": "Активных сессий нет. Тапни 🆕 Новая, чтобы создать.", - "conf.kill": ( - "Убить *{name}*?\nTmux-окно умрёт, claude session id сохранится.\n" - "Восстановить можно через архив." - ), - "conf.done": "Закрыть *{name}*?\nЦель закрыта, сессия в архиве.", - "conf.delete": ( - "Удалить *{name}* из архива?\nЗапись стирается. JSONL остаётся на диске." - ), - "conf.clear": ( - "Очистить *{name}*?\nОтправит Esc, затем /clear. Контекст сессии " - "стирается без возможности восстановления (в отличие от Kill → Restore)." - ), - "conf.killed": "💀 Убита `{name}`", - "conf.done_ok": "🎉 `{name}` закрыта.", - "conf.deleted": "🗑 Запись из архива удалена.", - "dir.title": "*Выбор рабочей директории*", - "dir.current": "Текущая: `{path}`", - "dir.empty": "_(Поддиректорий нет)_", - "dir.hint": "Тапни папку, чтобы войти, или выбери текущую", - "dir.btn.up": "..", - "dir.btn.select": "Выбрать", - "picker.title": "*Возобновить сессию?*", - "picker.summary": "стр. {page}/{pages} — {total} сессий в этой папке.", - "picker.btn.start_fresh": "🆕 С нуля", - "picker.btn.back_to_dirs": "← К папкам", - "toast.no_session": "Нет активной сессии", - "toast.window_gone": "Окно исчезло", - "toast.esc_sent": "⎋ Esc отправлен", - "toast.cleared": "🧹 Контекст очищен", - "toast.killed": "Убита", - "toast.done": "Закрыта", - "toast.deleted": "Удалена", - "toast.saved": "Сохранено", - "toast.agent_live": ( - "Перед сменой глобального агента заверши или архивируй все живые сессии." - ), - "toast.restored": "Восстановлена", - "toast.already_gone": "Уже нет", - "toast.nothing_to_kill": "Убивать нечего", - "toast.term_opened": "🖥 Терминал открыт", - "toast.invalid_page": "Неверная страница", - "toast.session_not_found": "Сессия не найдена", - "toast.restore_failed": "Не удалось восстановить: {msg}", - "toast.range_14d": "→ 14д", - "toast.range_72h": "→ 72ч", - # Archive screen - "archive.title": "Архивные сессии", - "archive.range_72h": " (0–72ч)", - "archive.range_14d": " (0–14д)", - "archive.empty": "Архивных сессий в этом окне нет.", - "archive.page_line": "стр. {page}/{pages} — всего {total}", - "archive.age.s": "{n}с назад", - "archive.age.m": "{n}мин назад", - "archive.age.h": "{n}ч назад", - "archive.age.d": "{n}д назад", - "usage.title": "*Claude Code*", - "usage.title.codex": "*OpenAI Codex*", - "usage.unavailable": "Живые данные usage недоступны.", - "usage.auth_required": ( - "Для загрузки Usage нужна авторизация Codex. Заверши вход по сообщению " - "выше, затем обнови этот экран." - ), - "usage.5h": "5ч", - "usage.week": "неделя", - "usage.week_sonnet": "неделя (Sonnet)", - "usage.not_reported": "Codex не передал", - "usage.today": "Сегодня", - "usage.today_left": "ещё", - "usage.today_overspent": "перерасход", - "usage.used": "Использовано", - "usage.reset": "Сброс", - "usage.extra": "Extra", - "usage.on": "вкл", - "usage.off": "выкл", - "usage.fetching": "Тяну usage…", - "settings.group.weekly_reset_day": "Сброс недели", - "settings.weeklyday.body": ( - "*День сброса недели*\n\n" - "День недели, в который сбрасывается недельная квота Anthropic.\n" - "Используется для расчёта %/день в weekly-строках." - ), - "day.mon": "пн", - "day.tue": "вт", - "day.wed": "ср", - "day.thu": "чт", - "day.fri": "пт", - "day.sat": "сб", - "day.sun": "вс", - "settings.group.auto_approve": "Авто-подтверждение", - "settings.approve.body": ( - "*Авто-подтверждение*\n\n" - "Как боту обращаться с интерактивными Yes/No-промптами,\n" - "которые --dangerously-skip-permissions сам не закрывает\n" - "(например, доверие домену для WebFetch):\n" - "• `off` — присылать в чат, ты тапаешь сам\n" - "• `on` — Yes на любой промпт" - ), - "approve.off": "выкл", - "approve.on": "вкл", - "settings.group.session_idle_hours": "Автоархив через", - "settings.idle_archive.body": ( - "*Автоархивация сессий*\n\n" - "Через сколько часов без активности архивировать живую сессию. " - "Архив остаётся доступен через Меню → Архив, сессию можно восстановить." - ), - "settings.value.hours": "{value} ч", - # Local terminal — 3-state (off / manual / auto). - "local.off": "выкл", - "local.manual": "по кнопке", - "local.auto": "всегда", - "settings.group.card_history": "История в карточке", - "settings.cardhist.body": ( - "*История в карточке*\n\n" - "Сколько последних end-of-turn границ подгружать в карточку\n" - "при первом доступе (после рестарта бота, тапа в свитчере или\n" - "Меню → Sessions). Глубокая история сверх этого всегда\n" - "доступна через /history независимо от значения.\n\n" - "Больше = больше истории в карточке, больше памяти на сессию." - ), - "settings.group.card_page_lines": "Размер страницы", - "settings.pagesize.body": ( - "*Размер страницы*\n\n" - "Максимум строк на одну страницу карточки. Старые события\n" - "уходят на предыдущие страницы (◀); длинный финальный ответ\n" - "режется на несколько страниц по умным границам (абзац /\n" - "строка / предложение / слово) — без обрывов посреди слова.\n" - "Допускается отклонение ±5 строк.\n\n" - "Меньше = компактнее для телефона. Больше = больше контекста\n" - "на странице, но тяжелее edits." - ), - "settings.group.card_inline_screenshots": "Скрины в карточке", - "settings.screens.body": ( - "*Скрины в карточке*\n\n" - "Когда *on* — карточка активной сессии = photo+caption:\n" - "сверху рендер pane, под ним body. Фото обновляется только\n" - "когда pane меняется, с лимитером 3с между апдейтами.\n" - "Кнопка Shot исчезает из top-row (она уже встроена).\n\n" - "*Важно:* Telegram caption ограничен 1024 char vs 4096 для\n" - "text — размер страницы уменьшается ~в 4 раза. Регулируй\n" - "через настройку Размер страницы." - ), - "screens.on": "on", - "screens.off": "off", - "settings.group.bg_notify_finished": "Bg: задача готова", - "settings.group.bg_notify_error": "Bg: ошибки", - "settings.group.bg_notify_needs_action": "Bg: нужен ввод", - "settings.bg_notify.finished.body": ( - "*Bg-сессия: задача готова*\n\n" - "Когда фоновая сессия достигает end-of-turn, шлём тихий\n" - "push ✅ [] task complete, чтобы юзер мог переключиться." - ), - "settings.bg_notify.error.body": ( - "*Bg-сессия: ошибки*\n\n" - "Push ❌ [] error когда фоновая сессия эмитит\n" - "ошибочный ивент. (Сейчас срабатывает только на явные\n" - "error-ивенты; детект исключений будет расширен.)" - ), - "settings.bg_notify.needs_action.body": ( - "*Bg-сессия: нужен ввод*\n\n" - "Push ❓ [] needs your attention когда фоновая сессия\n" - "показывает AskUserQuestion / ExitPlanMode / Permission промпт.\n" - "Иначе только ❓ бейдж в bg-panel — легко пропустить." - ), - "settings.group.haiku_naming": "Имена сессий через AI", - "settings.haiku.body": ( - "*Имена сессий через AI*\n\n" - "При *on* каждая новая сессия переименовывается после первого\n" - "пользовательского сообщения ≥20 символов одноразовым\n" - "вызовом легковесной модели (Haiku для Claude,\n" - "`CODEX_NAMING_MODEL` для Codex) — 1-3 слова в kebab-case о сути сессии\n" - "(``token-budget-alerts``, ``archive-pagination-fix``).\n" - "Сессии, переименованные вручную (``/rename``,\n" - "``/new ``), никогда не перетираются.\n\n" - "При *off* имя навсегда остаётся basename'ом директории\n" - "(``workdir``, ``workdir-2``, ``ccbot``). Нулевой расход токенов." - ), - "settings.cat.card": "🃏 Карточка / вид", - "settings.cat.notifications": "🔔 Уведомления", - "settings.cat.voice": "🎙 Голос", - "settings.cat.terminal": "🖥 Локальный терминал", - "settings.cat.behavior": "⚙ Агент, поведение и язык", - "settings.cat.card.body": ( - "*Карточка / вид*\n\nРаскладка, плотность и refresh живой карточки." - ), - "settings.cat.notifications.body": ( - "*Уведомления*\n\n" - "Bg-сессионные пуши (готово / ошибки / нужен ввод) и день\n" - "сброса для weekly-quota алертов." - ), - "settings.cat.voice.body": ("*Голос*\n\nДвижок speech-to-text для входящих voice."), - "settings.cat.terminal.body": ( - "*Локальный терминал*\n\nНативное Terminal / iTerm окно к tmux." - ), - "settings.cat.behavior.body": ( - "*Поведение и язык*\n\n" - "Глобальный агент; авто-Yes; имена через Haiku; язык интерфейса." - ), - "settings.group.local_terminal": "Локальный терминал", - "settings.local.body": ( - "*Локальный терминал*\n\n" - "Опциональное нативное окно с `tmux attach` к сессии —\n" - "удобно вести Claude руками с десктопа параллельно\n" - "с Telegram.\n\n" - "*выкл* — никогда не открывать, кнопку не показывать.\n" - "*по кнопке* — авто-спавна нет; *🖥 Терминал*\n" - "появляется рядом со *Стоп / Убить / Очистить / Меню*\n" - "когда у активной сессии терминал не аттачен.\n" - "*всегда* — спавнить при создании каждой сессии И\n" - "показывать ту же *🖥 Терминал*-кнопку, когда\n" - "терминала нет.\n\n" - "macOS: Terminal.app или iTerm2 (авто).\n" - "Linux: выбери эмулятор ниже. Тапни *Configure via Claude*\n" - "если автодетект не угадал." - ), - "settings.local.claude_help": "🪄 Настроить через Claude", - "help.home.body": ( - "*Помощь*\n\n" - "ccbot связывает этот личный чат с N параллельными сессиями " - "Claude Code в tmux. Тапни нужный раздел ниже." - ), - "help.btn.overview": "Обзор", - "help.btn.sessions": "Сессии", - "help.btn.menu": "Меню", - "help.btn.commands": "Команды", - "help.btn.voice": "Голос и файлы", - "help.btn.alerts": "Алерты", - "help.btn.terminal": "Локальный терминал", - "help.btn.tips": "Советы", - "help.body.overview": ( - "*Обзор*\n\n" - "Один личный DM, много параллельных сессий Claude Code. Любой " - "текст летит в *активную* сессию. У каждой сессии своё tmux-окно " - "и свой процесс claude — переключение активной не ставит другие " - "на паузу.\n\n" - "Инлайн-клавиатура под последним сообщением бота — это " - "переключатель сессий и ≡ Меню." - ), - "help.body.sessions": ( - "*Сессии*\n\n" - "• *Создать.* Просто отправь любой текст в пустой DM, или " - "≡ Меню → 🆕 New, выбери директорию.\n" - "• *Переключить.* Тапни кнопку сессии в инлайн-переключателе.\n" - "• *Reply-quote.* Ответь (Telegram-цитата) на сообщение бота из " - "неактивной сессии — твой текст уйдёт туда разово, без смены " - "активной.\n" - "• *Закрыть.* `/done [имя]` — отмечает сессию как готовую.\n" - "• *Idle TTL.* Автоархив через выбранные 6/12/24ч без активности.\n" - "• *Восстановить.* ≡ Меню → 📦 Archive → *Restore*." - ), - "help.body.menu": ( - "*≡ Меню*\n\n" - "Открывается через /menu или инлайн-кнопку ≡. Пункты:\n" - "• 📋 *Sessions* — переход на живую карточку активной\n" - "• 📊 *Status* — лимиты Claude Code (5ч / неделя / sonnet)\n" - "• 🧑‍💻 *Shot* — снимок терминала активной сессии\n" - "• 🆕 *New* — создать сессию через выбор директории\n" - "• 📦 *Archive* — восстановить / посмотреть / удалить\n" - "• ⚙ *Settings* — сгруппированы по Карточка / Уведомления / " - "Голос / Терминал / Поведение." - ), - "help.body.commands": ( - "*Слэш-команды*\n\n" - "Бот:\n" - "• `/menu` — открыть инлайн-меню\n" - "• `/help` — эта справка\n" - "• `/done [имя]` — архивировать сессию\n" - "• `/health` — uptime, очереди, latency, счётчики\n\n" - "Claude Code (форвардятся как есть):\n" - "• `/model` `/effort` `/clear` `/compact` `/cost` `/memory` …\n\n" - "Префикс `!` — захват вывода локальной шелл-команды и форвард." - ), - "help.body.voice": ( - "*Голос и файлы*\n\n" - "• *Голос.* Отправь голосовое — оно расшифровывается локально " - "(whisper.cpp / Apple Speech) и уходит в активную сессию как " - "текст.\n" - "• *Фото / документ.* Кладётся в `/.ccbot-inbox/`, " - "Claude получает относительный путь (с caption-префиксом, если " - "он был). TTL 24ч; Telegram `file_id` хранится 30д для " - "`/restore-file`." - ), - "help.body.alerts": ( - "*Алерты*\n\n" - "*Квоты Claude Code.* 5ч / неделя / неделя Sonnet — бот опрашивает " - "живой `/usage` каждые 10 мин и пушит при пересечении 50, 75, 90 %.\n\n" - "*Пуши по фоновым сессиям.* Settings → Уведомления, три " - "независимых тумблера (все по умолчанию on):\n" - "• ✅ task complete\n" - "• ❌ error\n" - "• ❓ needs your attention (интерактивный prompt)\n" - "Активная сессия не пушит — она дописывает свою live-карточку.\n\n" - "*Заполнение контекста.* На карточке у каждой сессии есть " - "``context: N%``. Для Codex используются точные token usage и размер " - "окна из rollout. Для Claude это оценка из JSONL: input + cache_read " - "последнего assistant-turn относительно окна модели." - ), - "help.body.terminal": ( - "*Локальный терминал*\n\n" - "Settings → Local terminal: при *on* каждая новая сессия " - "автоматически открывает нативное окно, уже привязанное к её " - "tmux-window — управляй с десктопа параллельно с Telegram.\n\n" - "macOS: Terminal.app / iTerm2 (auto, предпочитает вкладки в iTerm).\n" - "Linux: выбор эмулятора из списка, либо *Configure via Claude* " - "для нестандартных кейсов.\n\n" - "В любой момент работает прямой `tmux attach -t ccbot`." - ), - "help.body.tips": ( - "*Советы*\n\n" - "• *Auto-approve.* Settings → Auto-approve авто-Yes-ит модалки, " - "которые --dangerously-skip-permissions не закрывает сам " - "(WebFetch domain trust и т.п.).\n" - "• *Live lag.* Settings → Live lag — частота перерисовки " - "карточки сессии. Меньше = шустрее, больше = меньше rate-limit.\n" - "• *Языки.* Settings → Language: en / ru / zh.\n" - "• *Outbound proxy.* `TG_PROXY_URL` если api.telegram.org " - "недоступен напрямую.\n" - "• *Один инстанс.* Бот держит exclusive flock на " - "`$CCBOT_DIR/ccbot.lock`; второй `uv run ccbot` откажется " - "стартовать с ошибкой в stderr, не подерётся за Telegram updates.\n" - "• *Self-heal хук.* `SessionStart` + `UserPromptSubmit` оба " - "обновляют `session_map.json` — пропущенный SessionStart " - "автоматически чинится при следующем prompt'е." - ), -} - -_ZH: dict[str, str] = { - "voice.not_delivered": ( - "🎙 语音未送达会话 —— 会话中有待处理的提示(授权/提问)," - "转写文本无法送入。请先回应该提示,然后重新发送语音消息。" - ), - "voice.download_failed": ( - "🎙 语音未送达会话:Telegram 在 {attempts} 次尝试后仍无法提供音频文件。" - "请重新发送语音消息。" - ), - "voice.transcription_failed": "🎙 语音无法识别且未送达会话。请重新发送。", - "voice.transcribing": "🎙 正在转写语音消息…", - "voice.queued_dropped": "之后发送的消息也未送达会话。", - "btn.stop": "⏹ 停止", - "btn.kill": "💀 终止", - "btn.clear": "🧹 清空", - "btn.menu": "≡ 菜单", - "btn.term": "🖥 终端", - "btn.back": "← 返回", - "btn.cancel": "× 取消", - "btn.login": "🔐 登录", - # Claude re-authentication (/login) - "auth.expired": ( - "🔐 *Claude 授权已失效*\n\n" - "在重新登录之前,这台主机上的所有会话都会报错。机器人本身没事 —— " - "它可以带你走完流程。\n\n" - "发送 /login(或点下面的按钮):我给你链接,你在浏览器里确认," - "然后把码发回这里。" - ), - "auth.login.starting": "🔐 正在启动登录流程…", - "auth.login.url": ( - "🔐 *第 1/2 步* —— 打开并确认:\n\n" - "{url}\n\n" - "*第 2/2 步* —— 页面会显示一个码。把它作为普通消息发到这里。" - "链接 15 分钟内有效。" - ), - "auth.login.no_url": ( - "❌ 没能从 CLI 拿到登录链接。再试一次 /login;如果一直失败," - "请在主机上执行 `claude auth login`。" - ), - "auth.login.ok": ( - "✅ *已登录。* 授权已延长到 {deadline}。\n\n" - "之前报错的会话在下一条消息就会恢复。" - ), - "auth.login.failed": "❌ 验证码未被接受:{detail}\n\n发送 /login 重试。", - "auth.login.cancelled": "已取消登录。", - "auth.codex.device": ( - "🔐 *Codex 登录*\n\n" - "1. 打开 {url}\n" - "2. 输入代码: `{code}`\n\n" - "机器人会自动检测授权;无需把代码发到这里。代码约 15 分钟内有效。" - ), - "auth.codex.no_device_code": ( - "❌ Codex 未提供设备代码。请确认已安装最新 Codex CLI,然后发送 /login 重试。" - ), - "auth.codex.ok": "✅ *Codex 已授权。* 现在可以创建和恢复会话。", - "auth.codex.failed": "❌ Codex 登录失败:{detail}\n\n发送 /login 重试。", - "auth.codex.waiting": "🔐 Codex 仍在等待浏览器确认。请使用上面的链接和代码。", - "auth.codex.required": "🔐 请先通过上面的链接授权 Codex,然后重新创建会话。", - "auth.codex.check_failed": ( - "❌ 无法检查 Codex 授权。请检查 `codex --version` 和 " - "`CODEX_COMMAND`,然后发送 /login。" - ), - "auth.codex.storage_mismatch": ( - "⚠ Codex 找到了 `auth.json`,但当前凭据存储不会读取它。请设置 " - '`cli_auth_credentials_store = "file"`;机器人不会替换现有授权。' - ), - "btn.confirm": "✓ 确认", - "btn.no": "× 否", - "btn.yes_kill": "⚠ 是,终止", - "btn.yes_delete": "⚠ 是,删除", - "btn.yes_clear": "⚠ 是,清空", - "btn.refresh": "🔄 刷新", - "btn.save": "已保存", - "btn.cancelled": "已取消", - # Archive buttons - "btn.restore": "⤴ 恢复", - "btn.restore_with_name": "⤴ 恢复 {name}", - "btn.inspect": "🔍 查看", - "btn.open_session": "📜 {name}", - "btn.delete": "🗑 删除", - "btn.to_14d": "→ 14天", - "btn.to_72h": "→ 72时", - "mm.sessions": "📋 会话", - "mm.status": "📊 状态", - "mm.history": "📜 历史", - "mm.shot": "🧑‍💻 截图", - "mm.new": "🆕 新建", - "mm.archive": "🗄 归档", - "mm.settings": "⚙ 设置", - "menu.title": "*菜单*", - "menu.empty": "*菜单*\n\n无活动会话——从切换器选一个或点 🆕 新建。", - "menu.active": "*菜单* · 活动: *{name}*", - "settings.title": "*设置*", - "settings.body": ( - "*设置*\n\n" - "代理: `{agent}`\n" - "语言: `{language}`\n" - "卡片延迟: `{live_lag}秒`\n" - "语音: `{voice}`\n\n" - "_点击分组进行更改。_" - ), - "settings.group.agent": "代理", - "settings.group.language": "语言", - "settings.group.live_lag": "卡片延迟", - "settings.group.voice": "语音", - "settings.lag.body": ( - "*实时预览延迟*\n\n" - "实时卡片编辑的合并窗口。\n" - "`0秒` = 每个事件都更新,数值越高越安静。" - ), - "settings.voice.body": ( - "*语音识别*\n\n" - "语音消息使用的后端。\n" - "• `auto` — macOS 用 Apple, 其他用 whisper.cpp\n" - "• `whisper` — 强制 whisper.cpp\n" - "• `apple` — 强制 Apple Speech (仅 macOS)\n" - "• `off` — 忽略语音" - ), - "settings.agent.body": ( - "*代理*\n\n" - "整个机器人的全局后端。所有新会话统一使用 *Claude* 或 *Codex*。\n\n" - "当前后端仍有活动会话时不能切换;请先结束或归档这些会话。" - ), - "settings.lang.body": "*语言*\n\n界面语言。切换除 Claude 自身输出外的一切文本。", - "list.empty": "没有活动会话。点 🆕 新建以创建。", - "conf.kill": ( - "终止 *{name}*?\nTmux 窗口结束,claude session id 已保存。\n可通过归档列表恢复。" - ), - "conf.done": "标记 *{name}* 为完成?\n目标已关闭,会话已归档。", - "conf.delete": "从归档中删除 *{name}*?\n状态记录消失。JSONL 保留在磁盘。", - "conf.clear": ( - "清空 *{name}*?\n先发送 Esc,然后 /clear。会话上下文将被擦除," - "无法恢复(不同于 Kill → Restore)。" - ), - "conf.killed": "💀 已终止 `{name}`", - "conf.done_ok": "🎉 `{name}` 已标记完成。", - "conf.deleted": "🗑 归档记录已删除。", - "dir.title": "*选择工作目录*", - "dir.current": "当前: `{path}`", - "dir.empty": "_(无子目录)_", - "dir.hint": "点文件夹进入,或选择当前目录", - "dir.btn.up": "..", - "dir.btn.select": "选择", - "picker.title": "*恢复会话?*", - "picker.summary": "第 {page}/{pages} 页 — 此目录共 {total} 个会话。", - "picker.btn.start_fresh": "🆕 从零开始", - "picker.btn.back_to_dirs": "← 返回目录", - "toast.no_session": "无活动会话", - "toast.window_gone": "窗口已消失", - "toast.esc_sent": "⎋ 已发送 Esc", - "toast.cleared": "🧹 上下文已清空", - "toast.killed": "已终止", - "toast.done": "已完成", - "toast.deleted": "已删除", - "toast.saved": "已保存", - "toast.agent_live": "切换全局代理前,请先结束或归档所有活动会话。", - "toast.restored": "已恢复", - "toast.already_gone": "已不存在", - "toast.nothing_to_kill": "没什么可终止的", - "toast.term_opened": "🖥 已打开终端", - "toast.invalid_page": "页面无效", - "toast.session_not_found": "未找到会话", - "toast.restore_failed": "恢复失败:{msg}", - "toast.range_14d": "→ 14天", - "toast.range_72h": "→ 72时", - # Archive screen - "archive.title": "已归档会话", - "archive.range_72h": "(0–72时)", - "archive.range_14d": "(0–14天)", - "archive.empty": "此范围内没有已归档会话。", - "archive.page_line": "第 {page}/{pages} 页 — 共 {total}", - "archive.age.s": "{n}秒前", - "archive.age.m": "{n}分前", - "archive.age.h": "{n}时前", - "archive.age.d": "{n}天前", - "usage.title": "*Claude Code*", - "usage.title.codex": "*OpenAI Codex*", - "usage.unavailable": "实时使用数据不可用。", - "usage.auth_required": "加载 Usage 需要 Codex 授权。请完成上方登录,然后刷新此页面。", - "usage.5h": "5小时", - "usage.week": "本周", - "usage.week_sonnet": "本周 (Sonnet)", - "usage.not_reported": "Codex 未报告", - "usage.today": "今天", - "usage.today_left": "还可用", - "usage.today_overspent": "超出", - "usage.used": "已使用", - "usage.reset": "重置", - "usage.extra": "Extra", - "usage.on": "开", - "usage.off": "关", - "usage.fetching": "正在获取使用情况…", - "settings.group.weekly_reset_day": "周重置", - "settings.weeklyday.body": ( - "*每周重置日*\n\n" - "Anthropic 周配额重置的星期。\n" - "用于计算 weekly 行的 %/天 消耗速率。" - ), - "day.mon": "一", - "day.tue": "二", - "day.wed": "三", - "day.thu": "四", - "day.fri": "五", - "day.sat": "六", - "day.sun": "日", - "settings.group.auto_approve": "自动同意", - "settings.approve.body": ( - "*自动同意*\n\n" - "对 --dangerously-skip-permissions 未覆盖的\n" - "Claude Code 交互式 Yes/No 提示的处理方式\n" - "(例如 WebFetch 域名信任):\n" - "• `off` — 推送到聊天,手动点击\n" - "• `on` — 所有提示自动 Yes" - ), - "approve.off": "关", - "approve.on": "开", - "settings.group.session_idle_hours": "自动归档时间", - "settings.idle_archive.body": ( - "*会话自动归档*\n\n" - "实时会话无活动达到所选小时数后自动归档。" - "归档会话仍可通过菜单 → 归档恢复。" - ), - "settings.value.hours": "{value}小时", - # Local terminal — 3-state (off / manual / auto). - "local.off": "关", - "local.manual": "按钮", - "local.auto": "总是", - "settings.group.card_history": "卡片历史", - "settings.cardhist.body": ( - "*卡片历史*\n\n" - "首次访问时(机器人重启 / 切换器点击 / 菜单→Sessions)\n" - "从 JSONL 转录加载多少最近的 end-of-turn 边界。\n" - "更深的历史始终通过 /history 访问,与该值无关。\n\n" - "更多 = 卡片内更多历史,每会话占用更多内存。" - ), - "settings.group.card_page_lines": "页面大小", - "settings.pagesize.body": ( - "*页面大小*\n\n" - "卡片单页最大行数。较旧事件落到前面的页面(◀);\n" - "较长的最终回答按智能边界(段落 / 行 / 句子 / 单词)\n" - "拆分多页 — 不会在单词中间断开。允许 ±5 行偏差。\n\n" - "更小 = 手机视图更紧凑。更大 = 单页更多上下文,\n" - "但 edit 消息更重。" - ), - "settings.group.card_inline_screenshots": "卡片内嵌截图", - "settings.screens.body": ( - "*卡片内嵌截图*\n\n" - "*开启* 时,活动会话卡片 = photo+caption:照片为 pane\n" - "渲染,标题为正文。仅当 pane 变化时刷新照片,3 秒节流。\n" - "Shot 按钮从顶部消失(已内嵌)。\n\n" - "*注意:* Telegram caption 限制 1024 字符 vs text 4096 —\n" - "页面大小有效缩小 ~4 倍。可用「页面大小」设置补偿。" - ), - "screens.on": "开", - "screens.off": "关", - "settings.group.bg_notify_finished": "Bg:任务完成", - "settings.group.bg_notify_error": "Bg:错误", - "settings.group.bg_notify_needs_action": "Bg:需要操作", - "settings.bg_notify.finished.body": ( - "*Bg 会话:任务完成*\n\n" - "后台会话进入 end-of-turn 时,推送 ✅ [] task complete。" - ), - "settings.bg_notify.error.body": ( - "*Bg 会话:错误*\n\n后台会话发出错误事件时,推送 ❌ [] error。" - ), - "settings.bg_notify.needs_action.body": ( - "*Bg 会话:需要操作*\n\n" - "后台会话显示 AskUserQuestion / ExitPlanMode / Permission\n" - "提示时,推送 ❓ [] needs your attention。" - ), - "settings.cat.card": "🃏 卡片 / 视图", - "settings.cat.notifications": "🔔 通知", - "settings.cat.voice": "🎙 语音", - "settings.cat.terminal": "🖥 本地终端", - "settings.cat.behavior": "⚙ 代理、行为和语言", - "settings.cat.card.body": "*卡片 / 视图*\n\n实时会话卡片的布局、密度和刷新。", - "settings.cat.notifications.body": ( - "*通知*\n\nBg 会话推送(完成 / 错误 / 需要操作)和\nweekly quota 提醒的重置日。" - ), - "settings.cat.voice.body": "*语音*\n\n语音消息的 STT 后端。", - "settings.cat.terminal.body": "*本地终端*\n\n附加到每个新会话的本地终端窗口。", - "settings.cat.behavior.body": ( - "*行为和语言*\n\n全局代理;自动同意交互提示;界面语言。" - ), - "settings.group.local_terminal": "本地终端", - "settings.local.body": ( - "*本地终端*\n\n" - "可选的本地终端,附加到会话的 tmux 窗口 ——\n" - "便于在桌面手动操作 Claude,与 Telegram 并行。\n\n" - "*关* — 从不打开,不显示按钮。\n" - "*按钮* — 不自动打开;当活动会话未附加终端时,\n" - "*🖥 终端* 出现在 *停止 / 终止 / 清空 / 菜单* 旁边。\n" - "*总是* — 每个新会话都自动打开,同时在未附加\n" - "终端时显示相同的 *🖥 终端* 按钮。\n\n" - "macOS:Terminal.app 或 iTerm2(自动)。\n" - "Linux:在下方选择终端模拟器。如果自动检测\n" - "不符合实际环境,请点击 *Configure via Claude*。" - ), - "settings.local.claude_help": "🪄 通过 Claude 配置", - "help.home.body": ( - "*帮助*\n\n" - "ccbot 将这个私聊连接到 N 个并行运行在 tmux 中的\n" - "Claude Code 会话。点击下方对应章节查看简介。" - ), - "help.btn.overview": "概览", - "help.btn.sessions": "会话", - "help.btn.menu": "菜单", - "help.btn.commands": "命令", - "help.btn.voice": "语音和文件", - "help.btn.alerts": "提醒", - "help.btn.terminal": "本地终端", - "help.btn.tips": "技巧", - "help.body.overview": ( - "*概览*\n\n" - "一个私聊,多个并行的 Claude Code 会话。任何文本会发送到\n" - "当前的 *活动* 会话。每个会话拥有独立的 tmux 窗口和 claude\n" - "进程,切换活动会话不会暂停其他会话。\n\n" - "最新机器人消息下方的内联键盘是会话切换器和 ≡ 菜单。" - ), - "help.body.sessions": ( - "*会话*\n\n" - "• *创建。* 在空 DM 中发送任意文本,或 ≡ 菜单 → 🆕 New,\n" - "选择一个目录。\n" - "• *切换。* 点击切换器中的会话按钮。\n" - "• *引用回复。* 回复非活动会话的机器人消息 — 你的文本\n" - "只单次路由到该会话,不更改活动状态。\n" - "• *完成。* `/done [name]` — 标记并归档。\n" - "• *闲置 TTL。* 无活动达到所选 6/12/24 小时后自动归档。\n" - "• *恢复。* ≡ 菜单 → 📦 Archive → *Restore*。" - ), - "help.body.menu": ( - "*≡ 菜单*\n\n" - "通过 /menu 或 ≡ 菜单内联按钮打开:\n" - "• 📋 *Sessions* — 跳转到当前会话的实时卡片\n" - "• 📊 *Status* — 5h / 周 / sonnet 配额\n" - "• 🧑‍💻 *Shot* — 当前会话的终端快照\n" - "• 🆕 *New* — 通过目录浏览器创建会话\n" - "• 📦 *Archive* — 恢复 / 查看 / 删除\n" - "• ⚙ *Settings* — 按 卡片 / 通知 / 语音 / 终端 / 行为 分组。" - ), - "help.body.commands": ( - "*斜杠命令*\n\n" - "Bot 端:\n" - "• `/menu` — 打开内联菜单\n" - "• `/help` — 本帮助\n" - "• `/done [name]` — 归档会话\n" - "• `/health` — 运行时间 / 队列 / 延迟 / 计数器\n\n" - "Claude Code 透传(原样转发):\n" - "• `/model` `/effort` `/clear` `/compact` `/cost` `/memory` …\n\n" - "前缀 `!` — 捕获本地 shell 命令的输出并转发。" - ), - "help.body.voice": ( - "*语音和文件*\n\n" - "• *语音。* 发送语音消息 — 在本地转写\n" - "(whisper.cpp / Apple Speech)然后作为文本发送给活动会话。\n" - "• *照片 / 文档。* 落到 `/.ccbot-inbox/`,Claude 收到\n" - "相对路径(如果你附带 caption,会作为前缀)。TTL 24 小时;\n" - "Telegram `file_id` 保留 30 天用于 `/restore-file`。" - ), - "help.body.alerts": ( - "*提醒*\n\n" - "*配额提醒。* 5h / 周 / 周-Sonnet 配额 — 机器人每 10 分钟轮询\n" - "实时 `/usage` 弹窗,百分比跨过 50 / 75 / 90 时推送。\n\n" - "*后台会话推送。* Settings → 通知 三个独立开关(默认全部 on):\n" - "• ✅ task complete\n" - "• ❌ error\n" - "• ❓ needs your attention (交互式提示)\n" - "活动会话不推送 — 直接更新它的实时卡片。\n\n" - "*上下文占用。* 卡片每个会话显示 ``context: N%``。Codex 使用\n" - "rollout 中准确的 token usage 和模型窗口;Claude 使用 JSONL\n" - "估算(最近一次 assistant turn 的 input + cache_read 除以模型窗口)。" - ), - "help.body.terminal": ( - "*本地终端*\n\n" - "Settings → Local terminal:开启后,每次新建会话也会弹出\n" - "本地原生窗口,自动 attach 到对应 tmux 窗口 —\n" - "桌面手动操作和 Telegram 并行。\n\n" - "macOS:Terminal.app / iTerm2(自动,iTerm 优先用 tab)。\n" - "Linux:从自动检测列表选择,或 *Configure via Claude*\n" - "处理特殊环境。\n\n" - "随时也可直接 `tmux attach -t ccbot`。" - ), - "help.body.tips": ( - "*技巧*\n\n" - "• *自动同意。* Settings → Auto-approve 自动 Yes\n" - "--dangerously-skip-permissions 未覆盖的提示。\n" - "• *Live lag。* Settings → Live lag — 会话卡片重绘频率,\n" - "更小 = 更灵敏,更大 = 更省 rate-limit。\n" - "• *语言。* Settings → Language:en / ru / zh。\n" - "• *出站代理。* `TG_PROXY_URL` 如果主机无法\n" - "直接访问 api.telegram.org。\n" - "• *单实例锁。* bot 在 `$CCBOT_DIR/ccbot.lock` 持独占 flock;\n" - "第二个 `uv run ccbot` 会拒绝启动并在 stderr 报错,\n" - "不会和原实例争抢 Telegram updates。\n" - "• *Hook 自愈。* `SessionStart` + `UserPromptSubmit` 都会更新\n" - "`session_map.json` — 错过的 SessionStart 在下一个 prompt 自动修复。" - ), -} - TRANSLATIONS: dict[str, dict[str, str]] = { "en": _EN, "ru": _RU, @@ -1397,7 +28,7 @@ def get_user_lang(user_id: int) -> str: - """Resolve the user's language code, falling back to 'en'.""" + """Resolve the user's language code, falling back to ``en``.""" settings = session_manager.get_user_settings(user_id) code = settings.get("language", "en") if code not in TRANSLATIONS: @@ -1406,10 +37,7 @@ def get_user_lang(user_id: int) -> str: def t(user_id: int, key: str, **fmt: Any) -> str: - """Translate `key` for the user. Falls back to English on missing key. - - `fmt` kwargs are passed to str.format on the resolved template. - """ + """Translate ``key`` for the user and apply optional formatting.""" lang = get_user_lang(user_id) table = TRANSLATIONS.get(lang) or _EN template = table.get(key) or _EN.get(key) or key diff --git a/src/ccbot/i18n_locales/__init__.py b/src/ccbot/i18n_locales/__init__.py new file mode 100644 index 00000000..49fe87e8 --- /dev/null +++ b/src/ccbot/i18n_locales/__init__.py @@ -0,0 +1,11 @@ +"""Translation tables used by :mod:`ccbot.i18n`. + +Locale modules contain data only; language selection and fallback behavior stay +in the stable ``ccbot.i18n`` facade. +""" + +from .en import EN +from .ru import RU +from .zh import ZH + +__all__ = ["EN", "RU", "ZH"] diff --git a/src/ccbot/i18n_locales/en.py b/src/ccbot/i18n_locales/en.py new file mode 100644 index 00000000..2e56ec65 --- /dev/null +++ b/src/ccbot/i18n_locales/en.py @@ -0,0 +1,502 @@ +"""English source translation table for the Telegram UI.""" + +from __future__ import annotations + +EN: dict[str, str] = { + # Voice delivery + "voice.not_delivered": ( + "🎙 Voice didn't reach the session — it has an open prompt " + "(permission/question), so the transcribed text can't go in. " + "Answer the prompt and resend the voice message." + ), + "voice.download_failed": ( + "🎙 Voice didn't reach the session: Telegram couldn't provide the " + "audio file after {attempts} attempts. Please resend the voice message." + ), + "voice.transcription_failed": ( + "🎙 The voice message couldn't be recognized and didn't reach the " + "session. Please send it again." + ), + "voice.transcribing": "🎙 Voice message is being transcribed…", + "voice.queued_dropped": "Messages sent after it didn't reach the session either.", + # Footer buttons + "btn.stop": "⏹ Stop", + "btn.kill": "💀 Kill", + "btn.clear": "🧹 Clear", + "btn.menu": "≡ Menu", + "btn.term": "🖥 Term", + "btn.back": "← Back", + "btn.cancel": "× Cancel", + "btn.login": "🔐 Log in", + # Claude re-authentication (/login) + "auth.expired": ( + "🔐 *Claude authorization expired*\n\n" + "Every session on this host will keep failing until the login is " + "renewed. The bot itself is fine — it can walk you through it.\n\n" + "Send /login (or tap below): I hand you a link, you approve it in the " + "browser and send the code back here." + ), + "auth.login.starting": "🔐 Starting the login exchange…", + "auth.login.url": ( + "🔐 *Step 1/2* — open this and approve:\n\n" + "{url}\n\n" + "*Step 2/2* — the page shows a code. Send it here as a normal " + "message. The link is valid for 15 minutes." + ), + "auth.login.no_url": ( + "❌ Could not get a login URL from the CLI. Try /login again; if it " + "keeps failing, run `claude auth login` on the host." + ), + "auth.login.ok": ( + "✅ *Logged in.* Authorization renewed until {deadline}.\n\n" + "Sessions that were failing will work on the next message." + ), + "auth.login.failed": "❌ The code was not accepted: {detail}\n\nSend /login to retry.", + "auth.login.cancelled": "Login cancelled.", + "auth.codex.device": ( + "🔐 *Codex sign-in*\n\n" + "1. Open {url}\n" + "2. Enter this code: `{code}`\n\n" + "The bot will detect approval automatically; don't send the code here. " + "The code is valid for about 15 minutes." + ), + "auth.codex.no_device_code": ( + "❌ Codex did not provide a device code. Check that a current Codex CLI " + "is installed and run /login to retry." + ), + "auth.codex.ok": ( + "✅ *Codex is authorized.* You can create and resume sessions now." + ), + "auth.codex.failed": "❌ Codex sign-in failed: {detail}\n\nSend /login to retry.", + "auth.codex.waiting": ( + "🔐 Codex is still waiting for browser approval. Use the link and code above." + ), + "auth.codex.required": ( + "🔐 Authorize Codex using the link above, then retry creating the session." + ), + "auth.codex.check_failed": ( + "❌ Could not check Codex authorization. Verify `codex --version` and " + "`CODEX_COMMAND`, then send /login." + ), + "auth.codex.storage_mismatch": ( + "⚠ Codex found `auth.json`, but its effective credential storage does " + 'not read it. Set `cli_auth_credentials_store = "file"`; the bot ' + "will not replace the existing authorization." + ), + "btn.confirm": "✓ Confirm", + "btn.no": "× No", + "btn.yes_kill": "⚠ Yes, kill", + "btn.yes_delete": "⚠ Yes, delete", + "btn.yes_clear": "⚠ Yes, clear", + "btn.refresh": "🔄 Refresh", + "btn.save": "Saved", + "btn.cancelled": "Cancelled", + # Archive buttons + "btn.restore": "⤴ Restore", + "btn.restore_with_name": "⤴ Restore {name}", + "btn.inspect": "🔍 Inspect", + "btn.open_session": "📜 {name}", + "btn.delete": "🗑 Delete", + "btn.to_14d": "→ 14d", + "btn.to_72h": "→ 72h", + # More menu + "mm.sessions": "📋 Sessions", + "mm.status": "📊 Status", + "mm.history": "📜 History", + "mm.shot": "🧑‍💻 Shot", + "mm.new": "🆕 New", + "mm.archive": "🗄 Archive", + "mm.settings": "⚙ Settings", + # Menu screen body + "menu.title": "*Menu*", + "menu.empty": "*Menu*\n\nNo active session — pick one from the switcher or tap 🆕 New.", + "menu.active": "*Menu* · active: *{name}*", + # Settings — top + "settings.title": "*Settings*", + "settings.body": ( + "*Settings*\n\n" + "Agent: `{agent}`\n" + "Language: `{language}`\n" + "Live lag: `{live_lag}s`\n" + "Voice: `{voice}`\n\n" + "_Tap a group to change._" + ), + # Settings — group labels (in the main grid) + "settings.group.agent": "Agent", + "settings.group.language": "Language", + "settings.group.live_lag": "Live lag", + "settings.group.voice": "Voice", + # Settings — group sub-screen descriptions + "settings.lag.body": ( + "*Live preview lag*\n\n" + "Coalescing window for live-card edits.\n" + "`0s` = update on every event, higher = quieter chat." + ), + "settings.voice.body": ( + "*Voice transcription*\n\n" + "Backend used for voice messages.\n" + "• `auto` — Apple on macOS, whisper.cpp elsewhere\n" + "• `whisper` — force whisper.cpp\n" + "• `apple` — force Apple Speech (macOS only)\n" + "• `off` — drop voice messages" + ), + "settings.agent.body": ( + "*Agent*\n\n" + "Global backend for the entire bot. All new sessions use either " + "*Claude* or *Codex*.\n\n" + "Switching is blocked while sessions from the current backend are " + "still live. Archive or kill them first." + ), + "settings.lang.body": "*Language*\n\nUI language. Switches everything\nbut Claude's own output.", + # Sessions list — only ``list.empty`` is still used (Menu → Sessions + # empty-state when there's no active session). ``list.active`` / + # ``list.lost`` are legacy. + "list.empty": "No live sessions. Use 🆕 New to create one.", + # Confirm dialogs + "conf.kill": ( + "Kill *{name}*?\nTmux window dies, claude session id stored.\n" + "Restore via the archive list." + ), + "conf.done": "Mark *{name}* as done?\nGoal closed, session archived.", + "conf.delete": ( + "Delete *{name}* from archive?\nState record gone. JSONL kept on disk." + ), + "conf.clear": ( + "Clear *{name}*?\nSends Esc then /clear. Session context wiped — " + "cannot be undone (unlike Kill → Restore)." + ), + "conf.killed": "💀 Killed `{name}`", + "conf.done_ok": "🎉 Marked `{name}` as done.", + "conf.deleted": "🗑 Archive entry deleted.", + # Directory browser + "dir.title": "*Select Working Directory*", + "dir.current": "Current: `{path}`", + "dir.empty": "_(No subdirectories)_", + "dir.hint": "Tap a folder to enter, or select current directory", + "dir.btn.up": "..", + "dir.btn.select": "Select", + # Session picker + "picker.title": "*Resume Session?*", + "picker.summary": "page {page}/{pages} — {total} session(s) in this directory.", + "picker.btn.start_fresh": "🆕 Start fresh", + "picker.btn.back_to_dirs": "← Back to dirs", + # Inline toasts + "toast.no_session": "No active session", + "toast.window_gone": "Window gone", + "toast.esc_sent": "⎋ Esc sent", + "toast.cleared": "🧹 Context cleared", + "toast.killed": "Killed", + "toast.done": "Done", + "toast.deleted": "Deleted", + "toast.saved": "Saved", + "toast.agent_live": ( + "Archive or kill all live sessions before switching the global agent." + ), + "toast.restored": "Restored", + "toast.already_gone": "Already gone", + "toast.nothing_to_kill": "Nothing to kill", + "toast.term_opened": "🖥 Terminal opened", + "toast.invalid_page": "Invalid page", + "toast.session_not_found": "Session not found", + "toast.restore_failed": "Restore failed: {msg}", + "toast.range_14d": "→ 14d", + "toast.range_72h": "→ 72h", + # Archive screen + "archive.title": "Archived sessions", + "archive.range_72h": " (0–72h)", + "archive.range_14d": " (0–14d)", + "archive.empty": "No archived sessions in this window.", + "archive.page_line": "page {page}/{pages} — {total} total", + "archive.age.s": "{n}s ago", + "archive.age.m": "{n}m ago", + "archive.age.h": "{n}h ago", + "archive.age.d": "{n}d ago", + # /usage compact display + "usage.title": "*Claude Code*", + "usage.title.codex": "*OpenAI Codex*", + "usage.unavailable": "Live usage unavailable.", + "usage.auth_required": ( + "Codex authorization is required to load Usage. Complete the sign-in " + "sent above, then refresh this screen." + ), + "usage.5h": "5h", + "usage.week": "week", + "usage.week_sonnet": "week (Sonnet)", + "usage.not_reported": "not reported by Codex", + "usage.today": "Today", + "usage.today_left": "another", + "usage.today_overspent": "over by", + "usage.used": "Used", + "usage.reset": "Reset", + "usage.extra": "Extra", + "usage.on": "on", + "usage.off": "off", + "usage.fetching": "Fetching usage…", + # Settings group: weekly reset day + "settings.group.weekly_reset_day": "Weekly reset", + "settings.weeklyday.body": ( + "*Weekly reset day*\n\n" + "Day of week the Anthropic weekly window resets.\n" + "Used to compute the %/day burn rate on the weekly rows." + ), + "day.mon": "Mon", + "day.tue": "Tue", + "day.wed": "Wed", + "day.thu": "Thu", + "day.fri": "Fri", + "day.sat": "Sat", + "day.sun": "Sun", + # Settings group: auto-approve interactive prompts + "settings.group.auto_approve": "Auto-approve", + "settings.approve.body": ( + "*Auto-approve*\n\n" + "Bot's response to Claude Code's interactive Yes/No prompts\n" + "that --dangerously-skip-permissions doesn't already bypass\n" + "(e.g. WebFetch per-domain trust):\n" + "• `off` — surface in chat, you tap manually\n" + "• `on` — auto-Yes on every prompt" + ), + "approve.off": "off", + "approve.on": "on", + "settings.group.session_idle_hours": "Auto-archive after", + "settings.idle_archive.body": ( + "*Session auto-archive*\n\n" + "Archive a live session after this many hours without activity. " + "Archived sessions remain available through Menu → Archive and can be restored." + ), + "settings.value.hours": "{value}h", + # Local terminal — 3-state (off / manual / auto). + "local.off": "off", + "local.manual": "manual", + "local.auto": "auto", + # Settings group: how many recent end_turn boundaries to seed into a + # fresh live card from the JSONL transcript. + "settings.group.card_history": "Card history", + "settings.cardhist.body": ( + "*Card history*\n\n" + "How many recent end-of-turn boundaries to load into the live " + "card on first access (after a bot restart, switcher tap, or " + "Menu → Sessions). Deep history beyond this stays accessible " + "via /history regardless of the chosen value.\n\n" + "Higher = more scrollback in the card, more memory per session." + ), + "settings.group.card_page_lines": "Page size", + "settings.pagesize.body": ( + "*Page size*\n\n" + "Max lines on one card page. Older events drop to previous " + "pages (◀); a long final answer is chunked across multiple " + "pages with smart paragraph / sentence boundaries — no breaks " + "mid-word. ±5 lines tolerance.\n\n" + "Smaller = compact phone view. Larger = more context per page " + "but heavier message edits." + ), + "settings.group.card_inline_screenshots": "Inline screenshots", + "settings.screens.body": ( + "*Inline screenshots*\n\n" + "When *on*, the terminal pane appears only while the turn is " + "*RUNNING*: body → gap → pane → gap → context → background. " + "It disappears on *IDLE*, final answer, or /clear, and returns " + "when the next turn starts. Pane changes are throttled to ~3 sec.\n\n" + "Rich Bot API keeps text and media in one message; older servers " + "use photo + caption. Failed sends fall back to legacy photo, then " + "text-only. Transient edits retry on the next update; a lost card " + "is recreated without an immediate duplicate.\n\n" + "A silent unfinished active turn keeps the pane without a warning " + "push; a background one is marked only with ⚠️ in the background panel.\n\n" + "When *off*, the card keeps its normal text-only flow and Shot " + "remains available from the top-row terminal button." + ), + "screens.on": "on", + "screens.off": "off", + # Bg notifications (Task #42) — three independent toggles. + "settings.group.bg_notify_finished": "Bg: task complete", + "settings.group.bg_notify_error": "Bg: errors", + "settings.group.bg_notify_needs_action": "Bg: needs action", + "settings.bg_notify.finished.body": ( + "*Bg session: task complete*\n\n" + "When a background session reaches end-of-turn, push a quiet " + "notification ✅ [] task complete so you can switch in." + ), + "settings.bg_notify.error.body": ( + "*Bg session: errors*\n\n" + "Push ❌ [] error when a background session emits an " + "error event. (Currently fires only on explicit error events; " + "exception detection is being extended.)" + ), + "settings.bg_notify.needs_action.body": ( + "*Bg session: needs action*\n\n" + "Push ❓ [] needs your attention when a background session " + "shows an AskUserQuestion / ExitPlanMode / Permission prompt. " + "Otherwise only the ❓ badge in the bg-panel signals it — easy " + "to miss." + ), + "settings.group.haiku_naming": "AI session names", + "settings.haiku.body": ( + "*AI session names*\n\n" + "When *on*, every new session is renamed after the first user " + "message ≥20 chars via a one-shot lightweight-model call (Haiku " + "for Claude, `CODEX_NAMING_MODEL` for Codex) — yields a 1-3 " + "word kebab-case summary of the session's intent " + "(``token-budget-alerts``, ``archive-pagination-fix``). " + "Manually-renamed sessions (``/rename``, ``/new " + "``) are never overwritten.\n\n" + "When *off*, sessions keep the directory-basename name forever " + "(``workdir``, ``workdir-2``, ``ccbot``). Zero token cost." + ), + # Settings categories (top-level Settings is now a category selector). + "settings.cat.card": "🃏 Card / view", + "settings.cat.notifications": "🔔 Notifications", + "settings.cat.voice": "🎙 Voice", + "settings.cat.terminal": "🖥 Local terminal", + "settings.cat.behavior": "⚙ Agent, behavior & language", + "settings.cat.card.body": ( + "*Card / view*\n\nLayout, density and refresh of the live session card." + ), + "settings.cat.notifications.body": ( + "*Notifications*\n\n" + "Bg-session pushes (finished / errors / needs-action) and " + "the weekly-reset day for quota alerts." + ), + "settings.cat.voice.body": ( + "*Voice*\n\nSpeech-to-text backend for incoming voice messages." + ), + "settings.cat.terminal.body": ( + "*Local terminal*\n\n" + "Native Terminal / iTerm window attached to each new session." + ), + "settings.cat.behavior.body": ( + "*Behavior & language*\n\n" + "Global agent; auto-approve prompts; Haiku session names; UI language." + ), + # Settings group: pop a native Terminal/iTerm window per new session + "settings.group.local_terminal": "Local terminal", + "settings.local.body": ( + "*Local terminal*\n\n" + "Optional native desktop terminal attached to a session's " + "tmux window — useful for driving Claude by hand in parallel " + "with the Telegram UI.\n\n" + "*off* — never spawn, never offer.\n" + "*manual* — no auto-spawn; *🖥 Term* shows up next to *Stop / " + "Kill / Clear / Menu* whenever the active session has no " + "terminal attached.\n" + "*auto* — spawn one on every new session AND show the same " + "*🖥 Term* button whenever no terminal is attached.\n\n" + "macOS: Terminal.app or iTerm2 (auto-detected).\n" + "Linux: pick an emulator below. Tap *Configure via Claude* if " + "the auto-detected list is wrong for your setup." + ), + "settings.local.claude_help": "🪄 Configure via Claude", + # /help inline mini-doc + "help.home.body": ( + "*Help*\n\n" + "ccbot bridges this DM to N parallel Claude Code sessions running " + "in tmux. Tap a section below for a quick tour." + ), + "help.btn.overview": "Overview", + "help.btn.sessions": "Sessions", + "help.btn.menu": "Menu", + "help.btn.commands": "Commands", + "help.btn.voice": "Voice & files", + "help.btn.alerts": "Alerts", + "help.btn.terminal": "Local terminal", + "help.btn.tips": "Tips", + "help.body.overview": ( + "*Overview*\n\n" + "One private DM, many parallel Claude Code sessions. Send any " + "text — it goes to your *active* session. Each session lives in " + "its own tmux window with its own claude process; switching the " + "active session never pauses the others.\n\n" + "The inline keyboard under the most recent bot message hosts " + "the session switcher and the ≡ Menu surface." + ), + "help.body.sessions": ( + "*Sessions*\n\n" + "• *Create.* Send any text from an empty DM, or tap ≡ Menu → 🆕 " + "New, then pick a project directory.\n" + "• *Switch.* Tap a session button in the inline switcher under " + "the latest bot message.\n" + "• *Reply-quote.* Reply to a non-active session's bot message — " + "your text is routed there for that one message only.\n" + "• *Done.* `/done [name]` archives a session as completed.\n" + "• *Idle TTL.* Sessions auto-archive after the selected 6/12/24h without activity.\n" + "• *Restore.* ≡ Menu → 📦 Archive → tap *Restore*." + ), + "help.body.menu": ( + "*≡ Menu*\n\n" + "Open via /menu or the ≡ Menu inline button. Items:\n" + "• 📋 *Sessions* — jump to the active session's live card\n" + "• 📊 *Status* — Claude Code 5h / weekly / sonnet quotas\n" + "• 🧑‍💻 *Shot* — terminal snapshot of the active session\n" + "• 🆕 *New* — create a session from a directory browser\n" + "• 📦 *Archive* — restore / inspect / delete archived sessions\n" + "• ⚙ *Settings* — grouped by Card / Notifications / Voice / " + "Terminal / Behavior." + ), + "help.body.commands": ( + "*Slash commands*\n\n" + "Bot-side:\n" + "• `/menu` — open the inline menu\n" + "• `/help` — this help\n" + "• `/done [name]` — archive a session\n" + "• `/health` — uptime, queues, latency, counters\n\n" + "Claude Code passthrough — any other `/cmd` is forwarded:\n" + "• `/model` `/effort` `/clear` `/compact` `/cost` `/memory` …\n\n" + "Type a leading `!` to capture local shell output and forward." + ), + "help.body.voice": ( + "*Voice & files*\n\n" + "• *Voice.* Send a voice message — transcribed locally " + "(whisper.cpp / Apple Speech) and routed to the active session " + "as if you typed it.\n" + "• *Photo / document.* Lands in `/.ccbot-inbox/` and " + "Claude is told via the relative path (with your caption prefix " + "if you attached one). Files auto-clean after 24h; the Telegram " + "`file_id` is retained for 30d for `/restore-file`." + ), + "help.body.alerts": ( + "*Alerts*\n\n" + "*Quota alerts.* 5h / weekly / weekly-Sonnet quotas are sampled " + "from the live `/usage` modal every 10 min. Bot pushes when % " + "crosses 50, 75, or 90.\n\n" + "*Bg session pushes.* Settings → Notifications has three " + "toggles (all default on):\n" + "• ✅ task complete\n" + "• ❌ error\n" + "• ❓ needs your attention (interactive prompt)\n" + "Active session never pushes — it edits its live card instead.\n\n" + "*Context fill.* The card shows ``context: N%`` per session. " + "For Codex it uses exact token usage and model-window values from " + "the rollout. For Claude it is a JSONL estimate (latest assistant " + "input + cache reads vs the published model window)." + ), + "help.body.terminal": ( + "*Local terminal*\n\n" + "Settings → Local terminal: when *on*, every new session pops " + "a native window already attached to its tmux window — drive " + "the session by hand from the desktop in parallel.\n\n" + "macOS: Terminal.app / iTerm2 (auto, prefers iTerm tabs).\n" + "Linux: pick an emulator from the auto-detected list, or use " + "*Configure via Claude* for unusual setups.\n\n" + "Direct attach also works any time: `tmux attach -t ccbot`." + ), + "help.body.tips": ( + "*Tips*\n\n" + "• *Auto-approve.* Settings → Auto-approve auto-Yes's " + "interactive prompts that --dangerously-skip-permissions " + "doesn't already bypass (e.g. WebFetch domain trust).\n" + "• *Card edit lag.* Settings → Live lag controls how often the " + "live session card is re-edited (lower = snappier, higher = " + "less rate-limit pressure).\n" + "• *Languages.* Settings → Language: en / ru / zh.\n" + "• *Outbound proxy.* Set `TG_PROXY_URL` if the host can't reach " + "api.telegram.org directly.\n" + "• *Single instance.* Bot holds an exclusive flock on " + "`$CCBOT_DIR/ccbot.lock`; a second `uv run ccbot` refuses with " + "an error in stderr instead of fighting for Telegram updates.\n" + "• *Hook self-heal.* `SessionStart` + `UserPromptSubmit` hooks " + "both update `session_map.json` — a missed SessionStart is " + "fixed on the next prompt automatically." + ), +} + +__all__ = ["EN"] diff --git a/src/ccbot/i18n_locales/ru.py b/src/ccbot/i18n_locales/ru.py new file mode 100644 index 00000000..2427b541 --- /dev/null +++ b/src/ccbot/i18n_locales/ru.py @@ -0,0 +1,479 @@ +"""Russian translation table for the Telegram UI.""" + +from __future__ import annotations + +RU: dict[str, str] = { + "voice.not_delivered": ( + "🎙 Голос не попал в сессию — в ней открыт запрос (опрув/вопрос), " + "и распознанный текст туда не уходит. Ответь на запрос и перешли " + "голосовое ещё раз." + ), + "voice.download_failed": ( + "🎙 Голосовое не дошло до сессии: Telegram не отдал аудиофайл после " + "{attempts} попыток. Отправь голосовое ещё раз." + ), + "voice.transcription_failed": ( + "🎙 Голосовое не удалось распознать, и оно не дошло до сессии. " + "Отправь его ещё раз." + ), + "voice.transcribing": "🎙 Голосовое распознаётся…", + "voice.queued_dropped": "Последующие сообщения тоже не дошли до сессии.", + "btn.stop": "⏹ Стоп", + "btn.kill": "💀 Убить", + "btn.clear": "🧹 Очистить", + "btn.menu": "≡ Меню", + "btn.term": "🖥 Терминал", + "btn.back": "← Назад", + "btn.cancel": "× Отмена", + "btn.login": "🔐 Войти", + # Claude re-authentication (/login) + "auth.expired": ( + "🔐 *Авторизация Claude слетела*\n\n" + "Все сессии на этом хосте будут падать, пока логин не обновлён. Сам " + "бот при этом жив — он и проведёт тебя через процедуру.\n\n" + "Отправь /login (или нажми кнопку): я дам ссылку, ты подтверждаешь в " + "браузере и присылаешь код сюда." + ), + "auth.login.starting": "🔐 Запускаю процедуру логина…", + "auth.login.url": ( + "🔐 *Шаг 1/2* — открой и подтверди:\n\n" + "{url}\n\n" + "*Шаг 2/2* — на странице будет код. Пришли его сюда обычным " + "сообщением. Ссылка живёт 15 минут." + ), + "auth.login.no_url": ( + "❌ Не удалось получить ссылку логина от CLI. Попробуй /login ещё раз; " + "если повторяется — выполни `claude auth login` на хосте." + ), + "auth.login.ok": ( + "✅ *Готово.* Авторизация продлена до {deadline}.\n\n" + "Падавшие сессии заработают со следующего сообщения." + ), + "auth.login.failed": "❌ Код не принят: {detail}\n\nОтправь /login, чтобы повторить.", + "auth.login.cancelled": "Логин отменён.", + "auth.codex.device": ( + "🔐 *Авторизация Codex*\n\n" + "1. Открой {url}\n" + "2. Введи код: `{code}`\n\n" + "Бот сам увидит подтверждение; присылать код сюда не нужно. " + "Код действует около 15 минут." + ), + "auth.codex.no_device_code": ( + "❌ Codex не выдал device code. Проверь, что установлен актуальный " + "Codex CLI, и повтори /login." + ), + "auth.codex.ok": ( + "✅ *Codex авторизован.* Теперь можно создавать и возобновлять сессии." + ), + "auth.codex.failed": ( + "❌ Авторизация Codex не завершена: {detail}\n\nПовтори /login." + ), + "auth.codex.waiting": ( + "🔐 Codex всё ещё ждёт подтверждения в браузере. Используй ссылку и код выше." + ), + "auth.codex.required": ( + "🔐 Авторизуй Codex по ссылке выше, затем повтори создание сессии." + ), + "auth.codex.check_failed": ( + "❌ Не удалось проверить авторизацию Codex. Проверь `codex --version` " + "и `CODEX_COMMAND`, затем отправь /login." + ), + "auth.codex.storage_mismatch": ( + "⚠ Codex нашел `auth.json`, но effective credential storage его не " + 'читает. Установи `cli_auth_credentials_store = "file"`; бот не ' + "будет заменять существующую авторизацию." + ), + "btn.confirm": "✓ Подтвердить", + "btn.no": "× Нет", + "btn.yes_kill": "⚠ Да, убить", + "btn.yes_delete": "⚠ Да, удалить", + "btn.yes_clear": "⚠ Да, очистить", + "btn.refresh": "🔄 Обновить", + "btn.save": "Сохранено", + "btn.cancelled": "Отменено", + # Archive buttons + "btn.restore": "⤴ Восстановить", + "btn.restore_with_name": "⤴ Восстановить {name}", + "btn.inspect": "🔍 Просмотр", + "btn.open_session": "📜 {name}", + "btn.delete": "🗑 Удалить", + "btn.to_14d": "→ 14д", + "btn.to_72h": "→ 72ч", + "mm.sessions": "📋 Сессии", + "mm.status": "📊 Статус", + "mm.history": "📜 История", + "mm.shot": "🧑‍💻 Скрин", + "mm.new": "🆕 Новая", + "mm.archive": "🗄 Архив", + "mm.settings": "⚙ Настройки", + "menu.title": "*Меню*", + "menu.empty": "*Меню*\n\nАктивной сессии нет — выбери в свитчере или тапни 🆕 Новая.", + "menu.active": "*Меню* · активна: *{name}*", + "settings.title": "*Настройки*", + "settings.body": ( + "*Настройки*\n\n" + "Агент: `{agent}`\n" + "Язык: `{language}`\n" + "Лаг карточки: `{live_lag}с`\n" + "Голос: `{voice}`\n\n" + "_Тапни группу, чтобы изменить._" + ), + "settings.group.agent": "Агент", + "settings.group.language": "Язык", + "settings.group.live_lag": "Лаг карточки", + "settings.group.voice": "Голос", + "settings.lag.body": ( + "*Лаг карточки*\n\n" + "Окно сглаживания правок live-карточки.\n" + "`0с` = править на каждом событии, больше = тише в чате." + ), + "settings.voice.body": ( + "*Распознавание голоса*\n\n" + "Бэкенд для voice-сообщений.\n" + "• `auto` — Apple на macOS, whisper.cpp иначе\n" + "• `whisper` — форсить whisper.cpp\n" + "• `apple` — форсить Apple Speech (только macOS)\n" + "• `off` — игнорировать voice" + ), + "settings.agent.body": ( + "*Агент*\n\n" + "Глобальный backend для всего бота. Все новые сессии работают либо " + "через *Claude*, либо через *Codex*.\n\n" + "Переключение заблокировано, пока остаются живые сессии текущего " + "агента. Сначала заверши или архивируй их." + ), + "settings.lang.body": ( + "*Язык*\n\nЯзык интерфейса. Переключает всё,\nкроме самого вывода Claude." + ), + "list.empty": "Активных сессий нет. Тапни 🆕 Новая, чтобы создать.", + "conf.kill": ( + "Убить *{name}*?\nTmux-окно умрёт, claude session id сохранится.\n" + "Восстановить можно через архив." + ), + "conf.done": "Закрыть *{name}*?\nЦель закрыта, сессия в архиве.", + "conf.delete": ( + "Удалить *{name}* из архива?\nЗапись стирается. JSONL остаётся на диске." + ), + "conf.clear": ( + "Очистить *{name}*?\nОтправит Esc, затем /clear. Контекст сессии " + "стирается без возможности восстановления (в отличие от Kill → Restore)." + ), + "conf.killed": "💀 Убита `{name}`", + "conf.done_ok": "🎉 `{name}` закрыта.", + "conf.deleted": "🗑 Запись из архива удалена.", + "dir.title": "*Выбор рабочей директории*", + "dir.current": "Текущая: `{path}`", + "dir.empty": "_(Поддиректорий нет)_", + "dir.hint": "Тапни папку, чтобы войти, или выбери текущую", + "dir.btn.up": "..", + "dir.btn.select": "Выбрать", + "picker.title": "*Возобновить сессию?*", + "picker.summary": "стр. {page}/{pages} — {total} сессий в этой папке.", + "picker.btn.start_fresh": "🆕 С нуля", + "picker.btn.back_to_dirs": "← К папкам", + "toast.no_session": "Нет активной сессии", + "toast.window_gone": "Окно исчезло", + "toast.esc_sent": "⎋ Esc отправлен", + "toast.cleared": "🧹 Контекст очищен", + "toast.killed": "Убита", + "toast.done": "Закрыта", + "toast.deleted": "Удалена", + "toast.saved": "Сохранено", + "toast.agent_live": ( + "Перед сменой глобального агента заверши или архивируй все живые сессии." + ), + "toast.restored": "Восстановлена", + "toast.already_gone": "Уже нет", + "toast.nothing_to_kill": "Убивать нечего", + "toast.term_opened": "🖥 Терминал открыт", + "toast.invalid_page": "Неверная страница", + "toast.session_not_found": "Сессия не найдена", + "toast.restore_failed": "Не удалось восстановить: {msg}", + "toast.range_14d": "→ 14д", + "toast.range_72h": "→ 72ч", + # Archive screen + "archive.title": "Архивные сессии", + "archive.range_72h": " (0–72ч)", + "archive.range_14d": " (0–14д)", + "archive.empty": "Архивных сессий в этом окне нет.", + "archive.page_line": "стр. {page}/{pages} — всего {total}", + "archive.age.s": "{n}с назад", + "archive.age.m": "{n}мин назад", + "archive.age.h": "{n}ч назад", + "archive.age.d": "{n}д назад", + "usage.title": "*Claude Code*", + "usage.title.codex": "*OpenAI Codex*", + "usage.unavailable": "Живые данные usage недоступны.", + "usage.auth_required": ( + "Для загрузки Usage нужна авторизация Codex. Заверши вход по сообщению " + "выше, затем обнови этот экран." + ), + "usage.5h": "5ч", + "usage.week": "неделя", + "usage.week_sonnet": "неделя (Sonnet)", + "usage.not_reported": "Codex не передал", + "usage.today": "Сегодня", + "usage.today_left": "ещё", + "usage.today_overspent": "перерасход", + "usage.used": "Использовано", + "usage.reset": "Сброс", + "usage.extra": "Extra", + "usage.on": "вкл", + "usage.off": "выкл", + "usage.fetching": "Тяну usage…", + "settings.group.weekly_reset_day": "Сброс недели", + "settings.weeklyday.body": ( + "*День сброса недели*\n\n" + "День недели, в который сбрасывается недельная квота Anthropic.\n" + "Используется для расчёта %/день в weekly-строках." + ), + "day.mon": "пн", + "day.tue": "вт", + "day.wed": "ср", + "day.thu": "чт", + "day.fri": "пт", + "day.sat": "сб", + "day.sun": "вс", + "settings.group.auto_approve": "Авто-подтверждение", + "settings.approve.body": ( + "*Авто-подтверждение*\n\n" + "Как боту обращаться с интерактивными Yes/No-промптами,\n" + "которые --dangerously-skip-permissions сам не закрывает\n" + "(например, доверие домену для WebFetch):\n" + "• `off` — присылать в чат, ты тапаешь сам\n" + "• `on` — Yes на любой промпт" + ), + "approve.off": "выкл", + "approve.on": "вкл", + "settings.group.session_idle_hours": "Автоархив через", + "settings.idle_archive.body": ( + "*Автоархивация сессий*\n\n" + "Через сколько часов без активности архивировать живую сессию. " + "Архив остаётся доступен через Меню → Архив, сессию можно восстановить." + ), + "settings.value.hours": "{value} ч", + # Local terminal — 3-state (off / manual / auto). + "local.off": "выкл", + "local.manual": "по кнопке", + "local.auto": "всегда", + "settings.group.card_history": "История в карточке", + "settings.cardhist.body": ( + "*История в карточке*\n\n" + "Сколько последних end-of-turn границ подгружать в карточку\n" + "при первом доступе (после рестарта бота, тапа в свитчере или\n" + "Меню → Sessions). Глубокая история сверх этого всегда\n" + "доступна через /history независимо от значения.\n\n" + "Больше = больше истории в карточке, больше памяти на сессию." + ), + "settings.group.card_page_lines": "Размер страницы", + "settings.pagesize.body": ( + "*Размер страницы*\n\n" + "Максимум строк на одну страницу карточки. Старые события\n" + "уходят на предыдущие страницы (◀); длинный финальный ответ\n" + "режется на несколько страниц по умным границам (абзац /\n" + "строка / предложение / слово) — без обрывов посреди слова.\n" + "Допускается отклонение ±5 строк.\n\n" + "Меньше = компактнее для телефона. Больше = больше контекста\n" + "на странице, но тяжелее edits." + ), + "settings.group.card_inline_screenshots": "Скрины в карточке", + "settings.screens.body": ( + "*Скрины в карточке*\n\n" + "Когда *on*, pane виден только во время *RUNNING*: тело → отступ →\n" + "pane → отступ → context → фон. В *IDLE*, после финала или /clear\n" + "скрин удаляется, на следующем turn появляется снова. Обновление\n" + "pane — не чаще ~раз в 3с.\n\n" + "Rich Bot API держит текст и картинку в одном сообщении; старый API\n" + "использует фото + подпись. При сбое: legacy-photo, затем text-only.\n" + "Временный edit повторится на следующем обновлении; потерянный\n" + "carrier пересоздастся без мгновенного дубля.\n\n" + "Если незавершённый активный turn замолчал, pane остаётся без push-" + "заглушки; у фоновой сессии появится только ⚠️ в блоке фона.\n\n" + "Когда *off* — остаётся обычный текстовый flow, а Shot доступен\n" + "через кнопку терминала в верхнем ряду." + ), + "screens.on": "on", + "screens.off": "off", + "settings.group.bg_notify_finished": "Bg: задача готова", + "settings.group.bg_notify_error": "Bg: ошибки", + "settings.group.bg_notify_needs_action": "Bg: нужен ввод", + "settings.bg_notify.finished.body": ( + "*Bg-сессия: задача готова*\n\n" + "Когда фоновая сессия достигает end-of-turn, шлём тихий\n" + "push ✅ [] task complete, чтобы юзер мог переключиться." + ), + "settings.bg_notify.error.body": ( + "*Bg-сессия: ошибки*\n\n" + "Push ❌ [] error когда фоновая сессия эмитит\n" + "ошибочный ивент. (Сейчас срабатывает только на явные\n" + "error-ивенты; детект исключений будет расширен.)" + ), + "settings.bg_notify.needs_action.body": ( + "*Bg-сессия: нужен ввод*\n\n" + "Push ❓ [] needs your attention когда фоновая сессия\n" + "показывает AskUserQuestion / ExitPlanMode / Permission промпт.\n" + "Иначе только ❓ бейдж в bg-panel — легко пропустить." + ), + "settings.group.haiku_naming": "Имена сессий через AI", + "settings.haiku.body": ( + "*Имена сессий через AI*\n\n" + "При *on* каждая новая сессия переименовывается после первого\n" + "пользовательского сообщения ≥20 символов одноразовым\n" + "вызовом легковесной модели (Haiku для Claude,\n" + "`CODEX_NAMING_MODEL` для Codex) — 1-3 слова в kebab-case о сути сессии\n" + "(``token-budget-alerts``, ``archive-pagination-fix``).\n" + "Сессии, переименованные вручную (``/rename``,\n" + "``/new ``), никогда не перетираются.\n\n" + "При *off* имя навсегда остаётся basename'ом директории\n" + "(``workdir``, ``workdir-2``, ``ccbot``). Нулевой расход токенов." + ), + "settings.cat.card": "🃏 Карточка / вид", + "settings.cat.notifications": "🔔 Уведомления", + "settings.cat.voice": "🎙 Голос", + "settings.cat.terminal": "🖥 Локальный терминал", + "settings.cat.behavior": "⚙ Агент, поведение и язык", + "settings.cat.card.body": ( + "*Карточка / вид*\n\nРаскладка, плотность и refresh живой карточки." + ), + "settings.cat.notifications.body": ( + "*Уведомления*\n\n" + "Bg-сессионные пуши (готово / ошибки / нужен ввод) и день\n" + "сброса для weekly-quota алертов." + ), + "settings.cat.voice.body": ("*Голос*\n\nДвижок speech-to-text для входящих voice."), + "settings.cat.terminal.body": ( + "*Локальный терминал*\n\nНативное Terminal / iTerm окно к tmux." + ), + "settings.cat.behavior.body": ( + "*Поведение и язык*\n\n" + "Глобальный агент; авто-Yes; имена через Haiku; язык интерфейса." + ), + "settings.group.local_terminal": "Локальный терминал", + "settings.local.body": ( + "*Локальный терминал*\n\n" + "Опциональное нативное окно с `tmux attach` к сессии —\n" + "удобно вести Claude руками с десктопа параллельно\n" + "с Telegram.\n\n" + "*выкл* — никогда не открывать, кнопку не показывать.\n" + "*по кнопке* — авто-спавна нет; *🖥 Терминал*\n" + "появляется рядом со *Стоп / Убить / Очистить / Меню*\n" + "когда у активной сессии терминал не аттачен.\n" + "*всегда* — спавнить при создании каждой сессии И\n" + "показывать ту же *🖥 Терминал*-кнопку, когда\n" + "терминала нет.\n\n" + "macOS: Terminal.app или iTerm2 (авто).\n" + "Linux: выбери эмулятор ниже. Тапни *Configure via Claude*\n" + "если автодетект не угадал." + ), + "settings.local.claude_help": "🪄 Настроить через Claude", + "help.home.body": ( + "*Помощь*\n\n" + "ccbot связывает этот личный чат с N параллельными сессиями " + "Claude Code в tmux. Тапни нужный раздел ниже." + ), + "help.btn.overview": "Обзор", + "help.btn.sessions": "Сессии", + "help.btn.menu": "Меню", + "help.btn.commands": "Команды", + "help.btn.voice": "Голос и файлы", + "help.btn.alerts": "Алерты", + "help.btn.terminal": "Локальный терминал", + "help.btn.tips": "Советы", + "help.body.overview": ( + "*Обзор*\n\n" + "Один личный DM, много параллельных сессий Claude Code. Любой " + "текст летит в *активную* сессию. У каждой сессии своё tmux-окно " + "и свой процесс claude — переключение активной не ставит другие " + "на паузу.\n\n" + "Инлайн-клавиатура под последним сообщением бота — это " + "переключатель сессий и ≡ Меню." + ), + "help.body.sessions": ( + "*Сессии*\n\n" + "• *Создать.* Просто отправь любой текст в пустой DM, или " + "≡ Меню → 🆕 New, выбери директорию.\n" + "• *Переключить.* Тапни кнопку сессии в инлайн-переключателе.\n" + "• *Reply-quote.* Ответь (Telegram-цитата) на сообщение бота из " + "неактивной сессии — твой текст уйдёт туда разово, без смены " + "активной.\n" + "• *Закрыть.* `/done [имя]` — отмечает сессию как готовую.\n" + "• *Idle TTL.* Автоархив через выбранные 6/12/24ч без активности.\n" + "• *Восстановить.* ≡ Меню → 📦 Archive → *Restore*." + ), + "help.body.menu": ( + "*≡ Меню*\n\n" + "Открывается через /menu или инлайн-кнопку ≡. Пункты:\n" + "• 📋 *Sessions* — переход на живую карточку активной\n" + "• 📊 *Status* — лимиты Claude Code (5ч / неделя / sonnet)\n" + "• 🧑‍💻 *Shot* — снимок терминала активной сессии\n" + "• 🆕 *New* — создать сессию через выбор директории\n" + "• 📦 *Archive* — восстановить / посмотреть / удалить\n" + "• ⚙ *Settings* — сгруппированы по Карточка / Уведомления / " + "Голос / Терминал / Поведение." + ), + "help.body.commands": ( + "*Слэш-команды*\n\n" + "Бот:\n" + "• `/menu` — открыть инлайн-меню\n" + "• `/help` — эта справка\n" + "• `/done [имя]` — архивировать сессию\n" + "• `/health` — uptime, очереди, latency, счётчики\n\n" + "Claude Code (форвардятся как есть):\n" + "• `/model` `/effort` `/clear` `/compact` `/cost` `/memory` …\n\n" + "Префикс `!` — захват вывода локальной шелл-команды и форвард." + ), + "help.body.voice": ( + "*Голос и файлы*\n\n" + "• *Голос.* Отправь голосовое — оно расшифровывается локально " + "(whisper.cpp / Apple Speech) и уходит в активную сессию как " + "текст.\n" + "• *Фото / документ.* Кладётся в `/.ccbot-inbox/`, " + "Claude получает относительный путь (с caption-префиксом, если " + "он был). TTL 24ч; Telegram `file_id` хранится 30д для " + "`/restore-file`." + ), + "help.body.alerts": ( + "*Алерты*\n\n" + "*Квоты Claude Code.* 5ч / неделя / неделя Sonnet — бот опрашивает " + "живой `/usage` каждые 10 мин и пушит при пересечении 50, 75, 90 %.\n\n" + "*Пуши по фоновым сессиям.* Settings → Уведомления, три " + "независимых тумблера (все по умолчанию on):\n" + "• ✅ task complete\n" + "• ❌ error\n" + "• ❓ needs your attention (интерактивный prompt)\n" + "Активная сессия не пушит — она дописывает свою live-карточку.\n\n" + "*Заполнение контекста.* На карточке у каждой сессии есть " + "``context: N%``. Для Codex используются точные token usage и размер " + "окна из rollout. Для Claude это оценка из JSONL: input + cache_read " + "последнего assistant-turn относительно окна модели." + ), + "help.body.terminal": ( + "*Локальный терминал*\n\n" + "Settings → Local terminal: при *on* каждая новая сессия " + "автоматически открывает нативное окно, уже привязанное к её " + "tmux-window — управляй с десктопа параллельно с Telegram.\n\n" + "macOS: Terminal.app / iTerm2 (auto, предпочитает вкладки в iTerm).\n" + "Linux: выбор эмулятора из списка, либо *Configure via Claude* " + "для нестандартных кейсов.\n\n" + "В любой момент работает прямой `tmux attach -t ccbot`." + ), + "help.body.tips": ( + "*Советы*\n\n" + "• *Auto-approve.* Settings → Auto-approve авто-Yes-ит модалки, " + "которые --dangerously-skip-permissions не закрывает сам " + "(WebFetch domain trust и т.п.).\n" + "• *Live lag.* Settings → Live lag — частота перерисовки " + "карточки сессии. Меньше = шустрее, больше = меньше rate-limit.\n" + "• *Языки.* Settings → Language: en / ru / zh.\n" + "• *Outbound proxy.* `TG_PROXY_URL` если api.telegram.org " + "недоступен напрямую.\n" + "• *Один инстанс.* Бот держит exclusive flock на " + "`$CCBOT_DIR/ccbot.lock`; второй `uv run ccbot` откажется " + "стартовать с ошибкой в stderr, не подерётся за Telegram updates.\n" + "• *Self-heal хук.* `SessionStart` + `UserPromptSubmit` оба " + "обновляют `session_map.json` — пропущенный SessionStart " + "автоматически чинится при следующем prompt'е." + ), +} + +__all__ = ["RU"] diff --git a/src/ccbot/i18n_locales/zh.py b/src/ccbot/i18n_locales/zh.py new file mode 100644 index 00000000..43151863 --- /dev/null +++ b/src/ccbot/i18n_locales/zh.py @@ -0,0 +1,409 @@ +"""Chinese translation table for the Telegram UI.""" + +from __future__ import annotations + +ZH: dict[str, str] = { + "voice.not_delivered": ( + "🎙 语音未送达会话 —— 会话中有待处理的提示(授权/提问)," + "转写文本无法送入。请先回应该提示,然后重新发送语音消息。" + ), + "voice.download_failed": ( + "🎙 语音未送达会话:Telegram 在 {attempts} 次尝试后仍无法提供音频文件。" + "请重新发送语音消息。" + ), + "voice.transcription_failed": "🎙 语音无法识别且未送达会话。请重新发送。", + "voice.transcribing": "🎙 正在转写语音消息…", + "voice.queued_dropped": "之后发送的消息也未送达会话。", + "btn.stop": "⏹ 停止", + "btn.kill": "💀 终止", + "btn.clear": "🧹 清空", + "btn.menu": "≡ 菜单", + "btn.term": "🖥 终端", + "btn.back": "← 返回", + "btn.cancel": "× 取消", + "btn.login": "🔐 登录", + # Claude re-authentication (/login) + "auth.expired": ( + "🔐 *Claude 授权已失效*\n\n" + "在重新登录之前,这台主机上的所有会话都会报错。机器人本身没事 —— " + "它可以带你走完流程。\n\n" + "发送 /login(或点下面的按钮):我给你链接,你在浏览器里确认," + "然后把码发回这里。" + ), + "auth.login.starting": "🔐 正在启动登录流程…", + "auth.login.url": ( + "🔐 *第 1/2 步* —— 打开并确认:\n\n" + "{url}\n\n" + "*第 2/2 步* —— 页面会显示一个码。把它作为普通消息发到这里。" + "链接 15 分钟内有效。" + ), + "auth.login.no_url": ( + "❌ 没能从 CLI 拿到登录链接。再试一次 /login;如果一直失败," + "请在主机上执行 `claude auth login`。" + ), + "auth.login.ok": ( + "✅ *已登录。* 授权已延长到 {deadline}。\n\n" + "之前报错的会话在下一条消息就会恢复。" + ), + "auth.login.failed": "❌ 验证码未被接受:{detail}\n\n发送 /login 重试。", + "auth.login.cancelled": "已取消登录。", + "auth.codex.device": ( + "🔐 *Codex 登录*\n\n" + "1. 打开 {url}\n" + "2. 输入代码: `{code}`\n\n" + "机器人会自动检测授权;无需把代码发到这里。代码约 15 分钟内有效。" + ), + "auth.codex.no_device_code": ( + "❌ Codex 未提供设备代码。请确认已安装最新 Codex CLI,然后发送 /login 重试。" + ), + "auth.codex.ok": "✅ *Codex 已授权。* 现在可以创建和恢复会话。", + "auth.codex.failed": "❌ Codex 登录失败:{detail}\n\n发送 /login 重试。", + "auth.codex.waiting": "🔐 Codex 仍在等待浏览器确认。请使用上面的链接和代码。", + "auth.codex.required": "🔐 请先通过上面的链接授权 Codex,然后重新创建会话。", + "auth.codex.check_failed": ( + "❌ 无法检查 Codex 授权。请检查 `codex --version` 和 " + "`CODEX_COMMAND`,然后发送 /login。" + ), + "auth.codex.storage_mismatch": ( + "⚠ Codex 找到了 `auth.json`,但当前凭据存储不会读取它。请设置 " + '`cli_auth_credentials_store = "file"`;机器人不会替换现有授权。' + ), + "btn.confirm": "✓ 确认", + "btn.no": "× 否", + "btn.yes_kill": "⚠ 是,终止", + "btn.yes_delete": "⚠ 是,删除", + "btn.yes_clear": "⚠ 是,清空", + "btn.refresh": "🔄 刷新", + "btn.save": "已保存", + "btn.cancelled": "已取消", + # Archive buttons + "btn.restore": "⤴ 恢复", + "btn.restore_with_name": "⤴ 恢复 {name}", + "btn.inspect": "🔍 查看", + "btn.open_session": "📜 {name}", + "btn.delete": "🗑 删除", + "btn.to_14d": "→ 14天", + "btn.to_72h": "→ 72时", + "mm.sessions": "📋 会话", + "mm.status": "📊 状态", + "mm.history": "📜 历史", + "mm.shot": "🧑‍💻 截图", + "mm.new": "🆕 新建", + "mm.archive": "🗄 归档", + "mm.settings": "⚙ 设置", + "menu.title": "*菜单*", + "menu.empty": "*菜单*\n\n无活动会话——从切换器选一个或点 🆕 新建。", + "menu.active": "*菜单* · 活动: *{name}*", + "settings.title": "*设置*", + "settings.body": ( + "*设置*\n\n" + "代理: `{agent}`\n" + "语言: `{language}`\n" + "卡片延迟: `{live_lag}秒`\n" + "语音: `{voice}`\n\n" + "_点击分组进行更改。_" + ), + "settings.group.agent": "代理", + "settings.group.language": "语言", + "settings.group.live_lag": "卡片延迟", + "settings.group.voice": "语音", + "settings.lag.body": ( + "*实时预览延迟*\n\n" + "实时卡片编辑的合并窗口。\n" + "`0秒` = 每个事件都更新,数值越高越安静。" + ), + "settings.voice.body": ( + "*语音识别*\n\n" + "语音消息使用的后端。\n" + "• `auto` — macOS 用 Apple, 其他用 whisper.cpp\n" + "• `whisper` — 强制 whisper.cpp\n" + "• `apple` — 强制 Apple Speech (仅 macOS)\n" + "• `off` — 忽略语音" + ), + "settings.agent.body": ( + "*代理*\n\n" + "整个机器人的全局后端。所有新会话统一使用 *Claude* 或 *Codex*。\n\n" + "当前后端仍有活动会话时不能切换;请先结束或归档这些会话。" + ), + "settings.lang.body": "*语言*\n\n界面语言。切换除 Claude 自身输出外的一切文本。", + "list.empty": "没有活动会话。点 🆕 新建以创建。", + "conf.kill": ( + "终止 *{name}*?\nTmux 窗口结束,claude session id 已保存。\n可通过归档列表恢复。" + ), + "conf.done": "标记 *{name}* 为完成?\n目标已关闭,会话已归档。", + "conf.delete": "从归档中删除 *{name}*?\n状态记录消失。JSONL 保留在磁盘。", + "conf.clear": ( + "清空 *{name}*?\n先发送 Esc,然后 /clear。会话上下文将被擦除," + "无法恢复(不同于 Kill → Restore)。" + ), + "conf.killed": "💀 已终止 `{name}`", + "conf.done_ok": "🎉 `{name}` 已标记完成。", + "conf.deleted": "🗑 归档记录已删除。", + "dir.title": "*选择工作目录*", + "dir.current": "当前: `{path}`", + "dir.empty": "_(无子目录)_", + "dir.hint": "点文件夹进入,或选择当前目录", + "dir.btn.up": "..", + "dir.btn.select": "选择", + "picker.title": "*恢复会话?*", + "picker.summary": "第 {page}/{pages} 页 — 此目录共 {total} 个会话。", + "picker.btn.start_fresh": "🆕 从零开始", + "picker.btn.back_to_dirs": "← 返回目录", + "toast.no_session": "无活动会话", + "toast.window_gone": "窗口已消失", + "toast.esc_sent": "⎋ 已发送 Esc", + "toast.cleared": "🧹 上下文已清空", + "toast.killed": "已终止", + "toast.done": "已完成", + "toast.deleted": "已删除", + "toast.saved": "已保存", + "toast.agent_live": "切换全局代理前,请先结束或归档所有活动会话。", + "toast.restored": "已恢复", + "toast.already_gone": "已不存在", + "toast.nothing_to_kill": "没什么可终止的", + "toast.term_opened": "🖥 已打开终端", + "toast.invalid_page": "页面无效", + "toast.session_not_found": "未找到会话", + "toast.restore_failed": "恢复失败:{msg}", + "toast.range_14d": "→ 14天", + "toast.range_72h": "→ 72时", + # Archive screen + "archive.title": "已归档会话", + "archive.range_72h": "(0–72时)", + "archive.range_14d": "(0–14天)", + "archive.empty": "此范围内没有已归档会话。", + "archive.page_line": "第 {page}/{pages} 页 — 共 {total}", + "archive.age.s": "{n}秒前", + "archive.age.m": "{n}分前", + "archive.age.h": "{n}时前", + "archive.age.d": "{n}天前", + "usage.title": "*Claude Code*", + "usage.title.codex": "*OpenAI Codex*", + "usage.unavailable": "实时使用数据不可用。", + "usage.auth_required": "加载 Usage 需要 Codex 授权。请完成上方登录,然后刷新此页面。", + "usage.5h": "5小时", + "usage.week": "本周", + "usage.week_sonnet": "本周 (Sonnet)", + "usage.not_reported": "Codex 未报告", + "usage.today": "今天", + "usage.today_left": "还可用", + "usage.today_overspent": "超出", + "usage.used": "已使用", + "usage.reset": "重置", + "usage.extra": "Extra", + "usage.on": "开", + "usage.off": "关", + "usage.fetching": "正在获取使用情况…", + "settings.group.weekly_reset_day": "周重置", + "settings.weeklyday.body": ( + "*每周重置日*\n\n" + "Anthropic 周配额重置的星期。\n" + "用于计算 weekly 行的 %/天 消耗速率。" + ), + "day.mon": "一", + "day.tue": "二", + "day.wed": "三", + "day.thu": "四", + "day.fri": "五", + "day.sat": "六", + "day.sun": "日", + "settings.group.auto_approve": "自动同意", + "settings.approve.body": ( + "*自动同意*\n\n" + "对 --dangerously-skip-permissions 未覆盖的\n" + "Claude Code 交互式 Yes/No 提示的处理方式\n" + "(例如 WebFetch 域名信任):\n" + "• `off` — 推送到聊天,手动点击\n" + "• `on` — 所有提示自动 Yes" + ), + "approve.off": "关", + "approve.on": "开", + "settings.group.session_idle_hours": "自动归档时间", + "settings.idle_archive.body": ( + "*会话自动归档*\n\n" + "实时会话无活动达到所选小时数后自动归档。" + "归档会话仍可通过菜单 → 归档恢复。" + ), + "settings.value.hours": "{value}小时", + # Local terminal — 3-state (off / manual / auto). + "local.off": "关", + "local.manual": "按钮", + "local.auto": "总是", + "settings.group.card_history": "卡片历史", + "settings.cardhist.body": ( + "*卡片历史*\n\n" + "首次访问时(机器人重启 / 切换器点击 / 菜单→Sessions)\n" + "从 JSONL 转录加载多少最近的 end-of-turn 边界。\n" + "更深的历史始终通过 /history 访问,与该值无关。\n\n" + "更多 = 卡片内更多历史,每会话占用更多内存。" + ), + "settings.group.card_page_lines": "页面大小", + "settings.pagesize.body": ( + "*页面大小*\n\n" + "卡片单页最大行数。较旧事件落到前面的页面(◀);\n" + "较长的最终回答按智能边界(段落 / 行 / 句子 / 单词)\n" + "拆分多页 — 不会在单词中间断开。允许 ±5 行偏差。\n\n" + "更小 = 手机视图更紧凑。更大 = 单页更多上下文,\n" + "但 edit 消息更重。" + ), + "settings.group.card_inline_screenshots": "卡片内嵌截图", + "settings.screens.body": ( + "*卡片内嵌截图*\n\n" + "*开启* 时,pane 仅在 turn 为 *RUNNING* 时显示:正文 → 间距 →\n" + "pane → 间距 → context → 后台面板。进入 *IDLE*、收到最终回答\n" + "或执行 /clear 时移除;下一轮开始后再次出现。pane 更新约 3 秒\n" + "节流。\n\n" + "Rich Bot API 将文本和媒体保留在同一条消息中;旧版 API 使用\n" + "图片 + 说明文字。发送失败依次回退到 legacy 图片和纯文本;\n" + "临时 edit 失败在下次更新重试,carrier 丢失则重建且不立即重复。\n\n" + "未完成的活动 turn 长时间无响应时保留 pane,不发送警告 push;后台\n" + "会话只在后台面板中标记 ⚠️。\n\n" + "*关闭* 时,保持普通纯文本流程,Shot 可从顶部终端按钮打开。" + ), + "screens.on": "开", + "screens.off": "关", + "settings.group.bg_notify_finished": "Bg:任务完成", + "settings.group.bg_notify_error": "Bg:错误", + "settings.group.bg_notify_needs_action": "Bg:需要操作", + "settings.bg_notify.finished.body": ( + "*Bg 会话:任务完成*\n\n" + "后台会话进入 end-of-turn 时,推送 ✅ [] task complete。" + ), + "settings.bg_notify.error.body": ( + "*Bg 会话:错误*\n\n后台会话发出错误事件时,推送 ❌ [] error。" + ), + "settings.bg_notify.needs_action.body": ( + "*Bg 会话:需要操作*\n\n" + "后台会话显示 AskUserQuestion / ExitPlanMode / Permission\n" + "提示时,推送 ❓ [] needs your attention。" + ), + "settings.cat.card": "🃏 卡片 / 视图", + "settings.cat.notifications": "🔔 通知", + "settings.cat.voice": "🎙 语音", + "settings.cat.terminal": "🖥 本地终端", + "settings.cat.behavior": "⚙ 代理、行为和语言", + "settings.cat.card.body": "*卡片 / 视图*\n\n实时会话卡片的布局、密度和刷新。", + "settings.cat.notifications.body": ( + "*通知*\n\nBg 会话推送(完成 / 错误 / 需要操作)和\nweekly quota 提醒的重置日。" + ), + "settings.cat.voice.body": "*语音*\n\n语音消息的 STT 后端。", + "settings.cat.terminal.body": "*本地终端*\n\n附加到每个新会话的本地终端窗口。", + "settings.cat.behavior.body": ( + "*行为和语言*\n\n全局代理;自动同意交互提示;界面语言。" + ), + "settings.group.local_terminal": "本地终端", + "settings.local.body": ( + "*本地终端*\n\n" + "可选的本地终端,附加到会话的 tmux 窗口 ——\n" + "便于在桌面手动操作 Claude,与 Telegram 并行。\n\n" + "*关* — 从不打开,不显示按钮。\n" + "*按钮* — 不自动打开;当活动会话未附加终端时,\n" + "*🖥 终端* 出现在 *停止 / 终止 / 清空 / 菜单* 旁边。\n" + "*总是* — 每个新会话都自动打开,同时在未附加\n" + "终端时显示相同的 *🖥 终端* 按钮。\n\n" + "macOS:Terminal.app 或 iTerm2(自动)。\n" + "Linux:在下方选择终端模拟器。如果自动检测\n" + "不符合实际环境,请点击 *Configure via Claude*。" + ), + "settings.local.claude_help": "🪄 通过 Claude 配置", + "help.home.body": ( + "*帮助*\n\n" + "ccbot 将这个私聊连接到 N 个并行运行在 tmux 中的\n" + "Claude Code 会话。点击下方对应章节查看简介。" + ), + "help.btn.overview": "概览", + "help.btn.sessions": "会话", + "help.btn.menu": "菜单", + "help.btn.commands": "命令", + "help.btn.voice": "语音和文件", + "help.btn.alerts": "提醒", + "help.btn.terminal": "本地终端", + "help.btn.tips": "技巧", + "help.body.overview": ( + "*概览*\n\n" + "一个私聊,多个并行的 Claude Code 会话。任何文本会发送到\n" + "当前的 *活动* 会话。每个会话拥有独立的 tmux 窗口和 claude\n" + "进程,切换活动会话不会暂停其他会话。\n\n" + "最新机器人消息下方的内联键盘是会话切换器和 ≡ 菜单。" + ), + "help.body.sessions": ( + "*会话*\n\n" + "• *创建。* 在空 DM 中发送任意文本,或 ≡ 菜单 → 🆕 New,\n" + "选择一个目录。\n" + "• *切换。* 点击切换器中的会话按钮。\n" + "• *引用回复。* 回复非活动会话的机器人消息 — 你的文本\n" + "只单次路由到该会话,不更改活动状态。\n" + "• *完成。* `/done [name]` — 标记并归档。\n" + "• *闲置 TTL。* 无活动达到所选 6/12/24 小时后自动归档。\n" + "• *恢复。* ≡ 菜单 → 📦 Archive → *Restore*。" + ), + "help.body.menu": ( + "*≡ 菜单*\n\n" + "通过 /menu 或 ≡ 菜单内联按钮打开:\n" + "• 📋 *Sessions* — 跳转到当前会话的实时卡片\n" + "• 📊 *Status* — 5h / 周 / sonnet 配额\n" + "• 🧑‍💻 *Shot* — 当前会话的终端快照\n" + "• 🆕 *New* — 通过目录浏览器创建会话\n" + "• 📦 *Archive* — 恢复 / 查看 / 删除\n" + "• ⚙ *Settings* — 按 卡片 / 通知 / 语音 / 终端 / 行为 分组。" + ), + "help.body.commands": ( + "*斜杠命令*\n\n" + "Bot 端:\n" + "• `/menu` — 打开内联菜单\n" + "• `/help` — 本帮助\n" + "• `/done [name]` — 归档会话\n" + "• `/health` — 运行时间 / 队列 / 延迟 / 计数器\n\n" + "Claude Code 透传(原样转发):\n" + "• `/model` `/effort` `/clear` `/compact` `/cost` `/memory` …\n\n" + "前缀 `!` — 捕获本地 shell 命令的输出并转发。" + ), + "help.body.voice": ( + "*语音和文件*\n\n" + "• *语音。* 发送语音消息 — 在本地转写\n" + "(whisper.cpp / Apple Speech)然后作为文本发送给活动会话。\n" + "• *照片 / 文档。* 落到 `/.ccbot-inbox/`,Claude 收到\n" + "相对路径(如果你附带 caption,会作为前缀)。TTL 24 小时;\n" + "Telegram `file_id` 保留 30 天用于 `/restore-file`。" + ), + "help.body.alerts": ( + "*提醒*\n\n" + "*配额提醒。* 5h / 周 / 周-Sonnet 配额 — 机器人每 10 分钟轮询\n" + "实时 `/usage` 弹窗,百分比跨过 50 / 75 / 90 时推送。\n\n" + "*后台会话推送。* Settings → 通知 三个独立开关(默认全部 on):\n" + "• ✅ task complete\n" + "• ❌ error\n" + "• ❓ needs your attention (交互式提示)\n" + "活动会话不推送 — 直接更新它的实时卡片。\n\n" + "*上下文占用。* 卡片每个会话显示 ``context: N%``。Codex 使用\n" + "rollout 中准确的 token usage 和模型窗口;Claude 使用 JSONL\n" + "估算(最近一次 assistant turn 的 input + cache_read 除以模型窗口)。" + ), + "help.body.terminal": ( + "*本地终端*\n\n" + "Settings → Local terminal:开启后,每次新建会话也会弹出\n" + "本地原生窗口,自动 attach 到对应 tmux 窗口 —\n" + "桌面手动操作和 Telegram 并行。\n\n" + "macOS:Terminal.app / iTerm2(自动,iTerm 优先用 tab)。\n" + "Linux:从自动检测列表选择,或 *Configure via Claude*\n" + "处理特殊环境。\n\n" + "随时也可直接 `tmux attach -t ccbot`。" + ), + "help.body.tips": ( + "*技巧*\n\n" + "• *自动同意。* Settings → Auto-approve 自动 Yes\n" + "--dangerously-skip-permissions 未覆盖的提示。\n" + "• *Live lag。* Settings → Live lag — 会话卡片重绘频率,\n" + "更小 = 更灵敏,更大 = 更省 rate-limit。\n" + "• *语言。* Settings → Language:en / ru / zh。\n" + "• *出站代理。* `TG_PROXY_URL` 如果主机无法\n" + "直接访问 api.telegram.org。\n" + "• *单实例锁。* bot 在 `$CCBOT_DIR/ccbot.lock` 持独占 flock;\n" + "第二个 `uv run ccbot` 会拒绝启动并在 stderr 报错,\n" + "不会和原实例争抢 Telegram updates。\n" + "• *Hook 自愈。* `SessionStart` + `UserPromptSubmit` 都会更新\n" + "`session_map.json` — 错过的 SessionStart 在下一个 prompt 自动修复。" + ), +} + +__all__ = ["ZH"] diff --git a/src/ccbot/rich.py b/src/ccbot/rich.py index ad8019c2..6bde76bd 100644 --- a/src/ccbot/rich.py +++ b/src/ccbot/rich.py @@ -1,4 +1,4 @@ -"""Bot API 10.1 rich-message calls (sendRichMessage / rich editMessageText). +"""Bot API 10.2 rich-message calls (sendRichMessage / rich editMessageText). PTB 22.x wraps Bot API 10.0, so rich messages go through the raw ``Bot._post`` escape hatch until PTB ships native support; ``ExtBot`` @@ -12,8 +12,10 @@ swallows anything that looks like an unsupported HTML tag), and table cells are wrapped in so native tables render in a smaller font (the API exposes no font-size control; clients draw sub/sup smaller). - - send_rich_message / edit_rich_message: thin raw-API wrappers returning - PTB ``Message`` objects. + - send_rich_message / edit_rich_message: thin raw-API wrappers with optional + embedded photo upload/file-id reuse support. + - extract_rich_photo_file_id: recover the best reusable photo file_id from + a raw response or from the unknown-field payload in ``Message.api_kwargs``. Key functions: to_rich_markdown, send_rich_message, edit_rich_message. """ @@ -22,7 +24,7 @@ import re from typing import Any, cast -from telegram import InlineKeyboardMarkup, Message +from telegram import InlineKeyboardMarkup, InputFile, InputMediaPhoto, Message from telegram.ext import ExtBot from .transcript_format import ( @@ -33,9 +35,20 @@ EXPANDABLE_QUOTE_START, ) -# Rich messages cap (Bot API 10.1): 32768 UTF-8 chars of text. +# Rich messages cap (Bot API 10.2): 32768 UTF-8 chars of text. RICH_MAX_CHARS = 32768 +# One embedded terminal screenshot per rich message. The identifier connects +# the final Markdown media block to InputRichMessage.media; for a fresh upload +# it is also the multipart field name referenced via attach://. +_RICH_PHOTO_ID = "terminal_screenshot" +_RICH_PHOTO_UPLOAD_FIELD = _RICH_PHOTO_ID +_RICH_PHOTO_MARKDOWN = f"![](tg://photo?id={_RICH_PHOTO_ID})" +_RICH_PHOTO_FILENAME = "terminal_screenshot.png" +# Optional placement marker used by live cards. It is replaced only while +# building a rich photo payload and never reaches Telegram as visible text. +RICH_PHOTO_ANCHOR = "\x02RICH_PHOTO_ANCHOR\x02" + # Fenced code blocks (tolerating an unterminated fence at EOF) and inline # code spans — `<` inside these is preserved verbatim by the rich parser. _CODE_SPAN_RE = re.compile(r"```[\s\S]*?(?:```|$)|`[^`\n]*`") @@ -278,8 +291,96 @@ def to_rich_markdown(text: str) -> str: return _multiline_shell_fences_to_code(text) -def _input_rich_message(markdown: str) -> dict[str, Any]: - return {"markdown": markdown} +def _input_rich_message(markdown: str, photo_ref: str | None = None) -> dict[str, Any]: + if photo_ref is None: + return {"markdown": markdown.replace(RICH_PHOTO_ANCHOR, "")} + media = InputMediaPhoto(media=photo_ref).to_dict() + if RICH_PHOTO_ANCHOR in markdown: + markdown = markdown.replace(RICH_PHOTO_ANCHOR, _RICH_PHOTO_MARKDOWN, 1) + markdown = markdown.replace(RICH_PHOTO_ANCHOR, "") + else: + markdown = f"{markdown.rstrip()}\n\n{_RICH_PHOTO_MARKDOWN}" + return { + "markdown": markdown, + "media": [{"id": _RICH_PHOTO_ID, "media": media}], + } + + +def _photo_request_parts(photo: bytes | str) -> tuple[str, InputFile | None]: + """Return the InputMediaPhoto reference and optional multipart upload.""" + if isinstance(photo, bytes): + upload = InputFile(photo, filename=_RICH_PHOTO_FILENAME) + return f"attach://{_RICH_PHOTO_UPLOAD_FIELD}", upload + return photo, None + + +def _rich_message_payload(response: object) -> object | None: + """Find the RichMessage object in a raw response or PTB Message.""" + if isinstance(response, Message): + rich_message = getattr(response, "rich_message", None) + if rich_message is not None: + return rich_message + return response.api_kwargs.get("rich_message") + if isinstance(response, dict): + return response.get("rich_message", response) + api_kwargs = getattr(response, "api_kwargs", None) + if isinstance(api_kwargs, dict): + return api_kwargs.get("rich_message") + return None + + +def extract_rich_photo_file_id(response: object) -> str | None: + """Return the best reusable photo ``file_id`` from a rich response. + + Bot API 10.2 returns embedded photos inside ``rich_message.blocks`` rather + than the legacy top-level ``Message.photo`` field. PTB versions that don't + know RichMessage preserve that raw object in ``Message.api_kwargs``. This + walker supports both forms, including photos nested in collage/slideshow + blocks, and prefers the largest available PhotoSize by pixel area. + """ + payload = _rich_message_payload(response) + candidates: list[tuple[int, int, int, str]] = [] + order = 0 + + def visit(value: object) -> None: + nonlocal order + to_dict = getattr(value, "to_dict", None) + if callable(to_dict) and not isinstance(value, dict): + value = to_dict() + if isinstance(value, list | tuple): + for item in value: + visit(item) + return + if not isinstance(value, dict): + return + photo = value.get("photo") + if value.get("type") == "photo" and isinstance(photo, list): + for size in photo: + if not isinstance(size, dict): + continue + file_id = size.get("file_id") + if not isinstance(file_id, str) or not file_id: + continue + width = size.get("width") + height = size.get("height") + file_size = size.get("file_size") + area = ( + width * height + if isinstance(width, int) and isinstance(height, int) + else -1 + ) + size_bytes = file_size if isinstance(file_size, int) else -1 + candidates.append((area, size_bytes, order, file_id)) + order += 1 + for nested in value.values(): + if isinstance(nested, dict | list | tuple): + visit(nested) + + if payload is not None: + visit(payload) + if not candidates: + return None + return max(candidates)[-1] async def send_rich_message( @@ -288,14 +389,28 @@ async def send_rich_message( markdown: str, *, reply_markup: InlineKeyboardMarkup | None = None, + photo: bytes | str | None = None, + disable_notification: bool | None = None, ) -> Message: - """Send a rich message via the raw API; returns the sent Message.""" + """Send rich content, optionally ending with one embedded photo. + + ``photo`` accepts raw bytes for a multipart upload or a Telegram ``file_id`` + for reuse. Omitting it preserves the pre-10.2 request shape exactly. + """ + photo_ref: str | None = None + upload: InputFile | None = None + if photo is not None: + photo_ref, upload = _photo_request_parts(photo) data: dict[str, Any] = { "chat_id": chat_id, - "rich_message": _input_rich_message(markdown), + "rich_message": _input_rich_message(markdown, photo_ref), } if reply_markup is not None: data["reply_markup"] = reply_markup + if disable_notification is not None: + data["disable_notification"] = disable_notification + if upload is not None: + data[_RICH_PHOTO_UPLOAD_FIELD] = upload result = await bot._post("sendRichMessage", data) # pyright: ignore[reportPrivateUsage] msg = Message.de_json(cast(dict[str, Any], result), bot) return msg @@ -308,13 +423,28 @@ async def edit_rich_message( markdown: str, *, reply_markup: InlineKeyboardMarkup | None = None, -) -> None: - """Replace a message's content with rich content via the raw API.""" + photo: bytes | str | None = None, +) -> Message | None: + """Replace a message with rich content and an optional embedded photo.""" + photo_ref: str | None = None + upload: InputFile | None = None + if photo is not None: + photo_ref, upload = _photo_request_parts(photo) data: dict[str, Any] = { "chat_id": chat_id, "message_id": message_id, - "rich_message": _input_rich_message(markdown), + "rich_message": _input_rich_message(markdown, photo_ref), } if reply_markup is not None: data["reply_markup"] = reply_markup - await bot._post("editMessageText", data) # pyright: ignore[reportPrivateUsage] + if upload is not None: + data[_RICH_PHOTO_UPLOAD_FIELD] = upload + result = await bot._post( # pyright: ignore[reportPrivateUsage] + "editMessageText", data + ) + if isinstance(result, Message): + return result + if isinstance(result, dict): + return Message.de_json(result, bot) + # Inline-message edits and lightweight test doubles may return True. + return None diff --git a/src/ccbot/session.py b/src/ccbot/session.py index 26e48db4..5b01a948 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -25,10 +25,9 @@ import asyncio import json import logging -import time from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any import aiofiles @@ -36,7 +35,11 @@ from telegram import Bot from .config import config +from .session_defaults import DEFAULT_IDLE_ARCHIVE_HOURS, IDLE_ARCHIVE_HOUR_CHOICES +from .session_keys import key_matches_window +from .session_map import SessionMapMixin from .session_models import ClaudeSession, Session, SessionState, WindowState +from .session_state import SessionStateMixin from .terminal_parser import is_interactive_ui, parse_status_line from .tmux_manager import tmux_manager from .transcript_parser import TranscriptParser @@ -45,10 +48,13 @@ # Re-export for callers that still import these names from `ccbot.session`. __all__ = [ "ClaudeSession", + "DEFAULT_IDLE_ARCHIVE_HOURS", + "IDLE_ARCHIVE_HOUR_CHOICES", "Session", "SessionState", "SessionManager", "WindowState", + "key_matches_window", "session_manager", ] @@ -65,39 +71,9 @@ # fire_typing's own throttle and Telegram's ~5s indicator decay. _RESUME_SETTLE_TYPING_REFRESH = 4.0 -# Per-user idle auto-archive choices exposed in Settings. Keep the accepted -# values central so UI, callback validation, and the archive sweep cannot -# drift apart. -IDLE_ARCHIVE_HOUR_CHOICES: tuple[int, ...] = (6, 12, 24) -DEFAULT_IDLE_ARCHIVE_HOURS = 6 - - -def key_matches_window(key: str, window_id: str) -> bool: - """True if a session_map.json key targets ``window_id`` in our tmux server. - - Accepts both canonical keys (``:``) and grouped- - session keys (``-w:``) — when ccbot's local- - terminal helper attaches a per-window grouped session, an old - Claude hook build that resolves ``#{session_name}`` lands on the - grouped name and writes the wrong-prefix variant. Newer hooks - prefer ``#{session_group}`` and produce canonical keys. - """ - base = config.tmux_session_name - suffix = f":{window_id}" - if not key.endswith(suffix): - return False - prefix = key[: -len(suffix)] - if prefix == base: - return True - grouped = f"{base}-w" - if not prefix.startswith(grouped): - return False - tail = prefix[len(grouped) :] - return bool(tail) and tail.isdigit() - @dataclass -class SessionManager: +class SessionManager(SessionMapMixin, SessionStateMixin): """Manages session state for Claude Code. All internal keys use window_id (e.g. '@0', '@12') for uniqueness. @@ -308,769 +284,6 @@ def update_display_name(self, window_id: str, new_name: str) -> None: self.save_state() logger.info("Updated display name: window_id %s -> '%s'", window_id, new_name) - # --- session_map.json polling (hook-written window_id -> session) --- - - async def wait_for_session_map_entry( - self, window_id: str, timeout: float = 5.0, interval: float = 0.5 - ) -> bool: - """Poll session_map.json until an entry for window_id appears. - - Accepts both canonical ``:`` keys and grouped- - session keys ``-w:`` — older Claude hook - builds wrote the latter when called from a client attached to a - grouped session (see ``hook.py`` for the canonical fix). - - Returns True if the entry was found within timeout, False otherwise. - """ - logger.debug( - "Waiting for session_map entry: window_id=%s, timeout=%.1f", - window_id, - timeout, - ) - deadline = asyncio.get_event_loop().time() + timeout - while asyncio.get_event_loop().time() < deadline: - try: - if config.session_map_file.exists(): - async with aiofiles.open(config.session_map_file, "r") as f: - content = await f.read() - session_map = json.loads(content) - if any( - info.get("session_id") - for k, info in session_map.items() - if key_matches_window(k, window_id) - ): - logger.debug( - "session_map entry found for window_id %s", window_id - ) - await self.load_session_map() - return True - except (json.JSONDecodeError, OSError): - pass - await asyncio.sleep(interval) - logger.warning( - "Timed out waiting for session_map entry: window_id=%s", window_id - ) - return False - - async def load_session_map(self) -> None: - """Serialize map reconciliation against bot-owned restore publication.""" - async with self._session_map_lock: - await self._load_session_map_unlocked() - - async def _load_session_map_unlocked(self) -> None: - """Read session_map.json and update window_states with new session associations. - - Accepts canonical (``:``) and grouped-session - (``-w:``) keys — see ``key_matches_window`` - for why the latter exists. Cleans up window_states entries not - present in the map. Updates window_display_names from the - ``window_name`` field in values. - """ - 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 - - valid_wids: set[str] = set() - changed = False - - for key, info in session_map.items(): - # Extract window_id from any accepted key shape. - window_id = "" - if ":" in key: - candidate = key.rsplit(":", 1)[1] - if self.is_window_id(candidate) and key_matches_window(key, candidate): - window_id = candidate - if not window_id: - continue - valid_wids.add(window_id) - new_sid = info.get("session_id", "") - new_cwd = info.get("cwd", "") - new_wname = info.get("window_name", "") - new_backend = info.get("backend", "claude") - new_transcript_path = info.get("transcript_path", "") - if not new_sid: - continue - state = self.get_window_state(window_id) - state.backend = ( - new_backend if new_backend in ("claude", "codex") else "claude" - ) - if state.transcript_path != new_transcript_path: - state.transcript_path = new_transcript_path - changed = True - if state.session_id != new_sid or state.cwd != new_cwd: - logger.info( - "Session map: window_id %s updated sid=%s, cwd=%s", - window_id, - new_sid, - new_cwd, - ) - state.session_id = new_sid - state.cwd = new_cwd - changed = True - # Mirror the claude session id onto any Session record bound to this window. - sess = self.find_session_by_window(window_id) - if sess is not None: - if sess.claude_session_id != new_sid: - sess.claude_session_id = new_sid - changed = True - if sess.backend != state.backend: - sess.backend = state.backend - changed = True - if not sess.workdir and new_cwd: - sess.workdir = new_cwd - changed = True - # Update display name - if new_wname: - state.window_name = new_wname - if self.window_display_names.get(window_id) != new_wname: - self.window_display_names[window_id] = new_wname - changed = True - - # A fresh Codex window has no session_map entry until its first prompt - # is accepted. Keep provisional state for every bot Session still - # bound to a window; deleting it here removed the transcript binding - # and made first-turn delivery impossible to prove. - bound_wids = { - sess.window_id for sess in self.sessions.values() if sess.window_id - } - stale_wids = [ - w - for w in self.window_states - if w and w not in valid_wids and w not in bound_wids - ] - for wid in stale_wids: - logger.info("Removing stale window_state: %s", wid) - del self.window_states[wid] - changed = True - - if changed: - self.save_state() - - async def publish_codex_restore_binding( - self, - *, - sess: Session, - user_id: int, - window_id: str, - window_name: str, - transcript_path: Path, - ) -> None: - """Publish a native Codex resume before exposing it as active. - - ``load_session_map`` used to delete the provisional WindowState before - Codex emitted its first hook. The manager lock covers the complete - file-publish + in-memory bind transaction, while the store's flock - coordinates the file update with the external hook process. - """ - if not transcript_path.is_file(): - raise RuntimeError(f"Codex rollout does not exist: {transcript_path}") - from .session_map_store import upsert_session_map_entry - - key = f"{config.tmux_session_name}:{window_id}" - entry = { - "session_id": sess.claude_session_id, - "cwd": sess.workdir, - "window_name": window_name, - "backend": "codex", - "transcript_path": str(transcript_path), - } - async with self._session_map_lock: - await asyncio.to_thread( - upsert_session_map_entry, - config.session_map_file, - key, - entry, - ) - state = self.get_window_state(window_id) - state.session_id = sess.claude_session_id - state.cwd = sess.workdir - state.window_name = window_name - state.backend = "codex" - state.transcript_path = str(transcript_path) - self.set_session_window(sess.id, window_id) - self.set_active_session(user_id, sess.id) - - # --- Window state management --- - - def get_window_state(self, window_id: str) -> WindowState: - """Get or create window state.""" - if window_id not in self.window_states: - self.window_states[window_id] = WindowState() - return self.window_states[window_id] - - def clear_window_session(self, window_id: str) -> None: - """Clear session association for a window (e.g., after /clear command).""" - state = self.get_window_state(window_id) - state.session_id = "" - self.save_state() - logger.info("Cleared session for window_id %s", window_id) - - async def list_sessions_for_directory(self, cwd: str) -> list[ClaudeSession]: - """List existing sessions for the configured backend.""" - from . import codex_session_io, session_claude_io - - io = codex_session_io if self.agent_backend == "codex" else session_claude_io - return await io.list_sessions_for_directory(cwd) - - async def resolve_session_for_window(self, window_id: str) -> ClaudeSession | None: - """Resolve a tmux window to the best matching Claude session. - - Uses persisted session_id + cwd; returns None if the file is gone - and clears the stale window-state pointer when that happens. - """ - from . import codex_session_io, session_claude_io - - state = self.get_window_state(window_id) - if not state.session_id or not state.cwd: - return None - - if state.backend == "codex": - session = await codex_session_io.get_session_direct( - state.session_id, - state.cwd, - state.transcript_path or None, - ) - else: - session = await session_claude_io.get_session_direct( - state.session_id, state.cwd - ) - if session: - return session - - logger.warning( - "Session file no longer exists for window_id %s (sid=%s, cwd=%s)", - window_id, - state.session_id, - state.cwd, - ) - state.session_id = "" - state.cwd = "" - self.save_state() - return None - - # --- User window offset management --- - - def update_user_window_offset( - self, user_id: int, window_id: str, offset: int - ) -> None: - """Update the user's last read offset for a window.""" - if user_id not in self.user_window_offsets: - self.user_window_offsets[user_id] = {} - self.user_window_offsets[user_id][window_id] = offset - self.save_state() - - # --- DM mode: active session management --- - - def get_active_session(self, user_id: int) -> "Session | None": - """Return the currently active Session for a user, or None.""" - sid = self.active_sessions.get(user_id) - if not sid: - return None - return self.sessions.get(sid) - - def get_active_window(self, user_id: int) -> str | None: - """Return the tmux window_id of the user's active session, or None.""" - sess = self.get_active_session(user_id) - if sess is None or not sess.window_id or sess.state not in ("active", "idle"): - return None - return sess.window_id - - def set_active_session(self, user_id: int, session_id: str) -> None: - """Make `session_id` the active session for `user_id`.""" - if session_id not in self.sessions: - raise KeyError(f"Unknown session id: {session_id}") - prev = self.active_sessions.get(user_id) - if prev and prev != session_id: - history = self.active_history.setdefault(user_id, []) - # Deduplicate — if prev is already in history, move it to top. - if prev in history: - history.remove(prev) - history.append(prev) - # Cap recent-history depth. - if len(history) > 10: - del history[: len(history) - 10] - self.active_sessions[user_id] = session_id - self.save_state() - sess = self.sessions[session_id] - logger.info( - "active_session_change user=%d prev=%s next=%s next_name=%s " - "next_window=%s next_state=%s", - user_id, - prev or "-", - session_id, - sess.name, - sess.window_id, - sess.state, - extra={ - "event": "active_session_change", - "user_id": user_id, - "prev_session_id": prev, - "next_session_id": session_id, - "next_session_name": sess.name, - "next_window_id": sess.window_id, - "next_session_state": sess.state, - }, - ) - - def list_user_sessions( - self, - user_id: int, - *, - states: tuple[SessionState, ...] = ("active", "idle"), - ) -> list["Session"]: - """List sessions for a user filtered by state. Active first, by name.""" - # In v0.1 every session is implicitly the bot's single user's; we still - # accept user_id so the public surface is uniform with other helpers. - del user_id # no per-user partitioning yet - out = [s for s in self.sessions.values() if s.state in states] - out.sort(key=lambda s: (s.state != "active", s.name or s.id)) - return out - - def get_session(self, session_id: str) -> "Session | None": - return self.sessions.get(session_id) - - def find_session_by_window(self, window_id: str) -> "Session | None": - for s in self.sessions.values(): - if s.window_id == window_id and s.state in ("active", "idle"): - return s - return None - - def create_session( - self, - *, - name: str = "", - window_id: str = "", - workdir: str = "", - goal: str = "", - backend: str | None = None, - ) -> "Session": - """Register a new Session record. Caller is responsible for the tmux window.""" - now = time.time() - sid = Session.new_id() - # Avoid id collision in pathological case - while sid in self.sessions: - sid = Session.new_id() - if not name: - name = f"session-{len(self.sessions) + 1}" - sess = Session( - id=sid, - name=name, - window_id=window_id, - workdir=workdir, - goal=goal, - state="active", - created_at=now, - last_event_at=now, - backend=backend or self.agent_backend, - ) - self.sessions[sid] = sess - self.save_state() - from . import metrics - - metrics.inc("sessions_created") - logger.info("Created session %s (%s) on window %s", sid, name, window_id or "-") - return sess - - def touch_session(self, session_id: str) -> None: - """Bump last_event_at to now and persist.""" - sess = self.sessions.get(session_id) - if not sess: - return - sess.last_event_at = time.time() - # Don't save on every touch; callers batch via _save_state when appropriate. - - def mark_session_archived( - self, session_id: str, *, completed: bool = False - ) -> None: - """Move a session to archived/completed state, drop window_id binding.""" - sess = self.sessions.get(session_id) - if not sess: - return - if sess.state == "lost": - # Carry the lost-marker into archival so /archive can tag it - # explicitly (per user feedback on pivot #38). Without this - # the row reads identical to a clean archive and the fact - # that the tmux window died externally is lost forever. - sess.was_lost = True - sess.state = "completed" if completed else "archived" - sess.archived_at = time.time() - sess.window_id = "" - # If this was anyone's active session, auto-pick the - # previously-active session as the replacement (per user - # request: "при удалении активной сессии необходимо - # автоматически выбирать последнюю активную до нее"). Walks - # ``active_history`` newest-first, skipping any entries that - # are themselves no longer live. - for uid, sid in list(self.active_sessions.items()): - if sid != session_id: - continue - del self.active_sessions[uid] - history = self.active_history.get(uid, []) - # Also drop the just-archived session from history if - # present so it can't be re-picked later. - while session_id in history: - history.remove(session_id) - while history: - candidate_id = history.pop() - candidate = self.sessions.get(candidate_id) - if candidate is not None and candidate.state in ( - "active", - "idle", - ): - self.active_sessions[uid] = candidate_id - logger.info( - "auto_active_replacement user=%d killed=%s -> %s", - uid, - session_id, - candidate_id, - extra={ - "event": "auto_active_replacement", - "user_id": uid, - "killed_session_id": session_id, - "new_active_session_id": candidate_id, - }, - ) - break - # Drop any bg-status panel entry — an archived session shouldn't - # linger as a stale ✅/❓ badge on the next user message. - from .handlers import bg_status - - bg_status.clear_for_session(session_id) - self.save_state() - from . import metrics - - metrics.inc("sessions_completed" if completed else "sessions_archived") - logger.info("Archived session %s (completed=%s)", session_id, completed) - - def mark_session_lost(self, session_id: str) -> None: - """Mark a session as lost (its tmux window vanished externally).""" - sess = self.sessions.get(session_id) - if not sess: - return - sess.state = "lost" - sess.window_id = "" - # Lost sessions can't make progress; remove from the bg panel. - from .handlers import bg_status - - bg_status.clear_for_session(session_id) - self.save_state() - logger.warning("Session %s marked lost", session_id) - - def list_archived( - self, - *, - max_age_seconds: float | None = None, - states: tuple[SessionState, ...] = ("archived", "completed", "lost"), - ) -> list["Session"]: - """Return archived/completed/lost sessions, newest first. - - If `max_age_seconds` is given, only sessions whose archived_at is - within that window are returned. - """ - now = time.time() - out: list[Session] = [] - for s in self.sessions.values(): - if s.state not in states: - continue - if max_age_seconds is not None: - # Use archived_at if set, else last_event_at as fallback. - anchor = s.archived_at or s.last_event_at or s.created_at - if anchor and (now - anchor) > max_age_seconds: - continue - out.append(s) - out.sort(key=lambda s: s.archived_at or s.last_event_at or 0, reverse=True) - return out - - def find_idle_to_archive(self, idle_seconds: float) -> list["Session"]: - """Return active/idle sessions that have crossed the idle TTL threshold.""" - if idle_seconds <= 0: - return [] - now = time.time() - out: list[Session] = [] - for s in self.sessions.values(): - if s.state not in ("active", "idle"): - continue - anchor = s.last_event_at or s.created_at - if anchor and (now - anchor) >= idle_seconds: - out.append(s) - return out - - def find_archive_to_purge(self, purge_after_seconds: float) -> list["Session"]: - """Return archived/completed/lost sessions older than the purge threshold.""" - if purge_after_seconds <= 0: - return [] - now = time.time() - out: list[Session] = [] - for s in self.sessions.values(): - if s.state not in ("archived", "completed", "lost"): - continue - anchor = s.archived_at or s.last_event_at or s.created_at - if anchor and (now - anchor) >= purge_after_seconds: - out.append(s) - return out - - def delete_session(self, session_id: str) -> bool: - """Permanently remove a Session record. Transcripts on disk are kept.""" - if session_id not in self.sessions: - return False - del self.sessions[session_id] - # Defensive auto-replacement: delete is normally called on already- - # archived sessions, but if a record is purged while still listed as - # active, walk active_history newest-first to pick a successor (same - # rule as ``mark_session_archived``). - for uid, sid in list(self.active_sessions.items()): - if sid != session_id: - continue - del self.active_sessions[uid] - history = self.active_history.get(uid, []) - while history: - candidate_id = history.pop() - candidate = self.sessions.get(candidate_id) - if candidate is not None and candidate.state in ("active", "idle"): - self.active_sessions[uid] = candidate_id - break - for hist in self.active_history.values(): - while session_id in hist: - hist.remove(session_id) - from .handlers import bg_status - - bg_status.clear_for_session(session_id) - self.save_state() - logger.info("Deleted session record %s", session_id) - return True - - # --- User settings (set via the inline ⚙ menu) --- - - DEFAULT_USER_SETTINGS: ClassVar[dict[str, Any]] = { - "language": "en", # "en" | "ru" | "zh" — UI strings - "live_lag": 4, # seconds, see PREVIEW_LIVE_LAG - "voice": "auto", # "auto" | "whisper" | "apple" | "off" - # Hours without activity before a live session is archived. 6h is the - # closest supported migration from the historical global 4h default. - "session_idle_hours": DEFAULT_IDLE_ARCHIVE_HOURS, - # Day-of-week the Anthropic weekly window resets on. Drives the %/d - # burn-rate computation in Menu → Status. Values: "mon".."sun". - "weekly_reset_day": "mon", - # Auto-approve interactive Yes/No prompts that --dangerously-skip- - # permissions doesn't already bypass (e.g. WebFetch per-domain - # trust). "off" = surface in TG, "on" = auto-Yes on every prompt. - "auto_approve": "off", - # Three states for the desktop terminal companion: - # off — never spawn, never offer - # manual — don't auto-spawn, but show "Open terminal" in Menu - # when the active session has no attached tmux client - # auto — auto-spawn on session create AND show the manual - # button whenever no client is attached - # On Linux ``manual``/``auto`` also need ``local_terminal_cmd`` - # (or CCBOT_LOCAL_TERMINAL_CMD env) — without an emulator template - # the button is hidden because the click would silently no-op. - # Legacy binary "on" is auto-migrated to "auto" on read. - "local_terminal": "off", - # Linux: command template used by ``local_terminal``. Empty means - # "fall back to CCBOT_LOCAL_TERMINAL_CMD or skip". Templates are - # picked from a known list in Settings → Local terminal, or set - # manually via env. Use ``{shell}`` as the placeholder for the - # shell-quoted attach snippet. - "local_terminal_cmd": "", - # Disposition of the user's outgoing text relative to the live - # How many trailing end_turn boundaries to pull from the JSONL - # transcript when seeding an empty live-card state (e.g. after - # a bot restart, after switcher-tap / Menu → Sessions on a fresh - # state). Higher = more in-card scrollback at the cost of memory - # (each turn ≈ several events × ~500 bytes). Deep history is - # always accessible via /history regardless of this setting. - "card_history": 20, - # Inline screenshots — photo of the pane is embedded in the - # active session card msg (photo+caption) instead of being a - # separate Shot photo accessed via Menu→Shot. Updates on every - # event but throttled to 1 photo-edit per 3 sec; skips refresh - # when pane unchanged. Note: TG caption limit is 1024 chars vs - # text 4096 — page size effectively shrinks ~4x when ON. - "card_inline_screenshots": False, - # Bg session push notifications (Task #42). Three independent - # toggles — user asked to make each granular. Default all-on - # so the user knows what bg sessions are doing. - "bg_notify_finished": True, - "bg_notify_error": True, - "bg_notify_needs_action": True, - # Max page size in logical \n-delimited LINES. Values 10/20/40/70. - # 20 keeps the card compact on phone; 70 is for power users who - # scroll long bodies. Anchor (page top) chunking handles overflow - # with smart sentence / paragraph boundaries — see - # ``_chunk_final_text`` for the exact preference order. - "card_page_lines": 20, - # Auto-rename new sessions via a cheap one-shot model call after the - # first user message ≥20 chars. When ``False``, names stay as - # the directory basename (``workdir``, ``workdir-2``, ...) for - # the session's lifetime. The persisted key keeps its historical - # name for state-file compatibility. - "haiku_naming": True, - } - - def get_user_settings(self, user_id: int) -> dict[str, Any]: - """Return the user's settings, filling in defaults for missing keys.""" - stored = self.user_settings.get(user_id, {}) - merged: dict[str, Any] = dict(self.DEFAULT_USER_SETTINGS) - merged.update(stored) - # Backwards-compat: the old binary value "on" maps to the new - # 3-state "auto". Read-side only; stored value lingers until the - # user picks something on the settings screen. - if merged.get("local_terminal") == "on": - merged["local_terminal"] = "auto" - return merged - - def update_user_setting(self, user_id: int, key: str, value: Any) -> None: - """Persist a single user setting.""" - if key not in self.DEFAULT_USER_SETTINGS: - raise ValueError(f"Unknown setting key: {key}") - bucket = self.user_settings.setdefault(user_id, {}) - bucket[key] = value - self.save_state() - - def set_agent_backend(self, backend: str) -> None: - """Persist the bot-wide backend used for every newly created session. - - Switching while a live session exists is rejected: a bot instance is - deliberately single-backend at runtime. Archive/kill live sessions - first; historical records retain their backend for safe inspection. - """ - if backend not in ("claude", "codex"): - raise ValueError(f"Unknown agent backend: {backend}") - if backend == self.agent_backend: - return - live = [ - sess - for sess in self.sessions.values() - if sess.state in ("active", "idle") and sess.backend != backend - ] - if live: - raise RuntimeError("archive live sessions before switching backend") - self.agent_backend = backend - config.agent_backend = backend - self.save_state() - logger.info("Bot-wide agent backend changed to %s", backend) - - # --- Summary cache (agent session id -> short readable summary) --- - - def get_cached_summary( - self, claude_session_id: str, file_mtime: float - ) -> str | None: - """Return cached summary if mtime matches; otherwise None.""" - entry = self.summary_cache.get(claude_session_id) - if not entry: - return None - if abs(float(entry.get("mtime", 0.0)) - file_mtime) > 1e-3: - return None - return entry.get("summary") or None - - def set_cached_summary( - self, claude_session_id: str, summary: str, file_mtime: float - ) -> None: - """Persist a generated summary for an agent session id.""" - if not claude_session_id or not summary: - return - self.summary_cache[claude_session_id] = { - "summary": summary, - "mtime": file_mtime, - "ts": time.time(), - } - self.save_state() - - def rename_session(self, session_id: str, new_name: str) -> None: - sess = self.sessions.get(session_id) - if not sess: - return - sess.name = new_name - self.save_state() - - def set_session_window(self, session_id: str, window_id: str) -> None: - """Re-attach a session to a (possibly new) tmux window after restore. - - A restored (or re-bound lost) session re-enters as if freshly created: - ``created_at`` is bumped to now so the oldest -> newest switcher slots - it at the far right rather than back in its original position. - """ - sess = self.sessions.get(session_id) - if not sess: - return - now = time.time() - sess.window_id = window_id - sess.state = "active" - sess.created_at = now - sess.last_event_at = now - self.save_state() - - def set_session_claude_id(self, session_id: str, claude_session_id: str) -> None: - sess = self.sessions.get(session_id) - if not sess: - return - if sess.claude_session_id != claude_session_id: - sess.claude_session_id = claude_session_id - self.save_state() - - def get_last_switcher_msg(self, user_id: int) -> int | None: - return self.last_switcher_msg_id.get(user_id) - - def set_last_switcher_msg(self, user_id: int, message_id: int) -> None: - self.last_switcher_msg_id[user_id] = message_id - # Persist eagerly: cheap, helps survive bot restart for switcher cleanup. - self.save_state() - - def clear_last_switcher_msg(self, user_id: int) -> None: - if user_id in self.last_switcher_msg_id: - del self.last_switcher_msg_id[user_id] - self.save_state() - - def get_card_msg(self, user_id: int) -> int | None: - return self.card_msg_id.get(user_id) - - def set_card_msg(self, user_id: int, message_id: int) -> None: - if self.card_msg_id.get(user_id) == message_id: - return - self.card_msg_id[user_id] = message_id - # Persist eagerly so a restart can repaint the live card in place. - self.save_state() - - def clear_card_msg(self, user_id: int) -> None: - if user_id in self.card_msg_id: - del self.card_msg_id[user_id] - self.save_state() - - # --- Reverse map: claude_session_id -> user(s) via active_sessions --- - - def all_user_sessions_with_claude_id( - self, claude_session_id: str - ) -> list[tuple[int, "Session"]]: - """Return [(user_id, Session)] including non-active sessions for that claude id. - - Used to drive background-session live-card edits even when the session - is not active for any user. - - The session pool is global (shared workspace), so a claude event is - fanned out to **every** allowed user — each gets their own live card / - panel in their own DM. With a single allowed user (the common case) - this collapses to one (user_id, Session) per match, identical to the - previous single-user behaviour. Users are sorted for deterministic - ordering. - """ - if not config.allowed_users: - return [] - matched = [ - sess - for sess in self.sessions.values() - if sess.claude_session_id == claude_session_id - ] - out: list[tuple[int, "Session"]] = [] - for user_id in sorted(config.allowed_users): - for sess in matched: - out.append((user_id, sess)) - return out - # --- Tmux helpers --- def mark_window_resuming( diff --git a/src/ccbot/session_defaults.py b/src/ccbot/session_defaults.py new file mode 100644 index 00000000..c0d1d2b6 --- /dev/null +++ b/src/ccbot/session_defaults.py @@ -0,0 +1,4 @@ +"""Shared defaults for persisted session settings.""" + +IDLE_ARCHIVE_HOUR_CHOICES: tuple[int, ...] = (6, 12, 24) +DEFAULT_IDLE_ARCHIVE_HOURS = 6 diff --git a/src/ccbot/session_keys.py b/src/ccbot/session_keys.py new file mode 100644 index 00000000..ad8610d4 --- /dev/null +++ b/src/ccbot/session_keys.py @@ -0,0 +1,19 @@ +"""Canonical matching for hook-written tmux window keys.""" + +from .config import config + + +def key_matches_window(key: str, window_id: str) -> bool: + """True if a session-map key targets window_id in our tmux server.""" + base = config.tmux_session_name + suffix = f":{window_id}" + if not key.endswith(suffix): + return False + prefix = key[: -len(suffix)] + if prefix == base: + return True + grouped = f"{base}-w" + if not prefix.startswith(grouped): + return False + tail = prefix[len(grouped) :] + return bool(tail) and tail.isdigit() diff --git a/src/ccbot/session_map.py b/src/ccbot/session_map.py new file mode 100644 index 00000000..0c63ad91 --- /dev/null +++ b/src/ccbot/session_map.py @@ -0,0 +1,282 @@ +"""Session-map and transcript-resolution behavior for SessionManager. + +This mixin owns hook-written session-map reconciliation, window bindings, +and backend-specific transcript lookup. The public SessionManager remains in +ccbot.session; this module is an implementation leaf. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path +from typing import Any + +import aiofiles + +from .config import config +from .session_keys import key_matches_window +from .session_models import ClaudeSession, Session, WindowState + +logger = logging.getLogger("ccbot.session") + + +class SessionMapMixin: + """Window/session-map operations mixed into SessionManager.""" + + _session_map_lock: asyncio.Lock + window_states: dict[str, WindowState] + window_display_names: dict[str, str] + sessions: dict[str, Session] + agent_backend: str + is_window_id: Any + save_state: Any + find_session_by_window: Any + set_session_window: Any + set_active_session: Any + + # --- session_map.json polling (hook-written window_id -> session) --- + + async def wait_for_session_map_entry( + self, window_id: str, timeout: float = 5.0, interval: float = 0.5 + ) -> bool: + """Poll session_map.json until an entry for window_id appears. + + Accepts both canonical ``:`` keys and grouped- + session keys ``-w:`` — older Claude hook + builds wrote the latter when called from a client attached to a + grouped session (see ``hook.py`` for the canonical fix). + + Returns True if the entry was found within timeout, False otherwise. + """ + logger.debug( + "Waiting for session_map entry: window_id=%s, timeout=%.1f", + window_id, + timeout, + ) + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + try: + if config.session_map_file.exists(): + async with aiofiles.open(config.session_map_file, "r") as f: + content = await f.read() + session_map = json.loads(content) + if any( + info.get("session_id") + for k, info in session_map.items() + if key_matches_window(k, window_id) + ): + logger.debug( + "session_map entry found for window_id %s", window_id + ) + await self.load_session_map() + return True + except (json.JSONDecodeError, OSError): + pass + await asyncio.sleep(interval) + logger.warning( + "Timed out waiting for session_map entry: window_id=%s", window_id + ) + return False + + async def load_session_map(self) -> None: + """Serialize map reconciliation against bot-owned restore publication.""" + async with self._session_map_lock: + await self._load_session_map_unlocked() + + async def _load_session_map_unlocked(self) -> None: + """Read session_map.json and update window_states with new session associations. + + Accepts canonical (``:``) and grouped-session + (``-w:``) keys — see ``key_matches_window`` + for why the latter exists. Cleans up window_states entries not + present in the map. Updates window_display_names from the + ``window_name`` field in values. + """ + 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 + + valid_wids: set[str] = set() + changed = False + + for key, info in session_map.items(): + # Extract window_id from any accepted key shape. + window_id = "" + if ":" in key: + candidate = key.rsplit(":", 1)[1] + if self.is_window_id(candidate) and key_matches_window(key, candidate): + window_id = candidate + if not window_id: + continue + valid_wids.add(window_id) + new_sid = info.get("session_id", "") + new_cwd = info.get("cwd", "") + new_wname = info.get("window_name", "") + new_backend = info.get("backend", "claude") + new_transcript_path = info.get("transcript_path", "") + if not new_sid: + continue + state = self.get_window_state(window_id) + state.backend = ( + new_backend if new_backend in ("claude", "codex") else "claude" + ) + if state.transcript_path != new_transcript_path: + state.transcript_path = new_transcript_path + changed = True + if state.session_id != new_sid or state.cwd != new_cwd: + logger.info( + "Session map: window_id %s updated sid=%s, cwd=%s", + window_id, + new_sid, + new_cwd, + ) + state.session_id = new_sid + state.cwd = new_cwd + changed = True + # Mirror the claude session id onto any Session record bound to this window. + sess = self.find_session_by_window(window_id) + if sess is not None: + if sess.claude_session_id != new_sid: + sess.claude_session_id = new_sid + changed = True + if sess.backend != state.backend: + sess.backend = state.backend + changed = True + if not sess.workdir and new_cwd: + sess.workdir = new_cwd + changed = True + # Update display name + if new_wname: + state.window_name = new_wname + if self.window_display_names.get(window_id) != new_wname: + self.window_display_names[window_id] = new_wname + changed = True + + # A fresh Codex window has no session_map entry until its first prompt + # is accepted. Keep provisional state for every bot Session still + # bound to a window; deleting it here removed the transcript binding + # and made first-turn delivery impossible to prove. + bound_wids = { + sess.window_id for sess in self.sessions.values() if sess.window_id + } + stale_wids = [ + w + for w in self.window_states + if w and w not in valid_wids and w not in bound_wids + ] + for wid in stale_wids: + logger.info("Removing stale window_state: %s", wid) + del self.window_states[wid] + changed = True + + if changed: + self.save_state() + + async def publish_codex_restore_binding( + self, + *, + sess: Session, + user_id: int, + window_id: str, + window_name: str, + transcript_path: Path, + ) -> None: + """Publish a native Codex resume before exposing it as active. + + ``load_session_map`` used to delete the provisional WindowState before + Codex emitted its first hook. The manager lock covers the complete + file-publish + in-memory bind transaction, while the store's flock + coordinates the file update with the external hook process. + """ + if not transcript_path.is_file(): + raise RuntimeError(f"Codex rollout does not exist: {transcript_path}") + from .session_map_store import upsert_session_map_entry + + key = f"{config.tmux_session_name}:{window_id}" + entry = { + "session_id": sess.claude_session_id, + "cwd": sess.workdir, + "window_name": window_name, + "backend": "codex", + "transcript_path": str(transcript_path), + } + async with self._session_map_lock: + await asyncio.to_thread( + upsert_session_map_entry, + config.session_map_file, + key, + entry, + ) + state = self.get_window_state(window_id) + state.session_id = sess.claude_session_id + state.cwd = sess.workdir + state.window_name = window_name + state.backend = "codex" + state.transcript_path = str(transcript_path) + self.set_session_window(sess.id, window_id) + self.set_active_session(user_id, sess.id) + + # --- Window state management --- + + def get_window_state(self, window_id: str) -> WindowState: + """Get or create window state.""" + if window_id not in self.window_states: + self.window_states[window_id] = WindowState() + return self.window_states[window_id] + + def clear_window_session(self, window_id: str) -> None: + """Clear session association for a window (e.g., after /clear command).""" + state = self.get_window_state(window_id) + state.session_id = "" + self.save_state() + logger.info("Cleared session for window_id %s", window_id) + + async def list_sessions_for_directory(self, cwd: str) -> list[ClaudeSession]: + """List existing sessions for the configured backend.""" + from . import codex_session_io, session_claude_io + + io = codex_session_io if self.agent_backend == "codex" else session_claude_io + return await io.list_sessions_for_directory(cwd) + + async def resolve_session_for_window(self, window_id: str) -> ClaudeSession | None: + """Resolve a tmux window to the best matching Claude session. + + Uses persisted session_id + cwd; returns None if the file is gone + and clears the stale window-state pointer when that happens. + """ + from . import codex_session_io, session_claude_io + + state = self.get_window_state(window_id) + if not state.session_id or not state.cwd: + return None + + if state.backend == "codex": + session = await codex_session_io.get_session_direct( + state.session_id, + state.cwd, + state.transcript_path or None, + ) + else: + session = await session_claude_io.get_session_direct( + state.session_id, state.cwd + ) + if session: + return session + + logger.warning( + "Session file no longer exists for window_id %s (sid=%s, cwd=%s)", + window_id, + state.session_id, + state.cwd, + ) + state.session_id = "" + state.cwd = "" + self.save_state() + return None diff --git a/src/ccbot/session_state.py b/src/ccbot/session_state.py new file mode 100644 index 00000000..99e73b6c --- /dev/null +++ b/src/ccbot/session_state.py @@ -0,0 +1,551 @@ +"""Persisted DM session-pool operations for SessionManager. + +This mixin contains active-session routing, archive lifecycle, user settings, +summary caching, and Telegram carrier identifiers. The public class and +singleton continue to live in ccbot.session. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, ClassVar + +from .config import config +from .session_defaults import DEFAULT_IDLE_ARCHIVE_HOURS +from .session_models import Session, SessionState + +logger = logging.getLogger("ccbot.session") + + +class SessionStateMixin: + """DM routing and persisted session-state operations.""" + + user_window_offsets: dict[int, dict[str, int]] + active_sessions: dict[int, str] + active_history: dict[int, list[str]] + sessions: dict[str, Session] + user_settings: dict[int, dict[str, Any]] + summary_cache: dict[str, dict[str, Any]] + last_switcher_msg_id: dict[int, int] + card_msg_id: dict[int, int] + agent_backend: str + save_state: Any + get_display_name: Any + + # --- User window offset management --- + + def update_user_window_offset( + self, user_id: int, window_id: str, offset: int + ) -> None: + """Update the user's last read offset for a window.""" + if user_id not in self.user_window_offsets: + self.user_window_offsets[user_id] = {} + self.user_window_offsets[user_id][window_id] = offset + self.save_state() + + # --- DM mode: active session management --- + + def get_active_session(self, user_id: int) -> "Session | None": + """Return the currently active Session for a user, or None.""" + sid = self.active_sessions.get(user_id) + if not sid: + return None + return self.sessions.get(sid) + + def get_active_window(self, user_id: int) -> str | None: + """Return the tmux window_id of the user's active session, or None.""" + sess = self.get_active_session(user_id) + if sess is None or not sess.window_id or sess.state not in ("active", "idle"): + return None + return sess.window_id + + def set_active_session(self, user_id: int, session_id: str) -> None: + """Make `session_id` the active session for `user_id`.""" + if session_id not in self.sessions: + raise KeyError(f"Unknown session id: {session_id}") + prev = self.active_sessions.get(user_id) + if prev and prev != session_id: + history = self.active_history.setdefault(user_id, []) + # Deduplicate — if prev is already in history, move it to top. + if prev in history: + history.remove(prev) + history.append(prev) + # Cap recent-history depth. + if len(history) > 10: + del history[: len(history) - 10] + self.active_sessions[user_id] = session_id + self.save_state() + sess = self.sessions[session_id] + logger.info( + "active_session_change user=%d prev=%s next=%s next_name=%s " + "next_window=%s next_state=%s", + user_id, + prev or "-", + session_id, + sess.name, + sess.window_id, + sess.state, + extra={ + "event": "active_session_change", + "user_id": user_id, + "prev_session_id": prev, + "next_session_id": session_id, + "next_session_name": sess.name, + "next_window_id": sess.window_id, + "next_session_state": sess.state, + }, + ) + + def list_user_sessions( + self, + user_id: int, + *, + states: tuple[SessionState, ...] = ("active", "idle"), + ) -> list["Session"]: + """List sessions for a user filtered by state. Active first, by name.""" + # In v0.1 every session is implicitly the bot's single user's; we still + # accept user_id so the public surface is uniform with other helpers. + del user_id # no per-user partitioning yet + out = [s for s in self.sessions.values() if s.state in states] + out.sort(key=lambda s: (s.state != "active", s.name or s.id)) + return out + + def get_session(self, session_id: str) -> "Session | None": + return self.sessions.get(session_id) + + def find_session_by_window(self, window_id: str) -> "Session | None": + for s in self.sessions.values(): + if s.window_id == window_id and s.state in ("active", "idle"): + return s + return None + + def create_session( + self, + *, + name: str = "", + window_id: str = "", + workdir: str = "", + goal: str = "", + backend: str | None = None, + ) -> "Session": + """Register a new Session record. Caller is responsible for the tmux window.""" + now = time.time() + sid = Session.new_id() + # Avoid id collision in pathological case + while sid in self.sessions: + sid = Session.new_id() + if not name: + name = f"session-{len(self.sessions) + 1}" + sess = Session( + id=sid, + name=name, + window_id=window_id, + workdir=workdir, + goal=goal, + state="active", + created_at=now, + last_event_at=now, + backend=backend or self.agent_backend, + ) + self.sessions[sid] = sess + self.save_state() + from . import metrics + + metrics.inc("sessions_created") + logger.info("Created session %s (%s) on window %s", sid, name, window_id or "-") + return sess + + def touch_session(self, session_id: str) -> None: + """Bump last_event_at to now and persist.""" + sess = self.sessions.get(session_id) + if not sess: + return + sess.last_event_at = time.time() + # Don't save on every touch; callers batch via _save_state when appropriate. + + def mark_session_archived( + self, session_id: str, *, completed: bool = False + ) -> None: + """Move a session to archived/completed state, drop window_id binding.""" + sess = self.sessions.get(session_id) + if not sess: + return + if sess.state == "lost": + # Carry the lost-marker into archival so /archive can tag it + # explicitly (per user feedback on pivot #38). Without this + # the row reads identical to a clean archive and the fact + # that the tmux window died externally is lost forever. + sess.was_lost = True + sess.state = "completed" if completed else "archived" + sess.archived_at = time.time() + sess.window_id = "" + # If this was anyone's active session, auto-pick the + # previously-active session as the replacement (per user + # request: "при удалении активной сессии необходимо + # автоматически выбирать последнюю активную до нее"). Walks + # ``active_history`` newest-first, skipping any entries that + # are themselves no longer live. + for uid, sid in list(self.active_sessions.items()): + if sid != session_id: + continue + del self.active_sessions[uid] + history = self.active_history.get(uid, []) + # Also drop the just-archived session from history if + # present so it can't be re-picked later. + while session_id in history: + history.remove(session_id) + while history: + candidate_id = history.pop() + candidate = self.sessions.get(candidate_id) + if candidate is not None and candidate.state in ( + "active", + "idle", + ): + self.active_sessions[uid] = candidate_id + logger.info( + "auto_active_replacement user=%d killed=%s -> %s", + uid, + session_id, + candidate_id, + extra={ + "event": "auto_active_replacement", + "user_id": uid, + "killed_session_id": session_id, + "new_active_session_id": candidate_id, + }, + ) + break + # Drop any bg-status panel entry — an archived session shouldn't + # linger as a stale ✅/❓ badge on the next user message. + from .handlers import bg_status + + bg_status.clear_for_session(session_id) + self.save_state() + from . import metrics + + metrics.inc("sessions_completed" if completed else "sessions_archived") + logger.info("Archived session %s (completed=%s)", session_id, completed) + + def mark_session_lost(self, session_id: str) -> None: + """Mark a session as lost (its tmux window vanished externally).""" + sess = self.sessions.get(session_id) + if not sess: + return + sess.state = "lost" + sess.window_id = "" + # Lost sessions can't make progress; remove from the bg panel. + from .handlers import bg_status + + bg_status.clear_for_session(session_id) + self.save_state() + logger.warning("Session %s marked lost", session_id) + + def list_archived( + self, + *, + max_age_seconds: float | None = None, + states: tuple[SessionState, ...] = ("archived", "completed", "lost"), + ) -> list["Session"]: + """Return archived/completed/lost sessions, newest first. + + If `max_age_seconds` is given, only sessions whose archived_at is + within that window are returned. + """ + now = time.time() + out: list[Session] = [] + for s in self.sessions.values(): + if s.state not in states: + continue + if max_age_seconds is not None: + # Use archived_at if set, else last_event_at as fallback. + anchor = s.archived_at or s.last_event_at or s.created_at + if anchor and (now - anchor) > max_age_seconds: + continue + out.append(s) + out.sort(key=lambda s: s.archived_at or s.last_event_at or 0, reverse=True) + return out + + def find_idle_to_archive(self, idle_seconds: float) -> list["Session"]: + """Return active/idle sessions that have crossed the idle TTL threshold.""" + if idle_seconds <= 0: + return [] + now = time.time() + out: list[Session] = [] + for s in self.sessions.values(): + if s.state not in ("active", "idle"): + continue + anchor = s.last_event_at or s.created_at + if anchor and (now - anchor) >= idle_seconds: + out.append(s) + return out + + def find_archive_to_purge(self, purge_after_seconds: float) -> list["Session"]: + """Return archived/completed/lost sessions older than the purge threshold.""" + if purge_after_seconds <= 0: + return [] + now = time.time() + out: list[Session] = [] + for s in self.sessions.values(): + if s.state not in ("archived", "completed", "lost"): + continue + anchor = s.archived_at or s.last_event_at or s.created_at + if anchor and (now - anchor) >= purge_after_seconds: + out.append(s) + return out + + def delete_session(self, session_id: str) -> bool: + """Permanently remove a Session record. Transcripts on disk are kept.""" + if session_id not in self.sessions: + return False + del self.sessions[session_id] + # Defensive auto-replacement: delete is normally called on already- + # archived sessions, but if a record is purged while still listed as + # active, walk active_history newest-first to pick a successor (same + # rule as ``mark_session_archived``). + for uid, sid in list(self.active_sessions.items()): + if sid != session_id: + continue + del self.active_sessions[uid] + history = self.active_history.get(uid, []) + while history: + candidate_id = history.pop() + candidate = self.sessions.get(candidate_id) + if candidate is not None and candidate.state in ("active", "idle"): + self.active_sessions[uid] = candidate_id + break + for hist in self.active_history.values(): + while session_id in hist: + hist.remove(session_id) + from .handlers import bg_status + + bg_status.clear_for_session(session_id) + self.save_state() + logger.info("Deleted session record %s", session_id) + return True + + # --- User settings (set via the inline ⚙ menu) --- + + DEFAULT_USER_SETTINGS: ClassVar[dict[str, Any]] = { + "language": "en", # "en" | "ru" | "zh" — UI strings + "live_lag": 4, # seconds, see PREVIEW_LIVE_LAG + "voice": "auto", # "auto" | "whisper" | "apple" | "off" + # Hours without activity before a live session is archived. 6h is the + # closest supported migration from the historical global 4h default. + "session_idle_hours": DEFAULT_IDLE_ARCHIVE_HOURS, + # Day-of-week the Anthropic weekly window resets on. Drives the %/d + # burn-rate computation in Menu → Status. Values: "mon".."sun". + "weekly_reset_day": "mon", + # Auto-approve interactive Yes/No prompts that --dangerously-skip- + # permissions doesn't already bypass (e.g. WebFetch per-domain + # trust). "off" = surface in TG, "on" = auto-Yes on every prompt. + "auto_approve": "off", + # Three states for the desktop terminal companion: + # off — never spawn, never offer + # manual — don't auto-spawn, but show "Open terminal" in Menu + # when the active session has no attached tmux client + # auto — auto-spawn on session create AND show the manual + # button whenever no client is attached + # On Linux ``manual``/``auto`` also need ``local_terminal_cmd`` + # (or CCBOT_LOCAL_TERMINAL_CMD env) — without an emulator template + # the button is hidden because the click would silently no-op. + # Legacy binary "on" is auto-migrated to "auto" on read. + "local_terminal": "off", + # Linux: command template used by ``local_terminal``. Empty means + # "fall back to CCBOT_LOCAL_TERMINAL_CMD or skip". Templates are + # picked from a known list in Settings → Local terminal, or set + # manually via env. Use ``{shell}`` as the placeholder for the + # shell-quoted attach snippet. + "local_terminal_cmd": "", + # Disposition of the user's outgoing text relative to the live + # How many trailing end_turn boundaries to pull from the JSONL + # transcript when seeding an empty live-card state (e.g. after + # a bot restart, after switcher-tap / Menu → Sessions on a fresh + # state). Higher = more in-card scrollback at the cost of memory + # (each turn ≈ several events × ~500 bytes). Deep history is + # always accessible via /history regardless of this setting. + "card_history": 20, + # Inline screenshots — the pane render is the last media block of + # the active Rich Markdown card instead of a separate Shot photo. + # Updates are throttled to one media edit per ~3 sec and skipped + # when the pane is unchanged. Older Bot API servers fall back to + # the legacy photo+caption transport. + "card_inline_screenshots": False, + # Bg session push notifications (Task #42). Three independent + # toggles — user asked to make each granular. Default all-on + # so the user knows what bg sessions are doing. + "bg_notify_finished": True, + "bg_notify_error": True, + "bg_notify_needs_action": True, + # Max page size in logical \n-delimited LINES. Values 10/20/40/70. + # 20 keeps the card compact on phone; 70 is for power users who + # scroll long bodies. Anchor (page top) chunking handles overflow + # with smart sentence / paragraph boundaries — see + # ``_chunk_final_text`` for the exact preference order. + "card_page_lines": 20, + # Auto-rename new sessions via a cheap one-shot model call after the + # first user message ≥20 chars. When ``False``, names stay as + # the directory basename (``workdir``, ``workdir-2``, ...) for + # the session's lifetime. The persisted key keeps its historical + # name for state-file compatibility. + "haiku_naming": True, + } + + def get_user_settings(self, user_id: int) -> dict[str, Any]: + """Return the user's settings, filling in defaults for missing keys.""" + stored = self.user_settings.get(user_id, {}) + merged: dict[str, Any] = dict(self.DEFAULT_USER_SETTINGS) + merged.update(stored) + # Backwards-compat: the old binary value "on" maps to the new + # 3-state "auto". Read-side only; stored value lingers until the + # user picks something on the settings screen. + if merged.get("local_terminal") == "on": + merged["local_terminal"] = "auto" + return merged + + def update_user_setting(self, user_id: int, key: str, value: Any) -> None: + """Persist a single user setting.""" + if key not in self.DEFAULT_USER_SETTINGS: + raise ValueError(f"Unknown setting key: {key}") + bucket = self.user_settings.setdefault(user_id, {}) + bucket[key] = value + self.save_state() + + def set_agent_backend(self, backend: str) -> None: + """Persist the bot-wide backend used for every newly created session. + + Switching while a live session exists is rejected: a bot instance is + deliberately single-backend at runtime. Archive/kill live sessions + first; historical records retain their backend for safe inspection. + """ + if backend not in ("claude", "codex"): + raise ValueError(f"Unknown agent backend: {backend}") + if backend == self.agent_backend: + return + live = [ + sess + for sess in self.sessions.values() + if sess.state in ("active", "idle") and sess.backend != backend + ] + if live: + raise RuntimeError("archive live sessions before switching backend") + self.agent_backend = backend + config.agent_backend = backend + self.save_state() + logger.info("Bot-wide agent backend changed to %s", backend) + + # --- Summary cache (agent session id -> short readable summary) --- + + def get_cached_summary( + self, claude_session_id: str, file_mtime: float + ) -> str | None: + """Return cached summary if mtime matches; otherwise None.""" + entry = self.summary_cache.get(claude_session_id) + if not entry: + return None + if abs(float(entry.get("mtime", 0.0)) - file_mtime) > 1e-3: + return None + return entry.get("summary") or None + + def set_cached_summary( + self, claude_session_id: str, summary: str, file_mtime: float + ) -> None: + """Persist a generated summary for an agent session id.""" + if not claude_session_id or not summary: + return + self.summary_cache[claude_session_id] = { + "summary": summary, + "mtime": file_mtime, + "ts": time.time(), + } + self.save_state() + + def rename_session(self, session_id: str, new_name: str) -> None: + sess = self.sessions.get(session_id) + if not sess: + return + sess.name = new_name + self.save_state() + + def set_session_window(self, session_id: str, window_id: str) -> None: + """Re-attach a session to a (possibly new) tmux window after restore. + + A restored (or re-bound lost) session re-enters as if freshly created: + ``created_at`` is bumped to now so the oldest -> newest switcher slots + it at the far right rather than back in its original position. + """ + sess = self.sessions.get(session_id) + if not sess: + return + now = time.time() + sess.window_id = window_id + sess.state = "active" + sess.created_at = now + sess.last_event_at = now + self.save_state() + + def set_session_claude_id(self, session_id: str, claude_session_id: str) -> None: + sess = self.sessions.get(session_id) + if not sess: + return + if sess.claude_session_id != claude_session_id: + sess.claude_session_id = claude_session_id + self.save_state() + + def get_last_switcher_msg(self, user_id: int) -> int | None: + return self.last_switcher_msg_id.get(user_id) + + def set_last_switcher_msg(self, user_id: int, message_id: int) -> None: + self.last_switcher_msg_id[user_id] = message_id + # Persist eagerly: cheap, helps survive bot restart for switcher cleanup. + self.save_state() + + def clear_last_switcher_msg(self, user_id: int) -> None: + if user_id in self.last_switcher_msg_id: + del self.last_switcher_msg_id[user_id] + self.save_state() + + def get_card_msg(self, user_id: int) -> int | None: + return self.card_msg_id.get(user_id) + + def set_card_msg(self, user_id: int, message_id: int) -> None: + if self.card_msg_id.get(user_id) == message_id: + return + self.card_msg_id[user_id] = message_id + # Persist eagerly so a restart can repaint the live card in place. + self.save_state() + + def clear_card_msg(self, user_id: int) -> None: + if user_id in self.card_msg_id: + del self.card_msg_id[user_id] + self.save_state() + + # --- Reverse map: claude_session_id -> user(s) via active_sessions --- + + def all_user_sessions_with_claude_id( + self, claude_session_id: str + ) -> list[tuple[int, "Session"]]: + """Return [(user_id, Session)] including non-active sessions for that claude id. + + Used to drive background-session live-card edits even when the session + is not active for any user. + + The session pool is global (shared workspace), so a claude event is + fanned out to **every** allowed user — each gets their own live card / + panel in their own DM. With a single allowed user (the common case) + this collapses to one (user_id, Session) per match, identical to the + previous single-user behaviour. Users are sorted for deterministic + ordering. + """ + if not config.allowed_users: + return [] + matched = [ + sess + for sess in self.sessions.values() + if sess.claude_session_id == claude_session_id + ] + out: list[tuple[int, "Session"]] = [] + for user_id in sorted(config.allowed_users): + for sess in matched: + out.append((user_id, sess)) + return out diff --git a/src/ccbot/terminal_parser.py b/src/ccbot/terminal_parser.py index 8ca6445a..1eee35c6 100644 --- a/src/ccbot/terminal_parser.py +++ b/src/ccbot/terminal_parser.py @@ -16,6 +16,8 @@ import re from dataclasses import dataclass +from . import terminal_usage as _terminal_usage + @dataclass class InteractiveUIContent: @@ -507,210 +509,11 @@ def extract_bash_output(pane_text: str, command: str) -> str | None: return "\n".join(raw_output).strip() -# ── Usage modal parsing ────────────────────────────────────────────────────────── - - -@dataclass -class UsageInfo: - """Parsed output from Claude Code's /usage modal.""" - - raw_text: str # Full captured pane text - parsed_lines: list[str] # Cleaned content lines from the modal - - -@dataclass -class UsageBreakdown: - """Structured extract of the three usage rows + extra-usage flag. - - Each `pct` is the percentage Claude reports as "used"; `reset_hhmm` is - the wall-clock reset time in 24h format ("HH:MM"). Either may be None - if the row was missing or malformed in the captured pane. - """ - - session_pct: int | None = None - session_reset_hhmm: str | None = None - week_pct: int | None = None - week_reset_hhmm: str | None = None - week_sonnet_pct: int | None = None - week_sonnet_reset_hhmm: str | None = None - extra_enabled: bool = False - - -def _parse_clock_to_24h(text: str) -> str | None: - """Parse strings like ``9:59pm``, ``4pm``, ``May 17 at 4pm`` → ``HH:MM``. - - Claude Code's ``/usage`` modal switched, around mid-week, from - ``Resets 4pm (Europe/Moscow)`` to ``Resets May 17 at 4pm - (Europe/Moscow)`` on the *Current week* rows. Use ``re.search`` - (not ``re.match``) so the time can appear anywhere in the string, - and accept an optional ``at`` separator. - """ - m = re.search(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b", text, re.IGNORECASE) - if not m: - return None - hour = int(m.group(1)) - minute = int(m.group(2)) if m.group(2) else 0 - ampm = m.group(3).lower() - if ampm == "pm" and hour != 12: - hour += 12 - elif ampm == "am" and hour == 12: - hour = 0 - if not (0 <= hour < 24 and 0 <= minute < 60): - return None - return f"{hour:02d}:{minute:02d}" - - -def _parse_pct(text: str) -> int | None: - m = re.search(r"(\d+)\s*%\s*used", text) - return int(m.group(1)) if m else None - - -def extract_usage_breakdown(info: UsageInfo) -> UsageBreakdown: - """Walk parsed_lines, looking for the three section headers and - pulling the percentage + reset time + extra flag out of each. - """ - out = UsageBreakdown() - state: str | None = None - for raw in info.parsed_lines: - s = raw.strip() - if "Current session" in s: - state = "session" - continue - if "Current week" in s and "all models" in s.lower(): - state = "week_all" - continue - if "Current week" in s and "Sonnet" in s: - state = "week_sonnet" - continue - if s.startswith("Extra usage"): - state = "extra" - # The label itself sometimes lives on its own line; the value - # follows. Don't reset state — pick up "not enabled" / "enabled" - # below. - continue - - if state == "session": - pct = _parse_pct(s) - if pct is not None: - out.session_pct = pct - elif s.lower().startswith("resets"): - out.session_reset_hhmm = _parse_clock_to_24h( - re.sub(r"^resets\s*", "", s, flags=re.IGNORECASE) - ) - elif state == "week_all": - pct = _parse_pct(s) - if pct is not None: - out.week_pct = pct - elif s.lower().startswith("resets"): - out.week_reset_hhmm = _parse_clock_to_24h( - re.sub(r"^resets\s*", "", s, flags=re.IGNORECASE) - ) - elif state == "week_sonnet": - pct = _parse_pct(s) - if pct is not None: - out.week_sonnet_pct = pct - elif s.lower().startswith("resets"): - out.week_sonnet_reset_hhmm = _parse_clock_to_24h( - re.sub(r"^resets\s*", "", s, flags=re.IGNORECASE) - ) - elif state == "extra": - low = s.lower() - if "not enabled" in low: - out.extra_enabled = False - elif "enabled" in low: - out.extra_enabled = True - return out - - -def parse_usage_output(pane_text: str) -> UsageInfo | None: - """Extract usage information from Claude Code's /usage settings tab. - - Three start signals, tried in order: - - * modern tabs row ``Status Config Usage Stats``, - * legacy header ``Settings: ... Usage``, - * body fallback — any ``Current session`` / ``Current week`` line. - - The last one matters because ``tmux capture-pane`` reads only the - visible viewport (no scrollback by default). On a narrow pane the - modal body is taller than the visible rows, the tabs row scrolls - above the top, and the header-only detection returns ``None`` even - though every usage row is right there in the capture. The fallback - catches exactly that case. - - Returns ``UsageInfo`` with cleaned lines, or ``None`` if neither - signal is present. - """ - if not pane_text: - return None - - lines = pane_text.strip().split("\n") - - start_idx: int | None = None - end_idx: int | None = None - - # Pass 1: header-based detection. The modal can appear multiple - # times in a scrollback capture (each /usage attempt leaves its - # transcript behind), so walk backwards and pick the LAST header - # — that's the freshest modal, the one matching the data we want. - header_positions: list[int] = [] - for i, line in enumerate(lines): - stripped = line.strip() - is_modern = ( - "Status" in stripped - and "Config" in stripped - and "Usage" in stripped - and "Stats" in stripped - ) - is_legacy = "Settings:" in stripped and "Usage" in stripped - if is_modern or is_legacy: - header_positions.append(i) - if header_positions: - start_idx = header_positions[-1] + 1 - for j in range(start_idx, len(lines)): - if lines[j].strip().startswith("Esc to"): - end_idx = j - break - - # Pass 2: header escaped the captured viewport. Anchor on the LAST - # "Current session" line — that's the earliest body marker of the - # freshest modal, so we still pick up "Current week (all models)" - # below it. Falling back to the last "Current week" only when no - # session row was captured. - if start_idx is None: - session_positions = [i for i, ln in enumerate(lines) if "Current session" in ln] - week_positions = [i for i, ln in enumerate(lines) if "Current week" in ln] - if session_positions: - start_idx = session_positions[-1] - elif week_positions: - start_idx = week_positions[-1] - else: - return None - # Look for the dismiss sentinel in the remainder; if it's gone - # too (long modal on a tiny pane) we keep everything. - for j in range(start_idx, len(lines)): - if lines[j].strip().startswith("Esc to"): - end_idx = j - break - - if end_idx is None: - end_idx = len(lines) - - # Collect content lines, stripping progress bar characters and whitespace - cleaned: list[str] = [] - for line in lines[start_idx:end_idx]: - # Strip the line but preserve meaningful content - stripped = line.strip() - if not stripped: - continue - # Remove progress bar block characters but keep the rest - # Progress bars are like: █████▋ 38% used - # Strip leading block chars, keep the percentage - stripped = re.sub(r"^[\u2580-\u259f\s]+", "", stripped).strip() - if stripped: - cleaned.append(stripped) - - if cleaned: - return UsageInfo(raw_text=pane_text, parsed_lines=cleaned) - - return None +# Compatibility aliases: usage parsing moved to a focused leaf module while +# historical imports from ``ccbot.terminal_parser`` remain valid. +UsageInfo = _terminal_usage.UsageInfo +UsageBreakdown = _terminal_usage.UsageBreakdown +_parse_clock_to_24h = _terminal_usage._parse_clock_to_24h +_parse_pct = _terminal_usage._parse_pct +extract_usage_breakdown = _terminal_usage.extract_usage_breakdown +parse_usage_output = _terminal_usage.parse_usage_output diff --git a/src/ccbot/terminal_usage.py b/src/ccbot/terminal_usage.py new file mode 100644 index 00000000..b21a6daa --- /dev/null +++ b/src/ccbot/terminal_usage.py @@ -0,0 +1,225 @@ +"""Parse Claude Code's ``/usage`` terminal modal. + +This leaf module owns usage-modal data models, row extraction, percentage and +reset-time parsing. ``ccbot.terminal_parser`` re-exports its historical API. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +__all__ = [ + "UsageInfo", + "UsageBreakdown", + "_parse_clock_to_24h", + "_parse_pct", + "extract_usage_breakdown", + "parse_usage_output", +] + + +@dataclass +class UsageInfo: + """Parsed output from Claude Code's /usage modal.""" + + raw_text: str # Full captured pane text + parsed_lines: list[str] + + +@dataclass +class UsageBreakdown: + """Structured extract of the three usage rows + extra-usage flag. + + Each `pct` is the percentage Claude reports as "used"; `reset_hhmm` is + the wall-clock reset time in 24h format ("HH:MM"). Either may be None + if the row was missing or malformed in the captured pane. + """ + + session_pct: int | None = None + session_reset_hhmm: str | None = None + week_pct: int | None = None + week_reset_hhmm: str | None = None + week_sonnet_pct: int | None = None + week_sonnet_reset_hhmm: str | None = None + extra_enabled: bool = False + + +def _parse_clock_to_24h(text: str) -> str | None: + """Parse strings like ``9:59pm``, ``4pm``, ``May 17 at 4pm`` → ``HH:MM``. + + Claude Code's ``/usage`` modal switched, around mid-week, from + ``Resets 4pm (Europe/Moscow)`` to ``Resets May 17 at 4pm + (Europe/Moscow)`` on the *Current week* rows. Use ``re.search`` + (not ``re.match``) so the time can appear anywhere in the string, + and accept an optional ``at`` separator. + """ + m = re.search(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b", text, re.IGNORECASE) + if not m: + return None + hour = int(m.group(1)) + minute = int(m.group(2)) if m.group(2) else 0 + ampm = m.group(3).lower() + if ampm == "pm" and hour != 12: + hour += 12 + elif ampm == "am" and hour == 12: + hour = 0 + if not (0 <= hour < 24 and 0 <= minute < 60): + return None + return f"{hour:02d}:{minute:02d}" + + +def _parse_pct(text: str) -> int | None: + m = re.search(r"(\d+)\s*%\s*used", text) + return int(m.group(1)) if m else None + + +def extract_usage_breakdown(info: UsageInfo) -> UsageBreakdown: + """Walk parsed_lines, looking for the three section headers and + pulling the percentage + reset time + extra flag out of each. + """ + out = UsageBreakdown() + state: str | None = None + for raw in info.parsed_lines: + s = raw.strip() + if "Current session" in s: + state = "session" + continue + if "Current week" in s and "all models" in s.lower(): + state = "week_all" + continue + if "Current week" in s and "Sonnet" in s: + state = "week_sonnet" + continue + if s.startswith("Extra usage"): + state = "extra" + # The label itself sometimes lives on its own line; the value + # follows. Don't reset state — pick up "not enabled" / "enabled" + # below. + continue + + if state == "session": + pct = _parse_pct(s) + if pct is not None: + out.session_pct = pct + elif s.lower().startswith("resets"): + out.session_reset_hhmm = _parse_clock_to_24h( + re.sub(r"^resets\s*", "", s, flags=re.IGNORECASE) + ) + elif state == "week_all": + pct = _parse_pct(s) + if pct is not None: + out.week_pct = pct + elif s.lower().startswith("resets"): + out.week_reset_hhmm = _parse_clock_to_24h( + re.sub(r"^resets\s*", "", s, flags=re.IGNORECASE) + ) + elif state == "week_sonnet": + pct = _parse_pct(s) + if pct is not None: + out.week_sonnet_pct = pct + elif s.lower().startswith("resets"): + out.week_sonnet_reset_hhmm = _parse_clock_to_24h( + re.sub(r"^resets\s*", "", s, flags=re.IGNORECASE) + ) + elif state == "extra": + low = s.lower() + if "not enabled" in low: + out.extra_enabled = False + elif "enabled" in low: + out.extra_enabled = True + return out + + +def parse_usage_output(pane_text: str) -> UsageInfo | None: + """Extract usage information from Claude Code's /usage settings tab. + + Three start signals, tried in order: + + * modern tabs row ``Status Config Usage Stats``, + * legacy header ``Settings: ... Usage``, + * body fallback — any ``Current session`` / ``Current week`` line. + + The last one matters because ``tmux capture-pane`` reads only the + visible viewport (no scrollback by default). On a narrow pane the + modal body is taller than the visible rows, the tabs row scrolls + above the top, and the header-only detection returns ``None`` even + though every usage row is right there in the capture. The fallback + catches exactly that case. + + Returns ``UsageInfo`` with cleaned lines, or ``None`` if neither + signal is present. + """ + if not pane_text: + return None + + lines = pane_text.strip().split("\n") + + start_idx: int | None = None + end_idx: int | None = None + + # Pass 1: header-based detection. The modal can appear multiple + # times in a scrollback capture (each /usage attempt leaves its + # transcript behind), so walk backwards and pick the LAST header + # — that's the freshest modal, the one matching the data we want. + header_positions: list[int] = [] + for i, line in enumerate(lines): + stripped = line.strip() + is_modern = ( + "Status" in stripped + and "Config" in stripped + and "Usage" in stripped + and "Stats" in stripped + ) + is_legacy = "Settings:" in stripped and "Usage" in stripped + if is_modern or is_legacy: + header_positions.append(i) + if header_positions: + start_idx = header_positions[-1] + 1 + for j in range(start_idx, len(lines)): + if lines[j].strip().startswith("Esc to"): + end_idx = j + break + + # Pass 2: header escaped the captured viewport. Anchor on the LAST + # "Current session" line — that's the earliest body marker of the + # freshest modal, so we still pick up "Current week (all models)" + # below it. Falling back to the last "Current week" only when no + # session row was captured. + if start_idx is None: + session_positions = [i for i, ln in enumerate(lines) if "Current session" in ln] + week_positions = [i for i, ln in enumerate(lines) if "Current week" in ln] + if session_positions: + start_idx = session_positions[-1] + elif week_positions: + start_idx = week_positions[-1] + else: + return None + # Look for the dismiss sentinel in the remainder; if it's gone + # too (long modal on a tiny pane) we keep everything. + for j in range(start_idx, len(lines)): + if lines[j].strip().startswith("Esc to"): + end_idx = j + break + + if end_idx is None: + end_idx = len(lines) + + # Collect content lines, stripping progress bar characters and whitespace + cleaned: list[str] = [] + for line in lines[start_idx:end_idx]: + # Strip the line but preserve meaningful content + stripped = line.strip() + if not stripped: + continue + # Remove progress bar block characters but keep the rest + # Progress bars are like: █████▋ 38% used + # Strip leading block chars, keep the percentage + stripped = re.sub(r"^[\u2580-\u259f\s]+", "", stripped).strip() + if stripped: + cleaned.append(stripped) + + if cleaned: + return UsageInfo(raw_text=pane_text, parsed_lines=cleaned) + + return None diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py index 5fe619f3..f644a874 100644 --- a/src/ccbot/tmux_manager.py +++ b/src/ccbot/tmux_manager.py @@ -17,7 +17,6 @@ import logging import os import re -import shlex import signal import subprocess import time @@ -26,7 +25,9 @@ import libtmux +from . import tmux_window as _tmux_window from .config import SENSITIVE_ENV_VARS, config +from .tmux_process import kill_orphan_processes logger = logging.getLogger(__name__) @@ -90,81 +91,33 @@ def _send_lock_for(self, window_id: str) -> asyncio.Lock: @staticmethod def _handle_codex_startup_screen(pane: object) -> tuple[bool, bool]: - """Handle one captured startup screen. - - Returns ``(acted, terminal)``. Terminal means the normal Codex input - is ready or the pane can no longer be inspected. - """ - capture = getattr(pane, "capture_pane", None) - send_keys = getattr(pane, "send_keys", None) - if not callable(capture) or not callable(send_keys): - return False, True - try: - lines = capture() - text = "\n".join(lines) if isinstance(lines, list) else str(lines) - except Exception: - return False, True - if _CODEX_TRUST_PROMPT in text and _CODEX_TRUST_YES in text: - send_keys("", enter=True) - logger.info("Accepted Codex directory trust prompt") - return True, False - if ( - "Choose working directory to resume this session" in text - and "1. Use session directory" in text - and "2. Use current directory" in text - and "Press enter to continue" in text - ): - send_keys("Down", enter=False) - send_keys("", enter=True) - logger.info("Selected current directory for Codex resume") - return True, False - if ( - "Update available!" in text - and "1. Update now" in text - and "2. Skip" in text - and "Press enter to continue" in text - ): - # Never mutate the host toolchain from a Telegram session. - send_keys("Down", enter=False) - send_keys("", enter=True) - logger.info("Skipped Codex CLI update prompt") - return True, False - return False, "OpenAI Codex" in text and "›" in text + """Handle one captured Codex startup screen.""" + return _tmux_window.handle_codex_startup_screen( + pane, + trust_prompt=_CODEX_TRUST_PROMPT, + trust_yes=_CODEX_TRUST_YES, + logger_obj=logger, + ) @classmethod def _accept_codex_directory_trust(cls, pane: object) -> bool: - """Synchronously handle known prompts (small helper/test surface). - - Codex can show this before its normal input box even in full-access - mode. The bot already owns the selected working directory, so leaving - the TUI blocked here makes Telegram input appear broken. A resumed - rollout can also remember a different directory; in that case choose - the current directory that the user selected for this bot session. - Poll briefly because the Node wrapper needs a moment to draw prompts. - """ - accepted = False - for _ in range(30): - time.sleep(0.15) - acted, terminal = cls._handle_codex_startup_screen(pane) - accepted = accepted or acted - if terminal: - return accepted - return accepted + """Synchronously handle known Codex startup prompts.""" + return _tmux_window.accept_codex_directory_trust( + pane, + sleep=time.sleep, + handler=cls._handle_codex_startup_screen, + ) @classmethod async def _watch_codex_startup_screens(cls, pane: object) -> bool: """Cancellation-safe long watcher for cold Codex launches.""" - accepted = False - attempts = max(30, int(max(config.resume_settle_timeout, 4.5) / 0.15)) - for _ in range(attempts): - await asyncio.sleep(0.15) - acted, terminal = await asyncio.to_thread( - cls._handle_codex_startup_screen, pane - ) - accepted = accepted or acted - if terminal: - return accepted - return accepted + return await _tmux_window.watch_codex_startup_screens( + pane, + timeout=config.resume_settle_timeout, + sleep=asyncio.sleep, + to_thread=asyncio.to_thread, + handler=cls._handle_codex_startup_screen, + ) @property def server(self) -> libtmux.Server: @@ -625,71 +578,24 @@ def _kill_grouped_session_for_window(self, window_id: str) -> None: logger.debug("kill grouped session %s failed: %s", target, e) async def kill_orphan_claude_processes(self, claude_session_id: str) -> int: - """SIGTERM any 'claude --resume ' processes still alive. - - Called after ``kill_window`` to catch processes that survived the - pane SIGHUP — observed when a window kill races a bot restart - and the pane shell exits cleanly but its claude child detaches. - Leaving the orphan alive corrupts the session's JSONL (multiple - writers, interleaved entries, broken tool_use ↔ tool_result - pairing) which surfaces as ghost activity / lag in the live card. - - Returns the number of processes signalled. - - Happy path: ``kill_window`` worked, pgrep finds nothing, no-op. - """ + """SIGTERM any surviving claude resume process for this session.""" if not _CLAUDE_SESSION_RE.match(claude_session_id): logger.warning( "kill_orphan_claude_processes: invalid session id %r, skipping", claude_session_id, ) return 0 - - def _sync_kill_orphans() -> int: - try: - result = subprocess.run( - ["pgrep", "-f", f"claude.*--resume {claude_session_id}"], - capture_output=True, - text=True, - timeout=5, - ) - except (subprocess.TimeoutExpired, OSError) as e: - logger.debug("pgrep failed: %s", e) - return 0 - pids: list[int] = [] - for line in result.stdout.splitlines(): - line = line.strip() - if not line: - continue - try: - pids.append(int(line)) - except ValueError: - continue - # Guard: never SIGTERM our own PID or our parent (we run inside - # tmux, so the pane shell is an ancestor; killing ourselves - # would self-destruct the bot). - own = os.getpid() - parent = os.getppid() - killed = 0 - for pid in pids: - if pid == own or pid == parent: - continue - try: - os.kill(pid, signal.SIGTERM) - killed += 1 - logger.info( - "kill_orphan_claude pid=%d session=%s", - pid, - claude_session_id, - ) - except ProcessLookupError: - # Already dead between pgrep and kill — fine. - continue - except PermissionError as e: - logger.warning("kill_orphan_claude pid=%d denied: %s", pid, e) - return killed - - return await asyncio.to_thread(_sync_kill_orphans) + return await asyncio.to_thread( + kill_orphan_processes, + claude_session_id, + run=subprocess.run, + kill=os.kill, + own_pid=os.getpid(), + parent_pid=os.getppid(), + sigterm=signal.SIGTERM, + timeout_error=subprocess.TimeoutExpired, + logger=logger, + ) async def create_window( self, @@ -701,144 +607,19 @@ async def create_window( backend: str | None = None, initial_prompt: str | None = None, ) -> tuple[bool, str, str, str]: - """Create a new tmux window and optionally start the configured agent. - - Args: - work_dir: Working directory for the new window - window_name: Optional window name (defaults to directory name) - start_claude: Whether to start claude command - resume_session_id: If set, append --resume to claude command - owner_user_id: Telegram user_id that created this session, if - known — exported as ``CCBOT_CHAT_ID`` so ``ccbot send-file`` - (and Claude generally) knows which chat owns this session - without needing an explicit ``--chat-id``. - - Returns: - Tuple of (success, message, window_name, window_id) - """ - # Validate directory first - path = Path(work_dir).expanduser().resolve() - selected_backend = backend or config.agent_backend - if selected_backend not in ("claude", "codex"): - return False, f"Unsupported agent backend: {selected_backend}", "", "" - if not path.exists(): - return False, f"Directory does not exist: {work_dir}", "", "" - if not path.is_dir(): - return False, f"Not a directory: {work_dir}", "", "" - - # Create window name, adding suffix if name already exists - final_window_name = window_name if window_name else path.name - - # Check for existing window name - base_name = final_window_name - counter = 2 - while await self.find_window_by_name(final_window_name): - final_window_name = f"{base_name}-{counter}" - counter += 1 - - # Create window in thread - created_pane: object | None = None - - def _create_and_start() -> tuple[bool, str, str, str]: - nonlocal created_pane - session = self.get_or_create_session() - try: - # Create new window - window = session.new_window( - window_name=final_window_name, - start_directory=str(path), - ) - - wid = window.window_id or "" - - # Prevent Claude Code from overriding window name - window.set_window_option("allow-rename", "off") - - # Start Claude Code if requested - if start_claude: - pane = window.active_pane - if pane: - created_pane = pane - if selected_backend == "codex": - cmd = config.codex_command - if config.codex_flags: - cmd = f"{cmd} {config.codex_flags}" - if resume_session_id: - cmd = f"{cmd} resume {shlex.quote(resume_session_id)}" - else: - cmd = config.claude_command - if config.claude_flags: - cmd = f"{cmd} {config.claude_flags}" - if resume_session_id: - cmd = f"{cmd} --resume {shlex.quote(resume_session_id)}" - if initial_prompt: - cmd = f"{cmd} {shlex.quote(initial_prompt)}" - # Identify the runtime so Claude (via the - # output-format guidance in CLAUDE.md) can - # tailor its replies to the Telegram surface AND - # know *which* bot / device hosts the session — - # useful when the user runs multiple ccbot - # deployments (e.g. Mac + arm64 box). - env_prefix = ( - "CCBOT_INTERFACE=telegram " - f"CCBOT_AGENT_BACKEND={selected_backend} " - f"CCBOT_DIR={shlex.quote(str(config.config_dir))}" - ) - if config.bot_username: - env_prefix += f" CCBOT_BOT_USERNAME={shlex.quote(config.bot_username)}" - if config.host_label: - env_prefix += ( - f" CCBOT_HOST={shlex.quote(config.host_label)}" - ) - if owner_user_id is not None: - # Lets ``ccbot send-file`` target the right - # chat with no argument needed. - env_prefix += f" CCBOT_CHAT_ID={owner_user_id}" - if config.is_sandbox: - cmd = f"IS_SANDBOX=1 {env_prefix} {cmd}" - else: - cmd = f"{env_prefix} {cmd}" - pane.send_keys(cmd, enter=True) - - logger.info( - "Created window '%s' (id=%s) at %s", - final_window_name, - wid, - path, - ) - return ( - True, - f"Created window '{final_window_name}' at {path}", - final_window_name, - wid, - ) - - except Exception as e: - logger.error(f"Failed to create window: {e}") - return False, f"Failed to create window: {e}", "", "" - - result = await asyncio.to_thread(_create_and_start) - if result[0] and selected_backend == "codex" and created_pane is not None: - # Do not hold the Telegram callback open while the Node wrapper - # draws its startup UI. The background task accepts only the two - # known directory prompts; normal input is never confirmed. - task = asyncio.create_task( - self._watch_codex_startup_screens(created_pane), - name=f"codex-startup-trust:{result[3]}", - ) - self._startup_tasks.add(task) - - def _finish_startup_task(done: asyncio.Task[bool]) -> None: - self._startup_tasks.discard(done) - try: - done.result() - except asyncio.CancelledError: - return - except Exception as e: - logger.warning("Codex startup prompt handler failed: %s", e) - - task.add_done_callback(_finish_startup_task) - return result + """Create a tmux window and optionally start the configured agent.""" + return await _tmux_window.create_window( + self, + work_dir, + window_name=window_name, + start_claude=start_claude, + resume_session_id=resume_session_id, + owner_user_id=owner_user_id, + backend=backend, + initial_prompt=initial_prompt, + config_obj=config, + logger_obj=logger, + ) # Global instance with default session name diff --git a/src/ccbot/tmux_process.py b/src/ccbot/tmux_process.py new file mode 100644 index 00000000..26a59811 --- /dev/null +++ b/src/ccbot/tmux_process.py @@ -0,0 +1,56 @@ +"""Process cleanup helpers for tmux-backed agent sessions. + +The caller supplies OS and subprocess functions so tmux_manager keeps its +historical monkeypatch seams while this leaf owns PID parsing and signalling. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import Any + + +def kill_orphan_processes( + claude_session_id: str, + *, + run: Callable[..., Any], + kill: Callable[[int, int], None], + own_pid: int, + parent_pid: int, + sigterm: int, + timeout_error: type[BaseException], + logger: logging.Logger, +) -> int: + """Signal surviving ``claude --resume`` processes and return the count.""" + try: + result = run( + ["pgrep", "-f", f"claude.*--resume {claude_session_id}"], + capture_output=True, + text=True, + timeout=5, + ) + except (timeout_error, OSError) as exc: + logger.debug("pgrep failed: %s", exc) + return 0 + + pids: list[int] = [] + for line in result.stdout.splitlines(): + try: + pids.append(int(line.strip())) + except ValueError: + continue + + killed = 0 + for pid in pids: + if pid in (own_pid, parent_pid): + continue + try: + kill(pid, sigterm) + killed += 1 + logger.info("kill_orphan_claude pid=%d session=%s", pid, claude_session_id) + except ProcessLookupError: + continue + except PermissionError as exc: + logger.warning("kill_orphan_claude pid=%d denied: %s", pid, exc) + return killed diff --git a/src/ccbot/tmux_window.py b/src/ccbot/tmux_window.py new file mode 100644 index 00000000..3fd2396d --- /dev/null +++ b/src/ccbot/tmux_window.py @@ -0,0 +1,261 @@ +"""Tmux window creation and agent startup orchestration. + +This module builds Claude/Codex commands, creates the libtmux window, and +handles known Codex startup screens. ``TmuxManager`` remains the public API and +passes its patchable dependencies into these helpers explicitly. +""" + +from __future__ import annotations + +import asyncio +import logging +import shlex +from pathlib import Path +from typing import Any + + +def handle_codex_startup_screen( + pane: object, + *, + trust_prompt: str, + trust_yes: str, + logger_obj: logging.Logger, +) -> tuple[bool, bool]: + """Handle one captured startup screen. + + Returns ``(acted, terminal)``. Terminal means the normal Codex input + is ready or the pane can no longer be inspected. + """ + capture = getattr(pane, "capture_pane", None) + send_keys = getattr(pane, "send_keys", None) + if not callable(capture) or not callable(send_keys): + return False, True + try: + lines = capture() + text = "\n".join(lines) if isinstance(lines, list) else str(lines) + except Exception: + return False, True + if trust_prompt in text and trust_yes in text: + send_keys("", enter=True) + logger_obj.info("Accepted Codex directory trust prompt") + return True, False + if ( + "Choose working directory to resume this session" in text + and "1. Use session directory" in text + and "2. Use current directory" in text + and "Press enter to continue" in text + ): + send_keys("Down", enter=False) + send_keys("", enter=True) + logger_obj.info("Selected current directory for Codex resume") + return True, False + if ( + "Update available!" in text + and "1. Update now" in text + and "2. Skip" in text + and "Press enter to continue" in text + ): + # Never mutate the host toolchain from a Telegram session. + send_keys("Down", enter=False) + send_keys("", enter=True) + logger_obj.info("Skipped Codex CLI update prompt") + return True, False + return False, "OpenAI Codex" in text and "›" in text + + +def accept_codex_directory_trust( + pane: object, + *, + sleep: Any, + handler: Any, +) -> bool: + """Synchronously handle known prompts (small helper/test surface). + + Codex can show this before its normal input box even in full-access + mode. The bot already owns the selected working directory, so leaving + the TUI blocked here makes Telegram input appear broken. A resumed + rollout can also remember a different directory; in that case choose + the current directory that the user selected for this bot session. + Poll briefly because the Node wrapper needs a moment to draw prompts. + """ + accepted = False + for _ in range(30): + sleep(0.15) + acted, terminal = handler(pane) + accepted = accepted or acted + if terminal: + return accepted + return accepted + + +async def watch_codex_startup_screens( + pane: object, + *, + timeout: float, + sleep: Any, + to_thread: Any, + handler: Any, +) -> bool: + """Cancellation-safe long watcher for cold Codex launches.""" + accepted = False + attempts = max(30, int(max(timeout, 4.5) / 0.15)) + for _ in range(attempts): + await sleep(0.15) + acted, terminal = await to_thread(handler, pane) + accepted = accepted or acted + if terminal: + return accepted + return accepted + + +async def create_window( + manager: Any, + work_dir: str, + window_name: str | None = None, + start_claude: bool = True, + resume_session_id: str | None = None, + owner_user_id: int | None = None, + backend: str | None = None, + initial_prompt: str | None = None, + *, + config_obj: Any, + logger_obj: logging.Logger, +) -> tuple[bool, str, str, str]: + """Create a new tmux window and optionally start the configured agent. + + Args: + work_dir: Working directory for the new window + window_name: Optional window name (defaults to directory name) + start_claude: Whether to start claude command + resume_session_id: If set, append --resume to claude command + owner_user_id: Telegram user_id that created this session, if + known — exported as ``CCBOT_CHAT_ID`` so ``ccbot send-file`` + (and Claude generally) knows which chat owns this session + without needing an explicit ``--chat-id``. + + Returns: + Tuple of (success, message, window_name, window_id) + """ + # Validate directory first + path = Path(work_dir).expanduser().resolve() + selected_backend = backend or config_obj.agent_backend + if selected_backend not in ("claude", "codex"): + return False, f"Unsupported agent backend: {selected_backend}", "", "" + if not path.exists(): + return False, f"Directory does not exist: {work_dir}", "", "" + if not path.is_dir(): + return False, f"Not a directory: {work_dir}", "", "" + + # Create window name, adding suffix if name already exists + final_window_name = window_name if window_name else path.name + + # Check for existing window name + base_name = final_window_name + counter = 2 + while await manager.find_window_by_name(final_window_name): + final_window_name = f"{base_name}-{counter}" + counter += 1 + + # Create window in thread + created_pane: object | None = None + + def _create_and_start() -> tuple[bool, str, str, str]: + nonlocal created_pane + session = manager.get_or_create_session() + try: + # Create new window + window = session.new_window( + window_name=final_window_name, + start_directory=str(path), + ) + + wid = window.window_id or "" + + # Prevent Claude Code from overriding window name + window.set_window_option("allow-rename", "off") + + # Start Claude Code if requested + if start_claude: + pane = window.active_pane + if pane: + created_pane = pane + if selected_backend == "codex": + cmd = config_obj.codex_command + if config_obj.codex_flags: + cmd = f"{cmd} {config_obj.codex_flags}" + if resume_session_id: + cmd = f"{cmd} resume {shlex.quote(resume_session_id)}" + else: + cmd = config_obj.claude_command + if config_obj.claude_flags: + cmd = f"{cmd} {config_obj.claude_flags}" + if resume_session_id: + cmd = f"{cmd} --resume {shlex.quote(resume_session_id)}" + if initial_prompt: + cmd = f"{cmd} {shlex.quote(initial_prompt)}" + # Identify the runtime so Claude (via the + # output-format guidance in CLAUDE.md) can + # tailor its replies to the Telegram surface AND + # know *which* bot / device hosts the session — + # useful when the user runs multiple ccbot + # deployments (e.g. Mac + arm64 box). + env_prefix = ( + "CCBOT_INTERFACE=telegram " + f"CCBOT_AGENT_BACKEND={selected_backend} " + f"CCBOT_DIR={shlex.quote(str(config_obj.config_dir))}" + ) + if config_obj.bot_username: + env_prefix += f" CCBOT_BOT_USERNAME={shlex.quote(config_obj.bot_username)}" + if config_obj.host_label: + env_prefix += ( + f" CCBOT_HOST={shlex.quote(config_obj.host_label)}" + ) + if owner_user_id is not None: + # Lets ``ccbot send-file`` target the right + # chat with no argument needed. + env_prefix += f" CCBOT_CHAT_ID={owner_user_id}" + if config_obj.is_sandbox: + cmd = f"IS_SANDBOX=1 {env_prefix} {cmd}" + else: + cmd = f"{env_prefix} {cmd}" + pane.send_keys(cmd, enter=True) + + logger_obj.info( + "Created window '%s' (id=%s) at %s", + final_window_name, + wid, + path, + ) + return ( + True, + f"Created window '{final_window_name}' at {path}", + final_window_name, + wid, + ) + + except Exception as e: + logger_obj.error(f"Failed to create window: {e}") + return False, f"Failed to create window: {e}", "", "" + + result = await asyncio.to_thread(_create_and_start) + if result[0] and selected_backend == "codex" and created_pane is not None: + # Do not hold the Telegram callback open while the Node wrapper + # draws its startup UI. The background task accepts only the two + # known directory prompts; normal input is never confirmed. + task = asyncio.create_task( + manager._watch_codex_startup_screens(created_pane), + name=f"codex-startup-trust:{result[3]}", + ) + manager._startup_tasks.add(task) + + def _finish_startup_task(done: asyncio.Task[bool]) -> None: + manager._startup_tasks.discard(done) + try: + done.result() + except asyncio.CancelledError: + return + except Exception as e: + logger_obj.warning("Codex startup prompt handler failed: %s", e) + + task.add_done_callback(_finish_startup_task) + return result diff --git a/src/ccbot/transcript_codex.py b/src/ccbot/transcript_codex.py new file mode 100644 index 00000000..e89755e7 --- /dev/null +++ b/src/ccbot/transcript_codex.py @@ -0,0 +1,124 @@ +"""Normalization of Codex rollout rows into Claude-shaped message blocks.""" + +import json +from typing import Any + + +def normalize_codex_entry(data: dict[str, Any]) -> dict[str, Any] | None: + """Translate a stable subset of Codex rollout events to Claude blocks. + + Codex emits user/agent text as ``event_msg`` rows and tool calls as + ``response_item`` rows. Normalizing at this boundary lets the existing + history, live-card, tool-pairing, and Telegram formatting pipeline stay + unchanged. + """ + top_type = data.get("type") + payload = data.get("payload") + if not isinstance(payload, dict): + return None + timestamp = data.get("timestamp") + if top_type == "event_msg": + event_type = payload.get("type") + if event_type == "user_message": + text = str(payload.get("message") or "") + return { + "type": "user", + "timestamp": timestamp, + "message": {"content": [{"type": "text", "text": text}]}, + } + if event_type == "agent_message": + text = str(payload.get("message") or "") + phase = str(payload.get("phase") or "") + return { + "type": "assistant", + "timestamp": timestamp, + "message": { + "content": [{"type": "text", "text": text}], + "stop_reason": "end_turn" + if phase in ("final_answer", "final") + else None, + }, + } + return None + if top_type != "response_item": + return None + item_type = payload.get("type") + # Codex 0.147 stopped emitting the duplicate event_msg rows that used + # to carry user and assistant text. Its replacement message rows are + # numbered with an ordinal. Codex 0.146 also wrote unnumbered message + # response_items alongside event_msg rows, so accepting only numbered + # rows here preserves the old fallback without rendering every turn twice. + if item_type == "message" and data.get("ordinal") is not None: + role = str(payload.get("role") or "") + if role not in ("user", "assistant"): + return None + raw_content = payload.get("content", "") + content: list[dict[str, str]] = [] + if isinstance(raw_content, list): + for block in raw_content: + if not isinstance(block, dict): + continue + block_type = block.get("type") + if block_type not in ("input_text", "output_text", "text"): + continue + text = str(block.get("text") or "") + if text: + content.append({"type": "text", "text": text}) + elif isinstance(raw_content, str) and raw_content: + content.append({"type": "text", "text": raw_content}) + if not content: + return None + phase = str(payload.get("phase") or "") + return { + "type": role, + "timestamp": timestamp, + "message": { + "content": content, + "stop_reason": "end_turn" + if role == "assistant" and phase in ("final_answer", "final") + else None, + }, + } + if item_type in ("function_call", "custom_tool_call"): + arguments = payload.get("arguments") + if arguments is None: + arguments = payload.get("input") + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError: + arguments = {"input": arguments} + if not isinstance(arguments, dict): + arguments = {"input": arguments} + return { + "type": "assistant", + "timestamp": timestamp, + "message": { + "content": [ + { + "type": "tool_use", + "id": str(payload.get("call_id") or payload.get("id") or ""), + "name": str(payload.get("name") or "tool"), + "input": arguments, + } + ], + "stop_reason": "tool_use", + }, + } + if item_type in ("function_call_output", "custom_tool_call_output"): + return { + "type": "user", + "timestamp": timestamp, + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": str( + payload.get("call_id") or payload.get("id") or "" + ), + "content": payload.get("output") or "", + } + ] + }, + } + return None diff --git a/src/ccbot/transcript_message.py b/src/ccbot/transcript_message.py new file mode 100644 index 00000000..b811c3cc --- /dev/null +++ b/src/ccbot/transcript_message.py @@ -0,0 +1,88 @@ +"""Basic JSONL and Claude-shaped message parsing helpers.""" + +import json +from typing import Any + +from .transcript_types import ParsedMessage + + +def parse_line(line: str) -> dict[str, Any] | None: + """Parse one JSONL line, returning ``None`` for empty or invalid input.""" + line = line.strip() + if not line: + return None + try: + return json.loads(line) + except json.JSONDecodeError: + return None + + +def get_message_type(data: dict[str, Any]) -> str | None: + """Get the top-level transcript message type.""" + return data.get("type") + + +def is_user_message(data: dict[str, Any]) -> bool: + """Return whether this is a user message.""" + return data.get("type") == "user" + + +def extract_text_only(content_list: list[Any]) -> str: + """Extract text blocks, excluding tool calls and thinking blocks.""" + if not isinstance(content_list, list): # pyright: ignore[reportUnnecessaryIsInstance] + if isinstance(content_list, str): + return content_list + return "" + + texts: list[str] = [] + for item in content_list: + if isinstance(item, str): + texts.append(item) + elif isinstance(item, dict) and item.get("type") == "text": + text = item.get("text", "") + if text: + texts.append(text) + return "\n".join(texts) + + +def parse_message(parser_cls: Any, data: dict[str, Any]) -> ParsedMessage | None: + """Parse a Claude-shaped user/assistant row using ``parser_cls`` hooks.""" + msg_type = parser_cls.get_message_type(data) + if msg_type not in ("user", "assistant"): + return None + + message = data.get("message") + if not isinstance(message, dict): + return None + content = message.get("content", "") + if isinstance(content, list): + text = parser_cls.extract_text_only(content) + else: + text = str(content) if content else "" + text = parser_cls._RE_ANSI_ESCAPE.sub("", text) + + if msg_type == "user" and text: + stdout_match = parser_cls._RE_LOCAL_STDOUT.search(text) + if stdout_match: + stdout = stdout_match.group(1).strip() + cmd_match = parser_cls._RE_COMMAND_NAME.search(text) + cmd = cmd_match.group(1) if cmd_match else None + return ParsedMessage( + message_type="local_command", + text=stdout, + tool_name=cmd, + ) + cmd_match = parser_cls._RE_COMMAND_NAME.search(text) + if cmd_match: + return ParsedMessage( + message_type="local_command_invoke", + text="", + tool_name=cmd_match.group(1), + ) + + return ParsedMessage(message_type=msg_type, text=text) + + +def get_timestamp(data: dict[str, Any]) -> str | None: + """Extract timestamp from message data.""" + return data.get("timestamp") diff --git a/src/ccbot/transcript_parser.py b/src/ccbot/transcript_parser.py index 582be52d..31e6332d 100644 --- a/src/ccbot/transcript_parser.py +++ b/src/ccbot/transcript_parser.py @@ -12,13 +12,12 @@ Key classes: TranscriptParser (static methods), ParsedEntry, ParsedMessage, PendingToolInfo. """ -import json import logging import re -from dataclasses import dataclass from typing import Any from . import transcript_format +from .transcript_codex import normalize_codex_entry from .transcript_format import ( EXPANDABLE_HEADED_END, EXPANDABLE_HEADED_SEP, @@ -26,58 +25,21 @@ EXPANDABLE_QUOTE_END, EXPANDABLE_QUOTE_START, ) +from .transcript_message import extract_text_only as _extract_text_only +from .transcript_message import get_message_type as _get_message_type +from .transcript_message import get_timestamp as _get_timestamp +from .transcript_message import is_user_message as _is_user_message +from .transcript_message import parse_line as _parse_line +from .transcript_message import parse_message as _parse_message +from .transcript_types import ParsedEntry, ParsedMessage, PendingToolInfo logger = logging.getLogger(__name__) - -@dataclass -class ParsedMessage: - """Parsed message from a transcript.""" - - message_type: str # "user", "assistant", "tool_use", "tool_result", etc. - text: str # Extracted text content - tool_name: str | None = None # For tool_use messages - - -@dataclass -class ParsedEntry: - """A single parsed message entry ready for display.""" - - role: str # "user" | "assistant" - text: str # Already formatted text - content_type: ( - str # "text" | "thinking" | "tool_use" | "tool_result" | "local_command" - ) - tool_use_id: str | None = None - timestamp: str | None = None # ISO timestamp from JSONL - tool_name: str | None = ( - None # For tool_use entries, the tool name (e.g. "AskUserQuestion") - ) - image_data: list[tuple[str, bytes]] | None = ( - None # For tool_result entries with images: (media_type, raw_bytes) - ) - stop_reason: str | None = ( - None # Assistant message stop_reason: "end_turn" | "tool_use" | etc. - ) - # ``is_error=True`` when the tool_result block carried ``is_error: true`` - # in the JSONL — propagates through NewMessage and lands on the matching - # tool_use Event so ``render_event`` can flip the leading glyph to ✗. - is_error: bool = False - # Claude Code marks its own synthetic error turns at the entry level: - # ``isApiErrorMessage: true`` plus an ``error`` code (e.g. - # "authentication_failed"). Carried through so consumers can tell a real - # API failure from an assistant that merely *talks about* one — matching - # error text against arbitrary assistant output produces false positives. - api_error: str = "" - - -@dataclass -class PendingToolInfo: - """Information about a pending tool_use waiting for its tool_result.""" - - summary: str # Formatted tool summary (e.g. "**Read**(file.py)") - tool_name: str # Tool name (e.g. "Read", "Edit") - input_data: Any = None # Tool input parameters (for Edit to generate diff) +# Preserve the historical pickle/introspection path of public value types even +# though their definitions now live in a dependency-light sibling module. +ParsedMessage.__module__ = __name__ +ParsedEntry.__module__ = __name__ +PendingToolInfo.__module__ = __name__ class TranscriptParser: @@ -106,66 +68,23 @@ class TranscriptParser: @staticmethod def parse_line(line: str) -> dict[str, Any] | None: - """Parse a single JSONL line. - - Args: - line: A single line from the JSONL file - - Returns: - Parsed dict[str, Any] or None if line is empty/invalid - """ - line = line.strip() - if not line: - return None - - try: - return json.loads(line) - except json.JSONDecodeError: - return None + """Parse one JSONL line, returning ``None`` when it is invalid.""" + return _parse_line(line) @staticmethod def get_message_type(data: dict[str, Any]) -> str | None: - """Get the message type from parsed data. - - Returns: - Message type: "user", "assistant", "file-history-snapshot", etc. - """ - return data.get("type") + """Get the top-level transcript message type.""" + return _get_message_type(data) @staticmethod def is_user_message(data: dict[str, Any]) -> bool: - """Check if this is a user message.""" - return data.get("type") == "user" + """Return whether this is a user message.""" + return _is_user_message(data) @staticmethod def extract_text_only(content_list: list[Any]) -> str: - """Extract only text content from structured content. - - This is used for Telegram notifications where we only want - the actual text response, not tool calls or thinking. - - Args: - content_list: List of content blocks - - Returns: - Combined text content only - """ - if not isinstance(content_list, list): # pyright: ignore[reportUnnecessaryIsInstance] - if isinstance(content_list, str): - return content_list - return "" - - texts = [] - for item in content_list: - if isinstance(item, str): - texts.append(item) - elif isinstance(item, dict): - if item.get("type") == "text": - text = item.get("text", "") - if text: - texts.append(text) - - return "\n".join(texts) + """Extract text blocks, excluding tool calls and thinking blocks.""" + return _extract_text_only(content_list) _RE_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") @@ -179,184 +98,17 @@ def extract_text_only(content_list: list[Any]) -> str: @staticmethod def _normalize_codex_entry(data: dict[str, Any]) -> dict[str, Any] | None: - """Translate a stable subset of Codex rollout events to Claude blocks. - - Codex emits user/agent text as ``event_msg`` rows and tool calls as - ``response_item`` rows. Normalizing at this boundary lets the existing - history, live-card, tool-pairing, and Telegram formatting pipeline stay - unchanged. - """ - top_type = data.get("type") - payload = data.get("payload") - if not isinstance(payload, dict): - return None - timestamp = data.get("timestamp") - if top_type == "event_msg": - event_type = payload.get("type") - if event_type == "user_message": - text = str(payload.get("message") or "") - return { - "type": "user", - "timestamp": timestamp, - "message": {"content": [{"type": "text", "text": text}]}, - } - if event_type == "agent_message": - text = str(payload.get("message") or "") - phase = str(payload.get("phase") or "") - return { - "type": "assistant", - "timestamp": timestamp, - "message": { - "content": [{"type": "text", "text": text}], - "stop_reason": "end_turn" - if phase in ("final_answer", "final") - else None, - }, - } - return None - if top_type != "response_item": - return None - item_type = payload.get("type") - # Codex 0.147 stopped emitting the duplicate event_msg rows that used - # to carry user and assistant text. Its replacement message rows are - # numbered with an ordinal. Codex 0.146 also wrote unnumbered message - # response_items alongside event_msg rows, so accepting only numbered - # rows here preserves the old fallback without rendering every turn - # twice. - if item_type == "message" and data.get("ordinal") is not None: - role = str(payload.get("role") or "") - if role not in ("user", "assistant"): - return None - raw_content = payload.get("content", "") - content: list[dict[str, str]] = [] - if isinstance(raw_content, list): - for block in raw_content: - if not isinstance(block, dict): - continue - block_type = block.get("type") - if block_type not in ("input_text", "output_text", "text"): - continue - text = str(block.get("text") or "") - if text: - content.append({"type": "text", "text": text}) - elif isinstance(raw_content, str) and raw_content: - content.append({"type": "text", "text": raw_content}) - if not content: - return None - phase = str(payload.get("phase") or "") - return { - "type": role, - "timestamp": timestamp, - "message": { - "content": content, - "stop_reason": "end_turn" - if role == "assistant" and phase in ("final_answer", "final") - else None, - }, - } - if item_type in ("function_call", "custom_tool_call"): - arguments = payload.get("arguments") - if arguments is None: - arguments = payload.get("input") - if isinstance(arguments, str): - try: - arguments = json.loads(arguments) - except json.JSONDecodeError: - arguments = {"input": arguments} - if not isinstance(arguments, dict): - arguments = {"input": arguments} - return { - "type": "assistant", - "timestamp": timestamp, - "message": { - "content": [ - { - "type": "tool_use", - "id": str( - payload.get("call_id") or payload.get("id") or "" - ), - "name": str(payload.get("name") or "tool"), - "input": arguments, - } - ], - "stop_reason": "tool_use", - }, - } - if item_type in ("function_call_output", "custom_tool_call_output"): - return { - "type": "user", - "timestamp": timestamp, - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": str( - payload.get("call_id") or payload.get("id") or "" - ), - "content": payload.get("output") or "", - } - ] - }, - } - return None + """Normalize one supported Codex rollout row into Claude shape.""" + return normalize_codex_entry(data) @classmethod def parse_message(cls, data: dict[str, Any]) -> ParsedMessage | None: - """Parse a message entry from the JSONL data. - - Args: - data: Parsed JSON dict[str, Any] from a JSONL line - - Returns: - ParsedMessage or None if not a parseable message - """ - msg_type = cls.get_message_type(data) - - if msg_type not in ("user", "assistant"): - return None - - message = data.get("message") - if not isinstance(message, dict): - return None - content = message.get("content", "") - - if isinstance(content, list): - text = cls.extract_text_only(content) - else: - text = str(content) if content else "" - text = cls._RE_ANSI_ESCAPE.sub("", text) - - # Detect local command responses in user messages. - # These are rendered as bot replies: "❯ /cmd\n ⎿ output" - if msg_type == "user" and text: - stdout_match = cls._RE_LOCAL_STDOUT.search(text) - if stdout_match: - stdout = stdout_match.group(1).strip() - cmd_match = cls._RE_COMMAND_NAME.search(text) - cmd = cmd_match.group(1) if cmd_match else None - return ParsedMessage( - message_type="local_command", - text=stdout, - tool_name=cmd, # reuse field for command name - ) - # Pure command invocation (no stdout) — carry command name - cmd_match = cls._RE_COMMAND_NAME.search(text) - if cmd_match: - return ParsedMessage( - message_type="local_command_invoke", - text="", - tool_name=cmd_match.group(1), - ) - - return ParsedMessage( - message_type=msg_type, - text=text, - ) + return _parse_message(cls, data) @staticmethod def get_timestamp(data: dict[str, Any]) -> str | None: """Extract timestamp from message data.""" - return data.get("timestamp") + return _get_timestamp(data) @classmethod def parse_entries( diff --git a/src/ccbot/transcript_types.py b/src/ccbot/transcript_types.py new file mode 100644 index 00000000..971ddf32 --- /dev/null +++ b/src/ccbot/transcript_types.py @@ -0,0 +1,54 @@ +"""Public value objects produced by transcript parsing.""" + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class ParsedMessage: + """Parsed message from a transcript.""" + + message_type: str # "user", "assistant", "tool_use", "tool_result", etc. + text: str # Extracted text content + tool_name: str | None = None # For tool_use messages + + +@dataclass +class ParsedEntry: + """A single parsed message entry ready for display.""" + + role: str # "user" | "assistant" + text: str # Already formatted text + content_type: ( + str # "text" | "thinking" | "tool_use" | "tool_result" | "local_command" + ) + tool_use_id: str | None = None + timestamp: str | None = None # ISO timestamp from JSONL + tool_name: str | None = ( + None # For tool_use entries, the tool name (e.g. "AskUserQuestion") + ) + image_data: list[tuple[str, bytes]] | None = ( + None # For tool_result entries with images: (media_type, raw_bytes) + ) + stop_reason: str | None = ( + None # Assistant message stop_reason: "end_turn" | "tool_use" | etc. + ) + # ``is_error=True`` when the tool_result block carried ``is_error: true`` + # in the JSONL — propagates through NewMessage and lands on the matching + # tool_use Event so ``render_event`` can flip the leading glyph to ✗. + is_error: bool = False + # Claude Code marks its own synthetic error turns at the entry level: + # ``isApiErrorMessage: true`` plus an ``error`` code (e.g. + # "authentication_failed"). Carried through so consumers can tell a real + # API failure from an assistant that merely *talks about* one — matching + # error text against arbitrary assistant output produces false positives. + api_error: str = "" + + +@dataclass +class PendingToolInfo: + """Information about a pending tool_use waiting for its tool_result.""" + + summary: str # Formatted tool summary (e.g. "**Read**(file.py)") + tool_name: str # Tool name (e.g. "Read", "Edit") + input_data: Any = None # Tool input parameters (for Edit to generate diff) diff --git a/tests/ccbot/bot/test_callbacks_footer_pagination.py b/tests/ccbot/bot/test_callbacks_footer_pagination.py new file mode 100644 index 00000000..1a4eda92 --- /dev/null +++ b/tests/ccbot/bot/test_callbacks_footer_pagination.py @@ -0,0 +1,42 @@ +"""Live-card pagination uses the immediate, text-only edit path.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from ccbot.bot.callbacks import footer +from ccbot.handlers.callback_data import CB_PG_PREV +from ccbot.handlers.card_model import CardState + + +@pytest.mark.asyncio +async def test_pagination_answers_first_and_rolls_back_failed_paint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + order: list[str] = [] + query = SimpleNamespace(data=CB_PG_PREV) + query.answer = AsyncMock(side_effect=lambda: order.append("answer")) + context = SimpleNamespace(bot=SimpleNamespace()) + user = SimpleNamespace(id=42) + session = SimpleNamespace(id="s1") + state = CardState(msg_id=9, current_page_idx=1) + + monkeypatch.setattr( + footer.session_manager, "get_active_session", lambda _uid: session + ) + monkeypatch.setattr(footer, "get_card_state", lambda _uid, _sess: state) + monkeypatch.setattr(footer, "card_page_info", lambda _state, _uid: (1, 3)) + + async def failed_refresh(_bot: object, _uid: int, **kwargs: object) -> bool: + order.append("refresh") + assert kwargs == {"immediate": True} + return False + + monkeypatch.setattr(footer, "refresh_panel", failed_refresh) + + assert await footer.handle(query, context, user) + assert order == ["answer", "refresh"] + assert state.current_page_idx == 1 diff --git a/tests/ccbot/handlers/test_card_rich_media.py b/tests/ccbot/handlers/test_card_rich_media.py new file mode 100644 index 00000000..f9e97da7 --- /dev/null +++ b/tests/ccbot/handlers/test_card_rich_media.py @@ -0,0 +1,251 @@ +"""Rich-media live-card transport keeps the pane as its final block.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from telegram.error import BadRequest + +from ccbot.config import config +from ccbot.handlers import card_rich_media, card_transport, message_sender +from ccbot.handlers.card_model import CardState +from ccbot.handlers.card_types import TurnPhase + + +@pytest.mark.asyncio +async def test_send_uploads_pane_and_returns_reusable_file_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + send = AsyncMock(return_value=SimpleNamespace(message_id=17)) + monkeypatch.setattr(config, "rich_messages", True) + monkeypatch.setattr(card_rich_media.rich, "send_rich_message", send) + monkeypatch.setattr( + card_rich_media.rich, + "extract_rich_photo_file_id", + lambda _message: "pane-file-id", + ) + + text = "**answer**\n\ncontext: 42%\n\n─── фон ───" + state = CardState(media_anchor_offset=len("**answer**")) + result = await card_rich_media.send_rich_media_card( + SimpleNamespace(), 42, state, text, b"png", reply_markup=None + ) + + assert result is not None + assert result.message.message_id == 17 + assert result.photo_file_id == "pane-file-id" + assert send.await_args.kwargs["photo"] == b"png" + assert send.await_args.kwargs["disable_notification"] is True + markdown = send.await_args.args[2] + assert markdown.index(card_rich_media.rich.RICH_PHOTO_ANCHOR) < markdown.index( + "context: 42%" + ) + assert ( + f"{card_rich_media._MEDIA_SPACER}\n\n" + f"{card_rich_media.rich.RICH_PHOTO_ANCHOR}\n\n" + f"{card_rich_media._MEDIA_SPACER}" + ) in markdown + assert "
" not in markdown + + +@pytest.mark.asyncio +async def test_send_preserves_legacy_path_when_rich_is_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + send = AsyncMock() + monkeypatch.setattr(config, "rich_messages", False) + monkeypatch.setattr(card_rich_media.rich, "send_rich_message", send) + + result = await card_rich_media.send_rich_media_card( + SimpleNamespace(), 42, CardState(), "answer", b"png", reply_markup=None + ) + + assert result is None + send.assert_not_awaited() + + +def _wire_session(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + card_rich_media, "lookup_session_for_message", lambda _uid, _mid: "s1" + ) + monkeypatch.setattr( + card_rich_media.session_manager, + "get_session", + lambda _sid: SimpleNamespace(window_id="ccbot:1"), + ) + + +@pytest.mark.asyncio +async def test_interactive_text_edit_reuses_cached_photo_without_capture( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _wire_session(monkeypatch) + edit = AsyncMock(return_value=None) + capture = AsyncMock() + monkeypatch.setattr(card_rich_media.rich, "edit_rich_message", edit) + monkeypatch.setattr(card_rich_media, "_capture_pane_png", capture) + monkeypatch.setattr(card_rich_media.time, "monotonic", lambda: 10.0) + state = CardState( + msg_id=9, + is_rich_media_msg=True, + rich_media_file_id="cached-pane", + last_pane_hash="hash-a", + last_photo_edit_ts=1.0, + ) + + assert await card_rich_media.edit_rich_media_card( + SimpleNamespace(), + 42, + state, + text="next", + reply_markup=None, + min_photo_interval=2.5, + refresh_pane=False, + ) + + capture.assert_not_awaited() + assert edit.await_args.kwargs["photo"] == "cached-pane" + + +@pytest.mark.asyncio +async def test_changed_pane_uploads_and_atomically_replaces_file_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _wire_session(monkeypatch) + response = { + "rich_message": { + "blocks": [ + { + "type": "photo", + "photo": [{"file_id": "new-pane", "width": 100, "height": 100}], + } + ] + } + } + edit = AsyncMock(return_value=response) + monkeypatch.setattr(card_rich_media.rich, "edit_rich_message", edit) + monkeypatch.setattr( + card_rich_media, "_capture_pane_png", AsyncMock(return_value=(b"new", "hash-b")) + ) + monkeypatch.setattr(card_rich_media.time, "monotonic", lambda: 10.0) + state = CardState( + msg_id=9, + is_rich_media_msg=True, + rich_media_file_id="old-pane", + last_pane_hash="hash-a", + last_photo_edit_ts=1.0, + ) + + assert await card_rich_media.edit_rich_media_card( + SimpleNamespace(), + 42, + state, + text="next command", + reply_markup=None, + min_photo_interval=2.5, + ) + + assert edit.await_args.kwargs["photo"] == b"new" + assert state.rich_media_file_id == "new-pane" + assert state.last_pane_hash == "hash-b" + assert state.last_photo_edit_ts == 10.0 + + +@pytest.mark.asyncio +async def test_lost_rich_carrier_is_released( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _wire_session(monkeypatch) + monkeypatch.setattr( + card_rich_media.rich, + "edit_rich_message", + AsyncMock(side_effect=BadRequest("Message to edit not found")), + ) + monkeypatch.setattr(card_rich_media.time, "monotonic", lambda: 2.0) + state = CardState( + msg_id=9, + is_rich_media_msg=True, + rich_media_file_id="cached-pane", + last_photo_edit_ts=1.0, + ) + + assert not await card_rich_media.edit_rich_media_card( + SimpleNamespace(), + 42, + state, + text="next", + reply_markup=None, + min_photo_interval=2.5, + ) + assert state.msg_id is None + + +@pytest.mark.asyncio +async def test_final_edit_removes_rich_pane_media( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rich_edit = AsyncMock(return_value=True) + media_edit = AsyncMock() + monkeypatch.setattr(message_sender, "try_rich_edit", rich_edit) + monkeypatch.setattr(card_transport, "edit_rich_media_card", media_edit) + state = CardState( + msg_id=9, + is_rich_media_msg=True, + rich_media_file_id="pane-file", + last_pane_hash="pane-hash", + last_photo_edit_ts=10.0, + turn_phase=TurnPhase.IDLE, + ) + + assert await card_transport._edit_card_unlocked( + SimpleNamespace(), + 42, + state, + text="final answer\n\ncontext: 42%", + reply_markup=SimpleNamespace(), + ) + + rich_edit.assert_awaited_once() + media_edit.assert_not_awaited() + assert state.is_rich_media_msg is False + assert state.rich_media_file_id == "" + assert state.last_pane_hash == "" + assert state.last_photo_edit_ts == 0.0 + + +@pytest.mark.asyncio +async def test_final_send_never_captures_or_attaches_pane( + monkeypatch: pytest.MonkeyPatch, +) -> None: + capture = AsyncMock() + send_text = AsyncMock(return_value=SimpleNamespace(message_id=17)) + monkeypatch.setattr(card_transport, "_inline_screens_enabled", lambda _uid: True) + monkeypatch.setattr(card_transport, "_capture_pane_png", capture) + monkeypatch.setattr(message_sender, "send_with_fallback", send_text) + monkeypatch.setattr(card_transport, "_strip_stale_switchers", AsyncMock()) + monkeypatch.setattr(card_transport, "_register_msg", lambda *_args: None) + monkeypatch.setattr( + card_transport.session_manager, "set_last_switcher_msg", lambda *_args: None + ) + monkeypatch.setattr( + card_transport.session_manager, "set_card_msg", lambda *_args: None + ) + state = CardState(turn_phase=TurnPhase.IDLE) + session = SimpleNamespace(id="s1", window_id="@1") + + await card_transport._send_card_locked( + SimpleNamespace(), + 42, + session, + state, + text="final answer", + reply_markup=SimpleNamespace(), + ) + + capture.assert_not_awaited() + send_text.assert_awaited_once() + assert state.msg_id == 17 + assert state.is_rich_media_msg is False + assert state.is_photo_msg is False diff --git a/tests/ccbot/handlers/test_card_surface_immediate.py b/tests/ccbot/handlers/test_card_surface_immediate.py new file mode 100644 index 00000000..d684f91c --- /dev/null +++ b/tests/ccbot/handlers/test_card_surface_immediate.py @@ -0,0 +1,79 @@ +"""Interactive card refreshes bypass the normal live-update debounce.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from ccbot.handlers import card_surface +from ccbot.handlers.card_model import CardState + + +async def _long_deferred_edit() -> None: + await asyncio.sleep(60) + + +@pytest.mark.asyncio +async def test_regular_refresh_keeps_pending_debounce( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = SimpleNamespace(id="s1") + state = CardState(msg_id=7, last_rendered="old") + pending = asyncio.create_task(_long_deferred_edit()) + state.pending_edit = pending + card_surface._cards[(42, "s1")] = state + monkeypatch.setattr( + card_surface.session_manager, "get_active_session", lambda _uid: session + ) + edit = AsyncMock(return_value=True) + monkeypatch.setattr( + card_surface, + "_legacy", + lambda name: {"_render_card": lambda *_a, **_k: "new", "_edit_card": edit}[ + name + ], + ) + + try: + await card_surface.refresh_panel(SimpleNamespace(), 42) + assert not pending.done() + edit.assert_not_awaited() + finally: + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + card_surface._cards.pop((42, "s1"), None) + + +@pytest.mark.asyncio +async def test_immediate_refresh_cancels_debounce_and_paints_now( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = SimpleNamespace(id="s1") + state = CardState(msg_id=7, last_rendered="old") + pending = asyncio.create_task(_long_deferred_edit()) + state.pending_edit = pending + card_surface._cards[(42, "s1")] = state + monkeypatch.setattr( + card_surface.session_manager, "get_active_session", lambda _uid: session + ) + edit = AsyncMock(return_value=True) + monkeypatch.setattr( + card_surface, + "_legacy", + lambda name: {"_render_card": lambda *_a, **_k: "page 1", "_edit_card": edit}[ + name + ], + ) + + try: + await card_surface.refresh_panel(SimpleNamespace(), 42, immediate=True) + assert pending.cancelled() + assert state.pending_edit is None + edit.assert_awaited_once() + assert edit.await_args.kwargs["refresh_pane"] is False + assert state.last_rendered == "page 1" + finally: + card_surface._cards.pop((42, "s1"), None) diff --git a/tests/ccbot/handlers/test_card_transport_transitions.py b/tests/ccbot/handlers/test_card_transport_transitions.py new file mode 100644 index 00000000..2e150c09 --- /dev/null +++ b/tests/ccbot/handlers/test_card_transport_transitions.py @@ -0,0 +1,296 @@ +"""State-transition contracts for text, rich-media, and legacy card carriers. + +These tests intentionally describe the desired lifecycle at transport boundaries. +They stay separate from the lower-level rich payload tests so carrier state cannot +silently drift when the implementation is reorganized. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from ccbot.config import config +from ccbot.handlers import ( + card_carrier, + card_rich_media, + card_transport, + card_updates, + message_sender, +) +from ccbot.handlers.card_binding import bind_carrier, carrier_kind +from ccbot.handlers.card_model import CardState, CarrierKind, TurnPhase + + +def _assert_text_carrier(state: CardState, *, message_id: int) -> None: + assert state.msg_id == message_id + assert carrier_kind(state) is CarrierKind.TEXT + assert state.is_rich_media_msg is False + assert state.rich_media_file_id == "" + assert state.is_photo_msg is False + assert state.last_pane_hash == "" + assert state.last_photo_edit_ts == 0.0 + + +def _wire_legacy_replacement(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + card_transport, "lookup_session_for_message", lambda _uid, _mid: "s1" + ) + monkeypatch.setattr(card_transport, "_register_msg", lambda *_args: None) + monkeypatch.setattr(card_transport, "_strip_stale_switchers", AsyncMock()) + monkeypatch.setattr( + card_transport.session_manager, "set_card_msg", lambda *_args: None + ) + monkeypatch.setattr( + card_transport.session_manager, "set_last_switcher_msg", lambda *_args: None + ) + + +@pytest.mark.asyncio +async def test_running_turn_restores_rich_pane_after_final_text_transition( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """RICH_MEDIA -> final TEXT -> next running turn -> RICH_MEDIA again.""" + text_edit = AsyncMock(return_value=True) + media_edit = AsyncMock(return_value=True) + monkeypatch.setattr(config, "rich_messages", True) + monkeypatch.setattr(message_sender, "try_rich_edit", text_edit) + monkeypatch.setattr(card_transport, "edit_rich_media_card", media_edit) + monkeypatch.setattr(card_transport, "_inline_screens_enabled", lambda _uid: True) + state = CardState(turn_phase=TurnPhase.IDLE) + bind_carrier( + state, + 9, + CarrierKind.RICH_MEDIA, + rich_media_file_id="pane-file", + pane_hash="pane-hash", + photo_edit_ts=10.0, + ) + + assert await card_transport._edit_card_unlocked( + SimpleNamespace(), + 42, + state, + text="final answer", + reply_markup=SimpleNamespace(), + ) + _assert_text_carrier(state, message_id=9) + + state.turn_phase = TurnPhase.RUNNING + assert await card_transport._edit_card_unlocked( + SimpleNamespace(), + 42, + state, + text="next turn is running", + reply_markup=SimpleNamespace(), + ) + + media_edit.assert_awaited_once() + assert state.msg_id == 9 + assert state.is_rich_media_msg is True + assert state.is_photo_msg is False + + +@pytest.mark.asyncio +async def test_failed_rich_removal_falls_back_on_same_carrier( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A rejected rich edit must still try text fallback before giving up.""" + rich_edit = AsyncMock(return_value=False) + monkeypatch.setattr(message_sender, "try_rich_edit", rich_edit) + bot = SimpleNamespace(edit_message_text=AsyncMock(return_value=True)) + state = CardState(turn_phase=TurnPhase.IDLE) + bind_carrier( + state, + 9, + CarrierKind.RICH_MEDIA, + rich_media_file_id="pane-file", + pane_hash="pane-hash", + photo_edit_ts=10.0, + ) + + assert await card_transport._edit_card_unlocked( + bot, 42, state, text="final answer", reply_markup=SimpleNamespace() + ) + + assert rich_edit.await_count >= 1 + bot.edit_message_text.assert_awaited() + _assert_text_carrier(state, message_id=9) + + +@pytest.mark.asyncio +async def test_final_legacy_photo_is_replaced_send_before_delete( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A legacy photo cannot shed media in place; replace it without data loss.""" + _wire_legacy_replacement(monkeypatch) + order: list[str] = [] + + async def send_text(*_args: object, **_kwargs: object) -> SimpleNamespace: + order.append("send") + return SimpleNamespace(message_id=12) + + async def delete_message(**_kwargs: object) -> bool: + order.append("delete") + return True + + send = AsyncMock(side_effect=send_text) + monkeypatch.setattr(message_sender, "send_with_fallback", send) + bot = SimpleNamespace(delete_message=AsyncMock(side_effect=delete_message)) + state = CardState(turn_phase=TurnPhase.IDLE) + bind_carrier( + state, + 9, + CarrierKind.LEGACY_PHOTO, + pane_hash="pane-hash", + photo_edit_ts=10.0, + ) + + assert await card_transport._edit_card_unlocked( + bot, 42, state, text="final answer", reply_markup=SimpleNamespace() + ) + + assert order == ["send", "delete"] + bot.delete_message.assert_awaited_once_with(chat_id=42, message_id=9) + _assert_text_carrier(state, message_id=12) + + +@pytest.mark.asyncio +async def test_failed_legacy_photo_replacement_rolls_back_binding( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep the old photo carrier bound when both replacement sends fail.""" + _wire_legacy_replacement(monkeypatch) + send = AsyncMock(return_value=None) + monkeypatch.setattr(message_sender, "send_with_fallback", send) + bot = SimpleNamespace(delete_message=AsyncMock(return_value=True)) + state = CardState(turn_phase=TurnPhase.IDLE) + bind_carrier( + state, + 9, + CarrierKind.LEGACY_PHOTO, + pane_hash="pane-hash", + photo_edit_ts=10.0, + ) + + assert not await card_transport._edit_card_unlocked( + bot, 42, state, text="final answer", reply_markup=SimpleNamespace() + ) + + bot.delete_message.assert_not_awaited() + send.assert_awaited_once() + assert state.msg_id == 9 + assert state.is_photo_msg is True + assert state.is_rich_media_msg is False + assert state.last_pane_hash == "pane-hash" + assert state.last_photo_edit_ts == 10.0 + + +@pytest.mark.asyncio +async def test_missing_rich_photo_preserves_existing_carrier_binding( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Capture/file-id failure is retryable and is not proof of a lost message.""" + monkeypatch.setattr( + card_rich_media, "lookup_session_for_message", lambda _uid, _mid: None + ) + state = CardState() + bind_carrier(state, 9, CarrierKind.RICH_MEDIA) + + assert not await card_rich_media.edit_rich_media_card( + SimpleNamespace(), + 42, + state, + text="running", + reply_markup=None, + min_photo_interval=2.5, + ) + + assert state.msg_id == 9 + assert state.is_rich_media_msg is True + + +@pytest.mark.asyncio +async def test_finalize_waits_for_in_flight_deferred_edit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not cancel an edit after its Telegram request phase has begun.""" + state = CardState(msg_id=9, pending_edit_in_flight=True) + release = asyncio.Event() + was_cancelled = False + + async def in_flight_edit() -> None: + nonlocal was_cancelled + try: + await release.wait() + except asyncio.CancelledError: + was_cancelled = True + raise + finally: + state.pending_edit_in_flight = False + + pending = asyncio.create_task(in_flight_edit()) + state.pending_edit = pending + final_edit = AsyncMock(return_value=True) + ensure_seeded = AsyncMock(return_value=None) + monkeypatch.setattr(card_updates, "get_card_state", lambda _uid, _sess: state) + monkeypatch.setattr(card_updates, "_should_buffer", lambda *_args: False) + monkeypatch.setattr( + card_updates, + "_legacy", + lambda name: { + "_ensure_seeded": ensure_seeded, + "_render_card": lambda *_args, **_kwargs: "rendered final", + "build_footer_keyboard": lambda *_args, **_kwargs: None, + "_edit_card": final_edit, + }[name], + ) + session = SimpleNamespace(id="s1", window_id="") + + finalize = asyncio.create_task( + card_updates.finalize_task(SimpleNamespace(), 42, session, "final answer") + ) + await asyncio.sleep(0) + edited_before_release = final_edit.await_count + release.set() + await asyncio.gather(pending, finalize, return_exceptions=True) + + assert was_cancelled is False + assert edited_before_release == 0 + final_edit.assert_awaited_once() + assert state.pending_edit is None + + +@pytest.mark.parametrize( + ("kind", "file_id"), + [(CarrierKind.RICH_MEDIA, "pane-file"), (CarrierKind.LEGACY_PHOTO, "")], +) +def test_carrier_rebinding_clears_previous_media_kind( + monkeypatch: pytest.MonkeyPatch, + kind: CarrierKind, + file_id: str, +) -> None: + """Media flags describe the newly bound message, never an old carrier.""" + key = (42, "to") + state = CardState() + bind_carrier( + state, + 8, + kind, + rich_media_file_id=file_id, + pane_hash="pane-hash", + photo_edit_ts=10.0, + ) + card_carrier._cards[key] = state + monkeypatch.setattr( + card_carrier.session_manager, "set_card_msg", lambda *_args: None + ) + + try: + assert card_carrier.transfer_card_to_carrier(42, None, "to", 15) == 8 + _assert_text_carrier(state, message_id=15) + finally: + card_carrier._cards.pop(key, None) diff --git a/tests/ccbot/handlers/test_menu_settings_text.py b/tests/ccbot/handlers/test_menu_settings_text.py new file mode 100644 index 00000000..53f60bf4 --- /dev/null +++ b/tests/ccbot/handlers/test_menu_settings_text.py @@ -0,0 +1,61 @@ +"""Settings text preserves intentional line breaks in Rich Markdown.""" + +from __future__ import annotations + +import pytest + +from ccbot.handlers.menu import render_settings_group_text, render_settings_text +from ccbot.handlers.menu_settings_data import _GROUP_TEXT_KEYS +from ccbot.i18n import TRANSLATIONS +from ccbot.rich import to_rich_markdown +from ccbot.session import session_manager + + +def _assert_hard_single_breaks(text: str) -> None: + for index, char in enumerate(text): + if char != "\n": + continue + previous_is_newline = index > 0 and text[index - 1] == "\n" + next_is_newline = index + 1 < len(text) and text[index + 1] == "\n" + if not previous_is_newline and not next_is_newline: + assert text[:index].endswith(" ") + + +@pytest.mark.parametrize("language", ["en", "ru", "zh"]) +def test_settings_body_uses_hard_breaks_without_losing_paragraphs( + language: str, monkeypatch: pytest.MonkeyPatch +) -> None: + settings = { + "language": language, + "live_lag": 4, + "voice": "auto", + } + monkeypatch.setattr(session_manager, "get_user_settings", lambda _uid: settings) + monkeypatch.setattr(session_manager, "agent_backend", "claude") + + rendered = render_settings_text(42) + expected = TRANSLATIONS[language]["settings.body"].format( + agent="Claude", language=language, live_lag=4, voice="auto" + ) + + assert rendered.replace(" \n", "\n") == expected + assert "\n\n" in rendered + _assert_hard_single_breaks(rendered) + _assert_hard_single_breaks(to_rich_markdown(rendered)) + + +@pytest.mark.parametrize("language", ["en", "ru", "zh"]) +def test_every_settings_group_keeps_locale_text_and_hard_breaks( + language: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + session_manager, + "get_user_settings", + lambda _uid: {"language": language}, + ) + + for screen, key in _GROUP_TEXT_KEYS.items(): + rendered = render_settings_group_text(42, screen) # type: ignore[arg-type] + expected = TRANSLATIONS[language].get(key) or TRANSLATIONS["en"][key] + assert rendered.replace(" \n", "\n") == expected + _assert_hard_single_breaks(rendered) diff --git a/tests/ccbot/handlers/test_stall_recovery.py b/tests/ccbot/handlers/test_stall_recovery.py index 4ee187d3..015ba9e0 100644 --- a/tests/ccbot/handlers/test_stall_recovery.py +++ b/tests/ccbot/handlers/test_stall_recovery.py @@ -1,213 +1,40 @@ -"""Regression tests for stall_finalize false-positive recovery. - -When ``maybe_finalize_stalled`` fires too eagerly (e.g. the metrics-debug -incident on 2026-06-17: ``tail=tool_use idle=96s`` while Claude was -reasoning toward the final answer), a genuine assistant turn lands -*after* the STALL_NOTE was already appended to the card. Without the -recovery path the real reply would be silently edited into a card the -user has already scrolled past or marked as complete. - -The fix: ``maybe_finalize_stalled`` sets ``state.stall_finalized=True`` -after the STALL_NOTE finalize_task lands. Both ``update_session_card`` -and ``finalize_task`` check the flag on entry and wipe the binding via -``_recover_from_false_stall`` so the next render lands as a fresh -``_send_card`` below the stub. -""" +"""A real event or final answer exits silent-turn observation cleanly.""" from __future__ import annotations -import time -from typing import Any -from unittest.mock import AsyncMock, MagicMock +from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest -from ccbot.handlers import notifications -from ccbot.handlers.notifications import ( - CardState, - Event, - _card_locks, - _cards, - _recover_from_false_stall, - _repost_intent, -) -from ccbot.session_models import Session -from ccbot.session_monitor import NewMessage - - -@pytest.fixture(autouse=True) -def _clear_card_state(): - _cards.clear() - _card_locks.clear() - _repost_intent.clear() - yield - _cards.clear() - _card_locks.clear() - _repost_intent.clear() - - -def _make_sess(sid: str = "s1") -> Session: - return Session( - id=sid, - name="test", - window_id="@1", - workdir="/tmp", - state="active", - claude_session_id="uuid-" + sid, - ) - - -def _stalled_msg(text: str = "real answer at last") -> NewMessage: - return NewMessage( - session_id="uuid-s1", - text=text, - is_complete=True, - content_type="text", - role="assistant", - stop_reason="end_turn", - ) - - -def _seed_stalled_card(user_id: int, sess: Session, *, msg_id: int = 999) -> CardState: - """Seed a card in the post-stall_finalize state: STALL_NOTE landed, - flag armed, the original tool_use + STALL_NOTE final_text are in - events. This mirrors what ``maybe_finalize_stalled`` leaves behind.""" - now = time.time() - state = _cards.setdefault((user_id, sess.id), CardState()) - state.msg_id = msg_id - state.events = [ - Event(type="user_msg", text="run the analysis", started_at=now - 600), - Event(type="tool_use", text="Bash(…)", started_at=now - 500), - Event( - type="final_text", - text=notifications.STALL_NOTE, - started_at=now - 100, - ), - ] - state.last_event_ts = now - 100 - state.stall_finalized = True - return state - - -def test_recover_helper_wipes_binding_and_flag(): - """``_recover_from_false_stall`` clears msg_id, events, last_rendered - AND the flag itself; sets is_continuation + clears seed_attempted so - the next event re-pulls JSONL context.""" - state = CardState() - state.msg_id = 7177 - state.events = [Event(type="final_text", text="stub", started_at=time.time())] - state.last_rendered = "rendered stub" - state.stall_finalized = True - state.seed_attempted = True - state.current_page_idx = 3 - state.is_continuation = False - - _recover_from_false_stall(state) - - assert state.msg_id is None - assert state.events == [] - assert state.last_rendered == "" - assert state.stall_finalized is False - assert state.seed_attempted is False - assert state.current_page_idx is None - assert state.is_continuation is True - - -@pytest.mark.asyncio -async def test_update_session_card_recovers_after_stall(monkeypatch): - """A genuine assistant turn arriving after a false stall_finalize - must spawn a FRESH card (``_send_card``), NOT edit the stalled stub - (``_edit_card``). The recovery path is triggered by - ``state.stall_finalized=True``.""" - sess = _make_sess() - bot = AsyncMock() - sent: list[Any] = [] - edits: list[Any] = [] - - async def fake_send_card(b, uid, s, st, *, text, reply_markup=None): - st.msg_id = 5000 + len(sent) - sent.append(text) - - async def fake_edit_card(b, uid, st, *, text, reply_markup=None): - edits.append(text) - return True - - monkeypatch.setattr(notifications, "_send_card", fake_send_card) - monkeypatch.setattr(notifications, "_edit_card", fake_edit_card) - monkeypatch.setattr(notifications, "_ensure_seeded", AsyncMock(return_value=None)) - fake_active = MagicMock() - fake_active.id = sess.id - monkeypatch.setattr( - notifications.session_manager, - "get_active_session", - lambda uid: fake_active, - ) - monkeypatch.setattr( - notifications.session_manager, - "get_user_settings", - lambda uid: {"live_lag": 0}, - ) - - user_id = 42 - state = _seed_stalled_card(user_id, sess, msg_id=7177) - assert state.stall_finalized is True - assert state.msg_id == 7177 - - await notifications.update_session_card(bot, user_id, sess, _stalled_msg()) - - assert len(sent) == 1, f"expected fresh card spawn, got sends={sent} edits={edits}" - assert edits == [], "stalled stub must not be edited" - assert _cards[(user_id, sess.id)].stall_finalized is False +from ccbot.handlers import card_updates +from ccbot.handlers.card_types import CardState, Event, TurnPhase @pytest.mark.asyncio -async def test_finalize_task_recovers_after_stall(monkeypatch): - """A real end-of-turn assistant text arriving after a false stall - routes through ``finalize_task`` (not update_session_card). It also - must spawn a fresh card below the stub.""" - sess = _make_sess() - bot = AsyncMock() - sent: list[Any] = [] - edits: list[Any] = [] - - async def fake_send_card(b, uid, s, st, *, text, reply_markup=None): - st.msg_id = 6000 + len(sent) - sent.append(text) - - async def fake_edit_card(b, uid, st, *, text, reply_markup=None): - edits.append(text) - return True - - monkeypatch.setattr(notifications, "_send_card", fake_send_card) - monkeypatch.setattr(notifications, "_edit_card", fake_edit_card) - monkeypatch.setattr(notifications, "_ensure_seeded", AsyncMock(return_value=None)) - fake_active = MagicMock() - fake_active.id = sess.id - monkeypatch.setattr( - notifications.session_manager, - "get_active_session", - lambda uid: fake_active, +async def test_final_clears_stall_watch(monkeypatch: pytest.MonkeyPatch) -> None: + state = CardState( + msg_id=9, + events=[Event(type="thinking", text="work", started_at=1.0)], + stall_watch_active=True, + last_stall_pane_refresh_ts=3.0, ) + sess = SimpleNamespace(id="s1", window_id="") + monkeypatch.setattr(card_updates, "get_card_state", lambda *_a: state) + monkeypatch.setattr(card_updates, "_should_buffer", lambda *_a: False) monkeypatch.setattr( - notifications.session_manager, - "get_user_settings", - lambda uid: {"live_lag": 0, "card_page_lines": 45}, + card_updates, + "_legacy", + lambda name: { + "_ensure_seeded": AsyncMock(), + "_render_card": lambda *_a, **_k: "final", + "build_footer_keyboard": lambda *_a, **_k: None, + "_edit_card": AsyncMock(return_value=True), + }[name], ) - # ``finalize_task`` calls ``prewarm_pages_cache`` which would hit the - # JSONL file system path — stub it to a no-op. - fake_prewarm = AsyncMock(return_value=None) - monkeypatch.setattr(notifications, "_send_attachments", AsyncMock()) - - import ccbot.handlers.history as _history_mod - - monkeypatch.setattr(_history_mod, "prewarm_pages_cache", fake_prewarm) - - user_id = 42 - state = _seed_stalled_card(user_id, sess, msg_id=7177) - assert state.stall_finalized is True - await notifications.finalize_task(bot, user_id, sess, "the real BT_fin answer") + await card_updates.finalize_task(SimpleNamespace(), 42, sess, "done") - assert len(sent) == 1, f"expected fresh card spawn, got sends={sent} edits={edits}" - assert edits == [], "stalled stub must not be edited by finalize_task" - assert _cards[(user_id, sess.id)].stall_finalized is False + assert state.turn_phase is TurnPhase.IDLE + assert state.stall_watch_active is False + assert state.last_stall_pane_refresh_ts == 0.0 diff --git a/tests/ccbot/test_rich_messages.py b/tests/ccbot/test_rich_messages.py index 24f7b440..2fbe9f68 100644 --- a/tests/ccbot/test_rich_messages.py +++ b/tests/ccbot/test_rich_messages.py @@ -1,4 +1,4 @@ -"""Tests for the Bot API 10.1 rich-message layer (rich.py + safe_* wiring). +"""Tests for the Bot API 10.2 rich-message layer (rich.py + safe_* wiring). Covers to_rich_markdown escaping rules (bare ``<`` vs supported tags vs code spans), expandable-quote →
conversion, and the @@ -187,6 +187,36 @@ def _sent_message_json() -> dict[str, Any]: } +def _sent_rich_photo_json() -> dict[str, Any]: + return { + **_sent_message_json(), + "rich_message": { + "blocks": [ + {"type": "paragraph", "text": {"text": "status"}}, + { + "type": "photo", + "photo": [ + { + "file_id": "photo-small", + "file_unique_id": "small-unique", + "width": 90, + "height": 60, + "file_size": 100, + }, + { + "file_id": "photo-large", + "file_unique_id": "large-unique", + "width": 1280, + "height": 720, + "file_size": 20_000, + }, + ], + }, + ] + }, + } + + class _FakeBot: """Minimal stand-in for ExtBot: records _post calls.""" @@ -203,6 +233,163 @@ async def _post(self, endpoint: str, data: dict[str, Any]) -> Any: return self._post_result +class TestRichPhotoMedia: + @pytest.mark.asyncio + async def test_send_uploads_photo_as_separate_multipart_field(self) -> None: + from telegram import InputFile + + photo_bytes = b"\x89PNG\r\n\x1a\nterminal screenshot" + bot = _FakeBot(post_result=_sent_rich_photo_json()) + + msg = await rich.send_rich_message( # type: ignore[arg-type] + bot, 449, "**Status**\n", photo=photo_bytes + ) + + endpoint, data = bot.posts[0] + assert endpoint == "sendRichMessage" + assert data["rich_message"] == { + "markdown": ("**Status**\n\n![](tg://photo?id=terminal_screenshot)"), + "media": [ + { + "id": "terminal_screenshot", + "media": { + "type": "photo", + "media": "attach://terminal_screenshot", + }, + } + ], + } + upload = data["terminal_screenshot"] + assert isinstance(upload, InputFile) + assert upload.input_file_content == photo_bytes + assert upload.filename == "terminal_screenshot.png" + assert upload.attach_name is None + assert rich.extract_rich_photo_file_id(msg) == "photo-large" + + @pytest.mark.asyncio + async def test_send_reuses_photo_file_id_without_upload(self) -> None: + bot = _FakeBot(post_result=_sent_message_json()) + + await rich.send_rich_message( # type: ignore[arg-type] + bot, 449, "Status", photo="existing-photo-file-id" + ) + + data = bot.posts[0][1] + assert "terminal_screenshot" not in data + assert data["rich_message"]["media"] == [ + { + "id": "terminal_screenshot", + "media": { + "type": "photo", + "media": "existing-photo-file-id", + }, + } + ] + assert data["rich_message"]["markdown"].endswith( + "\n\n![](tg://photo?id=terminal_screenshot)" + ) + + @pytest.mark.asyncio + async def test_photo_anchor_places_media_before_service_tail(self) -> None: + bot = _FakeBot(post_result=_sent_message_json()) + markdown = f"answer\n\n{rich.RICH_PHOTO_ANCHOR}\n\ncontext: 42%\n\n─── фон ───" + + await rich.send_rich_message( # type: ignore[arg-type] + bot, 449, markdown, photo="existing-photo-file-id" + ) + + rendered = bot.posts[0][1]["rich_message"]["markdown"] + assert rich.RICH_PHOTO_ANCHOR not in rendered + assert rendered.index("![](tg://photo?id=terminal_screenshot)") < ( + rendered.index("context: 42%") + ) + + @pytest.mark.asyncio + async def test_photo_anchor_is_invisible_without_photo(self) -> None: + bot = _FakeBot(post_result=_sent_message_json()) + + await rich.send_rich_message( # type: ignore[arg-type] + bot, 449, f"answer{rich.RICH_PHOTO_ANCHOR}context" + ) + + assert bot.posts[0][1]["rich_message"]["markdown"] == "answercontext" + + @pytest.mark.asyncio + async def test_send_forwards_explicit_disable_notification_only(self) -> None: + quiet_bot = _FakeBot(post_result=_sent_message_json()) + default_bot = _FakeBot(post_result=_sent_message_json()) + + await rich.send_rich_message( # type: ignore[arg-type] + quiet_bot, 449, "Status", disable_notification=True + ) + await rich.send_rich_message( # type: ignore[arg-type] + default_bot, 449, "Status" + ) + + assert quiet_bot.posts[0][1]["disable_notification"] is True + assert "disable_notification" not in default_bot.posts[0][1] + + @pytest.mark.asyncio + async def test_edit_accepts_true_result_with_photo_upload(self) -> None: + from telegram import InputFile + + bot = _FakeBot(post_result=True) + + result = await rich.edit_rich_message( # type: ignore[arg-type] + bot, 449, 7, "Status", photo=b"photo" + ) + + assert result is None + endpoint, data = bot.posts[0] + assert endpoint == "editMessageText" + assert data["message_id"] == 7 + assert isinstance(data["terminal_screenshot"], InputFile) + assert data["rich_message"]["media"][0]["media"]["media"] == ( + "attach://terminal_screenshot" + ) + + @pytest.mark.asyncio + async def test_edit_returns_message_for_new_photo_file_id(self) -> None: + bot = _FakeBot(post_result=_sent_rich_photo_json()) + + msg = await rich.edit_rich_message( # type: ignore[arg-type] + bot, 449, 7, "Status", photo=b"new photo" + ) + + assert msg is not None and msg.message_id == 42 + assert rich.extract_rich_photo_file_id(msg) == "photo-large" + + def test_extract_file_id_from_raw_rich_message(self) -> None: + raw = _sent_rich_photo_json()["rich_message"] + + assert rich.extract_rich_photo_file_id(raw) == "photo-large" + + def test_extract_file_id_handles_nested_and_missing_media(self) -> None: + raw = { + "blocks": [ + { + "type": "collage", + "blocks": [ + { + "type": "photo", + "photo": [ + { + "file_id": "nested", + "width": 320, + "height": 200, + } + ], + } + ], + } + ] + } + + assert rich.extract_rich_photo_file_id(raw) == "nested" + assert rich.extract_rich_photo_file_id({"blocks": []}) is None + assert rich.extract_rich_photo_file_id(True) is None + + @pytest.fixture def rich_on(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(config, "rich_messages", True) diff --git a/tests/e2e/test_stalled_finalize.py b/tests/e2e/test_stalled_finalize.py index 9e027999..f9eef7f3 100644 --- a/tests/e2e/test_stalled_finalize.py +++ b/tests/e2e/test_stalled_finalize.py @@ -1,122 +1,52 @@ -"""E2E regression (bug A4): stalled-subprocess card finalize. - -When the upstream claude process silently stalls, the JSONL stops growing with -renderable entries and the live card freezes on its last non-terminal tail -(thinking / tool_use) forever. ``status_polling.update_status_message`` runs -``maybe_finalize_stalled`` each poll; for an ACTIVE session whose card has a -non-terminal tail, an idle (non-changing) pane, and a stale ``last_event_ts``, -it finalises the card with the stall note via the real ``finalize_task`` path. - -This test drives the FULL status-poll entry point (not the unit ``maybe_*`` -helper directly) with a FakeTmuxManager returning an idle pane, and asserts the -card msg got edited to carry ``STALL_NOTE``. -""" +"""End-to-end state contract for a silent unfinished active turn.""" from __future__ import annotations import time +from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest from ccbot.handlers import notifications -from ccbot.handlers.notifications import ( - STALL_FINALIZE_AFTER_SECONDS, - STALL_FINALIZE_TOOL_USE_SECONDS, - STALL_NOTE, - CardState, - Event, -) -from ccbot.handlers.status_polling import update_status_message -from ccbot.session import session_manager - -from harness import USER_ID, seed_session - -WINDOW_ID = "@100" -WORKDIR = "/tmp/proj" -CLAUDE_SID = "44444444-4444-4444-4444-444444444444" - -# An idle pane: no spinner status line, no interactive UI. parse_status_line -# returns None here, so pane_busy is False — the stall fingerprint. -IDLE_PANE = "user@host:~/proj$ \n" - - -def _seed_frozen_card(*, tail_type: str = "tool_use") -> CardState: - """Install an active-session card whose tail is non-terminal and whose - last event is older than the per-tail-type stall threshold.""" - threshold = ( - STALL_FINALIZE_TOOL_USE_SECONDS - if tail_type == "tool_use" - else STALL_FINALIZE_AFTER_SECONDS - ) - now = time.time() - state = CardState() - state.msg_id = 7777 - state.last_event_ts = now - (threshold + 30) - state.last_edit_ts = 0.0 - state.events = [ - Event(type="user_msg", text="do the long thing", started_at=now - 200), - Event(type=tail_type, text="Bash(make build)", started_at=now - 180), - ] - notifications._cards[(USER_ID, "dddd4444")] = state - return state +from ccbot.handlers.card_types import CardState, Event, TurnPhase +from ccbot.session_models import Session @pytest.mark.asyncio -async def test_idle_pane_finalizes_frozen_card(fake_tmux, fake_bot, no_card_lag): - fake_tmux.add_window(WINDOW_ID, name="proj", cwd=WORKDIR, pane=IDLE_PANE) - seed_session( - session_manager, - sid="dddd4444", - name="proj", - window_id=WINDOW_ID, - workdir=WORKDIR, - claude_session_id=CLAUDE_SID, - active_for=USER_ID, +async def test_silent_turn_preserves_event_and_live_pane( + monkeypatch: pytest.MonkeyPatch, +) -> None: + user_id = 42 + sess = Session( + id="s1", + name="active", + window_id="@1", + workdir="/tmp", + state="active", + claude_session_id="uuid-s1", ) - _seed_frozen_card(tail_type="tool_use") - - await update_status_message(fake_bot, USER_ID, WINDOW_ID) - - # The card msg was edited to carry the stall note (finalize_task path). - assert fake_bot.edit_message_text.call_count >= 1 - edit_texts = [e["text"] for e in fake_bot.edits] - # STALL_NOTE leads with "⚠️ session went idle without a final reply" — - # match on a distinctive non-escapable fragment. - assert any("went idle without a final reply" in t for t in edit_texts), edit_texts - # Card tail is now terminal. - state = notifications._cards[(USER_ID, "dddd4444")] - assert state.events[-1].type == "final_text" - assert STALL_NOTE in state.events[-1].text - - -@pytest.mark.asyncio -async def test_busy_pane_does_not_finalize(fake_tmux, fake_bot, no_card_lag): - # A changing spinner = genuine work. The card must NOT be finalized. - # parse_status_line anchors on the chrome separator below the spinner. - busy_pane = ( - "Some intermediate output\n" - "● Working… (18s · ↑1.2k tokens)\n" - "────────────────────\n" - "❯\n" - "────────────────────\n" - " ⏵⏵ bypass permissions on\n" - ) - fake_tmux.add_window(WINDOW_ID, name="proj", cwd=WORKDIR, pane=busy_pane) - seed_session( - session_manager, - sid="dddd4444", - name="proj", - window_id=WINDOW_ID, - workdir=WORKDIR, - claude_session_id=CLAUDE_SID, - active_for=USER_ID, + event = Event(type="tool_use", text="Bash", started_at=time.time() - 400) + state = CardState(msg_id=77, events=[event], last_event_ts=time.time() - 400) + notifications._cards[(user_id, sess.id)] = state + monkeypatch.setattr( + notifications.session_manager, "get_active_session", lambda _uid: sess ) - _seed_frozen_card(tail_type="thinking") - - await update_status_message(fake_bot, USER_ID, WINDOW_ID) - - state = notifications._cards[(USER_ID, "dddd4444")] - # Tail stays non-terminal; no stall note appended. - assert state.events[-1].type == "thinking" - edit_texts = [e["text"] for e in fake_bot.edits] - assert not any("went idle without a final reply" in t for t in edit_texts) + monkeypatch.setattr(notifications, "_render_card", lambda *_a, **_k: "live") + edit = AsyncMock(return_value=True) + monkeypatch.setattr(notifications, "_edit_card", edit) + + try: + assert await notifications.maybe_finalize_stalled( + SimpleNamespace(), + user_id, + sess, + pane_busy=False, + interactive_waiting=False, + in_menu=False, + ) + assert state.events == [event] + assert state.turn_phase is TurnPhase.RUNNING + edit.assert_awaited_once() + finally: + notifications._cards.clear() diff --git a/tests/test_session_header_dir_label.py b/tests/test_session_header_dir_label.py index eae9cfb3..da3c3159 100644 --- a/tests/test_session_header_dir_label.py +++ b/tests/test_session_header_dir_label.py @@ -6,6 +6,9 @@ from __future__ import annotations +import pytest + +from ccbot.handlers import card_layout from ccbot.handlers.card_model import CardState, Event, _render_card from ccbot.handlers.switcher import build_session_preview from ccbot.session_models import Session @@ -67,6 +70,27 @@ def test_marker_absent_when_not_pending(self) -> None: assert "🎙" not in text +def test_media_anchor_precedes_context_and_background_panel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + card_layout.bg_status, + "render_panel", + lambda *_args, **_kwargs: "─── фон ───\nbackground-session", + ) + state = CardState(context_pct=42) + state.events.append(Event(type="final_text", text="answer", started_at=1.0)) + + text = _render_card(_session(), state, user_id=1) + body = text[: state.media_anchor_offset] + service_tail = text[state.media_anchor_offset :] + + assert "answer" in body + assert "context:" not in body + assert "─── фон ───" not in body + assert service_tail.index("context: 42%") < service_tail.index("─── фон ───") + + class TestSwitcherPreviewDirLabel: def test_active_preview_shows_dirname(self) -> None: sess = _session() diff --git a/tests/test_stalled_card.py b/tests/test_stalled_card.py index ad5e68e4..5b540514 100644 --- a/tests/test_stalled_card.py +++ b/tests/test_stalled_card.py @@ -1,394 +1,179 @@ -"""Regression tests for bug A4 — stalled-session card rescue. - -When the upstream claude subprocess silently stalls or exits mid-turn, -the JSONL stops growing with renderable entries (it may still get -``last-prompt`` / ``ai-title`` metadata, which transcript_parser filters -out), so the session monitor produces ZERO card updates and the live -card freezes on its last "thinking" / tool_use frame forever, with no -signal to the user. - -``notifications.maybe_finalize_stalled`` closes that gap: for an ACTIVE -session whose card has a non-terminal tail event and an idle (non-busy) -pane that has stayed that way for ``STALL_FINALIZE_AFTER_SECONDS``, it -finalises the card with a clear note via the normal ``finalize_task`` -path. These tests pin the trigger condition and the negative cases that -must NOT fire (still-changing spinner, already-finalized card, waiting -interactive UI / kb prompt, menu navigation, too-recent event). -""" +"""Silent unfinished turns stay observable without synthetic warnings.""" from __future__ import annotations import time -from unittest.mock import ANY, AsyncMock +from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest -from ccbot.handlers import notifications -from ccbot.handlers.notifications import ( - STALL_FINALIZE_AFTER_SECONDS, - STALL_FINALIZE_TOOL_USE_SECONDS, - STALL_NOTE, - CardState, - Event, - _cards, - maybe_finalize_stalled, -) +from ccbot.handlers import bg_status, notifications +from ccbot.handlers.card_types import CardState, Event, TurnPhase from ccbot.session_models import Session -@pytest.fixture(autouse=True) -def _clear_cards(): - _cards.clear() - yield - _cards.clear() - - -@pytest.fixture -def stub_finalize(monkeypatch): - """Replace ``finalize_task`` with a recorder so we only assert the - trigger decision, not the full card-render machinery.""" - calls: list[tuple[int, str, str]] = [] - - async def _fake_finalize(bot, user_id, sess, final_text): # type: ignore[no-untyped-def] - calls.append((user_id, sess.id, final_text)) - - monkeypatch.setattr(notifications, "finalize_task", _fake_finalize) - return calls - - -@pytest.fixture(autouse=True) -def stub_stall_alert(monkeypatch): - """Keep tests offline while exposing the separate push notification.""" - send = AsyncMock() - monkeypatch.setattr(notifications, "safe_send", send) - return send - - -def _make_sess(sid: str = "s1") -> Session: +def _session(sid: str = "s1") -> Session: return Session( id=sid, name="tests", window_id="@1", workdir="/tmp", state="active", - claude_session_id="uuid-" + sid, + claude_session_id=f"uuid-{sid}", ) -def _seed_card( - user_id: int, - sess: Session, - *, - tail_type: str = "thinking", - msg_id: int | None = 100, - age_seconds: float = STALL_FINALIZE_AFTER_SECONDS + 30, - in_menu_view: bool = False, - in_kb_mode: bool = False, -) -> CardState: - """Install a CardState whose last event is ``tail_type`` and which - last updated ``age_seconds`` ago.""" +def _seed(user_id: int, sess: Session, *, tail: str = "thinking") -> CardState: now = time.time() - state = CardState() - state.msg_id = msg_id - state.in_menu_view = in_menu_view - state.in_kb_mode = in_kb_mode - state.last_event_ts = now - age_seconds - state.events = [ - Event(type="user_msg", text="do the thing", started_at=now - age_seconds - 5), - Event(type=tail_type, text="", started_at=now - age_seconds), - ] - _cards[(user_id, sess.id)] = state + state = CardState( + msg_id=100, + events=[Event(type=tail, text="unfinished", started_at=now - 400)], + last_event_ts=now - 400, + ) + notifications._cards[(user_id, sess.id)] = state return state -# ── Positive: the trigger fires ─────────────────────────────────────── - - -class TestStallFires: - @pytest.mark.asyncio - async def test_idle_nonterminal_tail_finalizes( - self, stub_finalize, stub_stall_alert - ): - """Card non-finalized + pane idle + last event stale + no UI/menu - => stalled-finalize path invoked with the stall note.""" - user_id, sess = 42, _make_sess() - _seed_card(user_id, sess, tail_type="thinking") - - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=False, - interactive_waiting=False, - in_menu=False, - ) - - assert fired is True - assert stub_finalize == [(user_id, sess.id, STALL_NOTE)] - stub_stall_alert.assert_awaited_once_with( - ANY, - user_id, - notifications.STALL_ALERT.format(session_name=sess.name), - ) - - @pytest.mark.asyncio - async def test_tool_use_tail_finalizes(self, stub_finalize): - """A tool_use whose result never came is also a stall fingerprint - once the pane has been idle long enough — but the threshold is - the longer ``STALL_FINALIZE_TOOL_USE_SECONDS`` because slow tools - and post-tool reasoning are legitimately silent for minutes.""" - user_id, sess = 42, _make_sess() - _seed_card( - user_id, - sess, - tail_type="tool_use", - age_seconds=STALL_FINALIZE_TOOL_USE_SECONDS + 30, - ) - - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=False, - interactive_waiting=False, - in_menu=False, - ) - - assert fired is True - assert len(stub_finalize) == 1 - - @pytest.mark.asyncio - async def test_tool_use_within_extended_threshold_no_fire(self, stub_finalize): - """A tool_use tail idle for ``STALL_FINALIZE_AFTER_SECONDS`` but - under ``STALL_FINALIZE_TOOL_USE_SECONDS`` must NOT finalize — this - is the metrics-debug regression: Claude was reasoning ~96 s after - the last tool_use before emitting the final answer, which the - old single-threshold policy treated as a stall.""" - user_id, sess = 42, _make_sess() - _seed_card( - user_id, - sess, - tail_type="tool_use", - age_seconds=STALL_FINALIZE_AFTER_SECONDS + 30, - ) - - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=False, - interactive_waiting=False, - in_menu=False, - ) - - assert fired is False - assert stub_finalize == [] - - @pytest.mark.asyncio - async def test_stall_arms_recovery_flag(self, monkeypatch): - """After ``finalize_task`` lands the STALL_NOTE, the card state - must carry ``stall_finalized=True`` so the next genuine assistant - turn spawns a fresh card instead of silently editing the stub.""" - - # Real ``finalize_task`` replacement that mirrors the actual - # contract: append a ``final_text`` Event so the post-call check - # sees a terminal tail (defensive — recovery flag is set after - # finalize returns, irrespective of internals). - async def _fake_finalize(bot, user_id, sess, final_text): # type: ignore[no-untyped-def] - st = _cards.get((user_id, sess.id)) - if st is not None: - st.events.append( - Event(type="final_text", text=final_text, started_at=time.time()) - ) - - monkeypatch.setattr(notifications, "finalize_task", _fake_finalize) - - user_id, sess = 42, _make_sess() - state = _seed_card( - user_id, - sess, - tail_type="tool_use", - age_seconds=STALL_FINALIZE_TOOL_USE_SECONDS + 30, - ) - assert state.stall_finalized is False - - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=False, - interactive_waiting=False, - in_menu=False, - ) - - assert fired is True - assert _cards[(user_id, sess.id)].stall_finalized is True - - -# ── Negative: the trigger must stay quiet ───────────────────────────── - - -class TestStallSuppressed: - @pytest.mark.asyncio - async def test_spinner_still_changing_no_fire(self, stub_finalize): - """A still-changing spinner (``pane_busy=True``) is genuine work, - not a stall — never finalize.""" - user_id, sess = 42, _make_sess() - _seed_card(user_id, sess, tail_type="thinking") - - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=True, - interactive_waiting=False, - in_menu=False, - ) - - assert fired is False - assert stub_finalize == [] - - @pytest.mark.asyncio - async def test_already_finalized_no_fire(self, stub_finalize): - """A card whose tail is ``final_text`` is already done — nothing - frozen to rescue.""" - user_id, sess = 42, _make_sess() - _seed_card(user_id, sess, tail_type="final_text") - - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=False, - interactive_waiting=False, - in_menu=False, - ) - - assert fired is False - assert stub_finalize == [] - - @pytest.mark.asyncio - async def test_error_tail_no_fire(self, stub_finalize): - """An ``error`` tail is also terminal — already finalized.""" - user_id, sess = 42, _make_sess() - _seed_card(user_id, sess, tail_type="error") - - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=False, - interactive_waiting=False, - in_menu=False, - ) - - assert fired is False - assert stub_finalize == [] - - @pytest.mark.asyncio - async def test_interactive_ui_waiting_no_fire(self, stub_finalize): - """An AskUserQuestion / ExitPlanMode / permission prompt waiting - for the user is a valid idle state, not a stall.""" - user_id, sess = 42, _make_sess() - _seed_card(user_id, sess, tail_type="thinking") - - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=False, - interactive_waiting=True, - in_menu=False, - ) - - assert fired is False - assert stub_finalize == [] - - @pytest.mark.asyncio - async def test_kb_mode_no_fire(self, stub_finalize): - """Card in kb-mode (prompt rendered on the card) is awaiting the - user — never a stall.""" - user_id, sess = 42, _make_sess() - _seed_card(user_id, sess, tail_type="thinking", in_kb_mode=True) - - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=False, - interactive_waiting=False, - in_menu=False, - ) - - assert fired is False - assert stub_finalize == [] - - @pytest.mark.asyncio - async def test_menu_view_no_fire(self, stub_finalize): - """User browsing a Menu sub-screen on the carrier — suppress.""" - user_id, sess = 42, _make_sess() - _seed_card(user_id, sess, tail_type="thinking", in_menu_view=True) - - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=False, - interactive_waiting=True, # status_polling passes in_menu too - in_menu=True, - ) - - assert fired is False - assert stub_finalize == [] - - @pytest.mark.asyncio - async def test_recent_event_no_fire(self, stub_finalize): - """Idle window not yet elapsed (last event within the threshold) — - an ordinary intra-turn gap, not a stall.""" - user_id, sess = 42, _make_sess() - _seed_card( - user_id, - sess, - tail_type="thinking", - age_seconds=STALL_FINALIZE_AFTER_SECONDS - 10, - ) - - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=False, - interactive_waiting=False, - in_menu=False, - ) +@pytest.fixture(autouse=True) +def _clear_state() -> None: + notifications._cards.clear() + bg_status._bg.clear() + + +@pytest.mark.asyncio +async def test_active_stall_keeps_running_pane_without_warning( + monkeypatch: pytest.MonkeyPatch, +) -> None: + user_id, sess = 42, _session() + state = _seed(user_id, sess) + edit = AsyncMock(return_value=True) + finalize = AsyncMock() + push = AsyncMock() + monkeypatch.setattr( + notifications.session_manager, "get_active_session", lambda _uid: sess + ) + monkeypatch.setattr(notifications, "_render_card", lambda *_a, **_k: "card") + monkeypatch.setattr(notifications, "_edit_card", edit) + monkeypatch.setattr(notifications, "finalize_task", finalize) + monkeypatch.setattr(notifications, "safe_send", push) + + assert await notifications.maybe_finalize_stalled( + SimpleNamespace(), + user_id, + sess, + pane_busy=False, + interactive_waiting=False, + in_menu=False, + ) - assert fired is False - assert stub_finalize == [] + assert state.turn_phase is TurnPhase.RUNNING + assert state.stall_watch_active is True + assert state.events[-1].type == "thinking" + edit.assert_awaited_once() + finalize.assert_not_awaited() + push.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_background_stall_sets_only_warning_badge( + monkeypatch: pytest.MonkeyPatch, +) -> None: + user_id, sess = 42, _session("bg") + state = _seed(user_id, sess) + state.in_menu_view = True + active = _session("active") + refresh = AsyncMock(return_value=True) + monkeypatch.setattr( + notifications.session_manager, "get_active_session", lambda _uid: active + ) + monkeypatch.setattr( + notifications.session_manager, + "get_session", + lambda sid: sess if sid == sess.id else active, + ) + monkeypatch.setattr(notifications, "refresh_panel", refresh) + monkeypatch.setattr(notifications, "_edit_card", AsyncMock()) + + assert await notifications.maybe_finalize_stalled( + SimpleNamespace(), + user_id, + sess, + pane_busy=False, + interactive_waiting=False, + in_menu=True, + ) - @pytest.mark.asyncio - async def test_no_card_no_fire(self, stub_finalize): - """No card / no msg_id => nothing to finalize.""" - user_id, sess = 42, _make_sess() - # No card seeded at all. - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=False, - interactive_waiting=False, - in_menu=False, - ) - assert fired is False + assert bg_status._bg[user_id][sess.id].status == "stalled" + assert "⚠️" in bg_status.render_panel(user_id, active_session_id=active.id) + refresh.assert_awaited_once() + notifications._edit_card.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_background_activity_clears_stalled_badge( + monkeypatch: pytest.MonkeyPatch, +) -> None: + user_id, sess = 42, _session("bg") + state = _seed(user_id, sess) + state.stall_watch_active = True + bg_status.update_status(user_id, sess.id, "stalled") + active = _session("active") + refresh = AsyncMock(return_value=True) + monkeypatch.setattr( + notifications.session_manager, "get_active_session", lambda _uid: active + ) + monkeypatch.setattr(notifications, "refresh_panel", refresh) + + assert not await notifications.maybe_finalize_stalled( + SimpleNamespace(), + user_id, + sess, + pane_busy=True, + interactive_waiting=False, + in_menu=True, + ) - # Card present but msg_id is None (e.g. after Shot closed it). - _seed_card(user_id, sess, tail_type="thinking", msg_id=None) - fired = await maybe_finalize_stalled( - AsyncMock(), - user_id, - sess, - pane_busy=False, - interactive_waiting=False, - in_menu=False, - ) - assert fired is False - assert stub_finalize == [] + assert bg_status._bg[user_id][sess.id].status == "working" + assert state.stall_watch_active is False + refresh.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("pane_busy", "interactive", "in_menu", "tail", "age"), + [ + (True, False, False, "thinking", 400), + (False, True, False, "thinking", 400), + (False, False, True, "thinking", 400), + (False, False, False, "final_text", 400), + (False, False, False, "thinking", 10), + ], +) +async def test_active_non_stall_states_do_not_start_watch( + monkeypatch: pytest.MonkeyPatch, + pane_busy: bool, + interactive: bool, + in_menu: bool, + tail: str, + age: float, +) -> None: + user_id, sess = 42, _session() + state = _seed(user_id, sess, tail=tail) + state.in_menu_view = in_menu + state.last_event_ts = time.time() - age + monkeypatch.setattr( + notifications.session_manager, "get_active_session", lambda _uid: sess + ) + edit = AsyncMock() + monkeypatch.setattr(notifications, "_edit_card", edit) + + assert not await notifications.maybe_finalize_stalled( + SimpleNamespace(), + user_id, + sess, + pane_busy=pane_busy, + interactive_waiting=interactive, + in_menu=in_menu, + ) + edit.assert_not_awaited()