ccgram 2.2.2__tar.gz → 2.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ccgram-2.2.2 → ccgram-2.2.4}/.claude/rules/architecture.md +3 -3
- {ccgram-2.2.2 → ccgram-2.2.4}/CHANGELOG.md +18 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/CLAUDE.md +1 -1
- {ccgram-2.2.2 → ccgram-2.2.4}/PKG-INFO +2 -2
- {ccgram-2.2.2 → ccgram-2.2.4}/README.md +1 -1
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/_version.py +2 -2
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/cc_commands.py +32 -32
- ccgram-2.2.4/src/ccgram/entity_formatting.py +203 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/__init__.py +1 -1
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/directory_browser.py +1 -1
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/interactive_ui.py +1 -2
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/message_queue.py +50 -130
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/message_sender.py +72 -64
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/recovery_callbacks.py +2 -2
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/response_builder.py +17 -21
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/resume_command.py +2 -2
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/text_handler.py +3 -21
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/topic_emoji.py +8 -5
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/voice_handler.py +1 -4
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/main.py +4 -1
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/_jsonl.py +1 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/base.py +6 -2
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/claude.py +4 -1
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/codex.py +1 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/gemini.py +1 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/screenshot.py +4 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/session_monitor.py +8 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/transcript_parser.py +13 -4
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/utils.py +11 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/handlers/test_response_builder.py +18 -2
- ccgram-2.2.4/tests/ccgram/test_entity_formatting.py +270 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_forward_command.py +1 -1
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_message_sender.py +102 -61
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_tool_batching.py +1 -1
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_utils.py +40 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/uv.lock +3 -3
- ccgram-2.2.2/src/ccgram/markdown_v2.py +0 -168
- ccgram-2.2.2/tests/ccgram/test_markdown_v2.py +0 -126
- {ccgram-2.2.2 → ccgram-2.2.4}/.claude/rules/message-handling.md +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/.claude/rules/topic-architecture.md +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/.claude/skills/releasing/SKILL.md +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/.env.example +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/.github/workflows/ci.yml +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/.github/workflows/release.yml +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/.gitignore +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/LICENSE +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/Makefile +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/cliff.toml +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/docs/ai-agents/README.md +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/docs/ai-agents/architecture-map.md +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/docs/ai-agents/codebase-index.md +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/docs/ai-agents/extension-and-fix-playbook.md +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/docs/ai-agents/tooling-and-tests.md +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/docs/guides.md +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/llm.txt +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/pyproject.toml +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/scripts/generate_homebrew_formula.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/scripts/restart.sh +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/__init__.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/bot.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/cli.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/codex_status.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/command_catalog.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/config.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/doctor_cmd.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/fonts/JetBrainsMono-Regular.ttf +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/fonts/LICENSE-JetBrainsMono.txt +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/fonts/LICENSE-NotoSansMono.txt +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/fonts/LICENSE-Symbola.txt +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/fonts/NotoSansMonoCJKsc-Regular.otf +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/fonts/Symbola.ttf +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/callback_data.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/callback_helpers.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/cleanup.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/command_history.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/directory_callbacks.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/file_handler.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/history.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/history_callbacks.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/hook_events.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/interactive_callbacks.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/restore_command.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/screenshot_callbacks.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/sessions_dashboard.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/status_polling.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/sync_command.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/upgrade.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/user_state.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/voice_callbacks.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/window_callbacks.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/hook.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/interactive_prompt_formatter.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/monitor_state.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/__init__.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/registry.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/screen_buffer.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/session.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/state_persistence.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/status_cmd.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/telegram_request.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/telegram_sender.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/terminal_parser.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/tmux_manager.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/whisper/__init__.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/whisper/base.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/whisper/httpx_transcriber.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/window_resolver.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/conftest.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/handlers/__init__.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/handlers/test_command_history.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/handlers/test_history.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/handlers/test_voice_handler.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_bot_callbacks.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_callback_auth.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_cc_commands.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_claude_characterization.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_cleanup.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_cli.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_codex_status.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_command_catalog.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_commands_command.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_config.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_directory_browser.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_doctor_cmd.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_emdash_integration.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_external_discovery.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_file_handler.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_group_filter.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_handle_new_window.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_hook.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_hook_events.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_interactive_prompt_formatter.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_interactive_ui.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_jsonl_providers.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_kill_command.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_message_queue_properties.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_monitor_state.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_new_command.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_new_window_sync.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_provider_autodetect.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_provider_contracts.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_provider_registry.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_provider_selection.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_recovery_ui.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_restore_command.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_resume_command.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_screen_buffer.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_session.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_session_favorites.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_session_monitor.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_session_monitor_events.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_session_notification_mode.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_sessions_dashboard.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_state_migration.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_status_buttons.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_status_cmd.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_status_polling.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_status_recall_callback.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_sync_command.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_task_utils.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_telegram_request.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_telegram_sender.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_terminal_parser.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_text_handler.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_tmux_autodetect.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_topic_edited.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_topic_emoji.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_transcript_parser.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_vim_mode.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_window_callbacks.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/whisper/__init__.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/whisper/test_transcriber.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/conftest.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/__init__.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/_helpers.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/conftest.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/test_claude_lifecycle.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/test_codex_lifecycle.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/test_gemini_lifecycle.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/test_voice_lifecycle.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/conftest.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_autodetect_integration.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_config_integration.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_hook_pipeline.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_message_dispatch.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_monitor_flow.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_monitor_state_integration.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_state_roundtrip.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_tmux_manager.py +0 -0
- {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_whisper_integration.py +0 -0
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
graph TB
|
|
5
5
|
subgraph bot["Telegram Bot — bot.py"]
|
|
6
6
|
direction TB
|
|
7
|
-
BotCore["Topic routing · /history · /sessions\nStatus messages · Interactive UI\nMessage queue + worker ·
|
|
8
|
-
BotSub1["
|
|
7
|
+
BotCore["Topic routing · /history · /sessions\nStatus messages · Interactive UI\nMessage queue + worker · Entity formatting"]
|
|
8
|
+
BotSub1["entity_formatting.py\nMD → plain text + MessageEntity offsets"]
|
|
9
9
|
BotSub2["telegram_sender.py\nsplit_message — 4096 limit"]
|
|
10
10
|
Terminal["terminal_parser.py + screen_buffer.py\npyte VT100 · interactive UI detection\nspinner parsing · separator detection"]
|
|
11
11
|
end
|
|
@@ -143,7 +143,7 @@ graph TB
|
|
|
143
143
|
- **Hook-based event system** — Claude Code hooks (SessionStart, Notification, Stop, StopFailure, SessionEnd, SubagentStart, SubagentStop, TeammateIdle, TaskCompleted) write to `session_map.json` and `events.jsonl`. SessionMonitor reads both: session_map for session tracking, events.jsonl for instant event dispatch (interactive UI, done detection, API error alerting, session lifecycle, subagent status, team notifications). Terminal scraping remains as fallback. Missing hooks are detected at startup with an actionable warning.
|
|
144
144
|
- **Multi-pane awareness** — Windows with multiple panes (e.g. Claude Code agent teams) are scanned for interactive prompts in non-active panes. Blocked panes are auto-surfaced as inline keyboard alerts. `/panes` command lists all panes with status and per-pane screenshot buttons. Callback data format extended to include pane_id: `"aq:enter:@12:%5"`.
|
|
145
145
|
- **Tool use ↔ tool result pairing** — `tool_use_id` tracked across poll cycles; tool result edits the original tool_use Telegram message in-place.
|
|
146
|
-
- **
|
|
146
|
+
- **Entity-based formatting** — All messages go through `safe_reply`/`safe_edit`/`safe_send` which convert markdown to plain text + `MessageEntity` offsets via `telegramify-markdown`, falling back to plain text on failure. No parse errors possible.
|
|
147
147
|
- **No truncation at parse layer** — Full content preserved; splitting at send layer respects Telegram's 4096 char limit with expandable quote atomicity.
|
|
148
148
|
- Only sessions registered in `session_map.json` (via hook) are monitored.
|
|
149
149
|
- Notifications delivered to users via thread bindings (topic → window_id → session).
|
|
@@ -4,8 +4,26 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
## [2.2.4] - 2026-03-20
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Switch to entity-based Telegram formatting ([#34](https://github.com/alexei-led/ccgram/pull/34))
|
|
11
|
+
|
|
12
|
+
## [2.2.3] - 2026-03-20
|
|
13
|
+
|
|
14
|
+
### Documentation
|
|
15
|
+
- Update CHANGELOG.md for v2.2.3
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Respect Telegram cooldown period and log version at startup
|
|
20
|
+
|
|
7
21
|
## [2.2.2] - 2026-03-20
|
|
8
22
|
|
|
23
|
+
### Documentation
|
|
24
|
+
- Update CHANGELOG.md for v2.2.2
|
|
25
|
+
|
|
26
|
+
|
|
9
27
|
### Fixed
|
|
10
28
|
- Handle Telegram flood control during startup command registration
|
|
11
29
|
|
|
@@ -38,7 +38,7 @@ ccgram --autoclose-dead 0 # Disable auto-close for dead sessions
|
|
|
38
38
|
- **1 Topic = 1 Window = 1 Session** — all internal routing keyed by tmux window ID (`@0`, `@12`), not window name. Window names kept as display names. Same directory can have multiple windows.
|
|
39
39
|
- **Topic-only** — no backward-compat for non-topic mode. No `active_sessions`, no `/list`, no General topic routing.
|
|
40
40
|
- **No message truncation** at parse layer — splitting only at send layer (`split_message`, 4096 char limit).
|
|
41
|
-
- **
|
|
41
|
+
- **Entity-based formatting** — use `safe_reply`/`safe_edit`/`safe_send` helpers which convert markdown to plain text + MessageEntity offsets (no parse errors possible, auto fallback to plain text). Internal queue/UI code calls bot API directly with its own fallback.
|
|
42
42
|
- **Hook-based session tracking** — Claude Code hooks (SessionStart, Notification, Stop, StopFailure, SessionEnd, SubagentStart, SubagentStop, TeammateIdle, TaskCompleted) write to `session_map.json` and `events.jsonl`; monitor polls both to detect session changes and deliver instant event notifications. Missing hooks are detected at startup with an actionable warning.
|
|
43
43
|
- **Message queue per user** — FIFO ordering, message merging (3800 char limit), tool_use/tool_result pairing.
|
|
44
44
|
- **Rate limiting** — 1.1s minimum interval between messages per user via `rate_limit_send()`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ccgram
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.4
|
|
4
4
|
Summary: CCGram — manage AI coding agents from Telegram via tmux
|
|
5
5
|
Project-URL: Homepage, https://github.com/alexei-led/ccgram
|
|
6
6
|
Project-URL: Repository, https://github.com/alexei-led/ccgram
|
|
@@ -120,7 +120,7 @@ Each Telegram Forum topic binds to one tmux window running an agent CLI. Message
|
|
|
120
120
|
|
|
121
121
|
- Assistant responses, thinking content, tool use/result pairs, and command output
|
|
122
122
|
- Live status line showing what the agent is currently doing
|
|
123
|
-
-
|
|
123
|
+
- Entity-based formatting with automatic plain text fallback
|
|
124
124
|
|
|
125
125
|
**Session management**
|
|
126
126
|
|
|
@@ -79,7 +79,7 @@ Each Telegram Forum topic binds to one tmux window running an agent CLI. Message
|
|
|
79
79
|
|
|
80
80
|
- Assistant responses, thinking content, tool use/result pairs, and command output
|
|
81
81
|
- Live status line showing what the agent is currently doing
|
|
82
|
-
-
|
|
82
|
+
- Entity-based formatting with automatic plain text fallback
|
|
83
83
|
|
|
84
84
|
**Session management**
|
|
85
85
|
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '2.2.
|
|
32
|
-
__version_tuple__ = version_tuple = (2, 2,
|
|
31
|
+
__version__ = version = '2.2.4'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 2, 4)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -290,37 +290,37 @@ async def register_commands(
|
|
|
290
290
|
|
|
291
291
|
from telegram.error import RetryAfter
|
|
292
292
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
293
|
+
max_startup_wait = 120 # Don't block startup longer than this
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
if scope is None:
|
|
297
|
+
await bot.delete_my_commands()
|
|
298
|
+
await bot.set_my_commands(bot_commands)
|
|
299
|
+
else:
|
|
300
|
+
await bot.delete_my_commands(scope=scope)
|
|
301
|
+
await bot.set_my_commands(bot_commands, scope=scope)
|
|
302
|
+
except RetryAfter as e:
|
|
303
|
+
retry_secs = (
|
|
304
|
+
e.retry_after
|
|
305
|
+
if isinstance(e.retry_after, int)
|
|
306
|
+
else int(e.retry_after.total_seconds())
|
|
307
|
+
)
|
|
308
|
+
if retry_secs > max_startup_wait:
|
|
309
|
+
logger.warning(
|
|
310
|
+
"Telegram flood control: %ds cooldown, skipping command registration"
|
|
311
|
+
" (will retry in 10min refresh cycle)",
|
|
312
|
+
retry_secs,
|
|
311
313
|
)
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
)
|
|
325
|
-
return
|
|
314
|
+
return
|
|
315
|
+
logger.warning(
|
|
316
|
+
"Telegram flood control: retrying command registration in %ds",
|
|
317
|
+
retry_secs,
|
|
318
|
+
)
|
|
319
|
+
await asyncio.sleep(retry_secs)
|
|
320
|
+
if scope is None:
|
|
321
|
+
await bot.delete_my_commands()
|
|
322
|
+
await bot.set_my_commands(bot_commands)
|
|
323
|
+
else:
|
|
324
|
+
await bot.delete_my_commands(scope=scope)
|
|
325
|
+
await bot.set_my_commands(bot_commands, scope=scope)
|
|
326
326
|
logger.info("Registered %d bot commands (%d CC)", len(bot_commands), cc_count)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Markdown → Telegram entity-based formatting layer.
|
|
2
|
+
|
|
3
|
+
Converts markdown text (with expandable blockquote sentinels) to plain text
|
|
4
|
+
plus a list of telegram.MessageEntity objects. Entity-based formatting uses
|
|
5
|
+
character offsets — there is no syntax to parse and no parse errors are possible.
|
|
6
|
+
|
|
7
|
+
Key function: convert_to_entities(text) → (str, list[MessageEntity]).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from telegram import MessageEntity as TelegramEntity
|
|
13
|
+
|
|
14
|
+
from telegramify_markdown import config as _tm_config
|
|
15
|
+
from telegramify_markdown import convert as _tm_convert
|
|
16
|
+
from telegramify_markdown import utf16_len as _utf16_len
|
|
17
|
+
from telegramify_markdown.entity import MessageEntity as _LibEntity
|
|
18
|
+
|
|
19
|
+
from .providers.base import EXPANDABLE_QUOTE_END, EXPANDABLE_QUOTE_START
|
|
20
|
+
|
|
21
|
+
# Disable auto-promotion of long blockquotes to expandable blockquotes —
|
|
22
|
+
# ccgram manages expandable quotes exclusively through sentinel tokens.
|
|
23
|
+
_tm_config.get_runtime_config().cite_expandable = False
|
|
24
|
+
|
|
25
|
+
_EXPQUOTE_RE = re.compile(
|
|
26
|
+
re.escape(EXPANDABLE_QUOTE_START) + r"([\s\S]*?)" + re.escape(EXPANDABLE_QUOTE_END)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Max rendered chars for a single expandable quote block.
|
|
30
|
+
# Leaves room for surrounding text within Telegram's 4096 char message limit.
|
|
31
|
+
_EXPQUOTE_MAX_RENDERED = 3800
|
|
32
|
+
|
|
33
|
+
# Minimum characters to bother including a partial line during truncation
|
|
34
|
+
_MIN_PARTIAL_LINE_LEN = 20
|
|
35
|
+
|
|
36
|
+
_FENCE_RE = re.compile(r"^(`{3,}|~{3,})", re.MULTILINE)
|
|
37
|
+
_INDENTED_CODE_RE = re.compile(r"(?<=\n\n)((?: .+\n?)+)")
|
|
38
|
+
_INDENTED_LINE_RE = re.compile(r"^ ", re.MULTILINE)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _strip_indented_code_blocks(text: str) -> str:
|
|
42
|
+
"""Strip 4-space indentation that CommonMark treats as code blocks.
|
|
43
|
+
|
|
44
|
+
Claude Code uses fenced ``` blocks for code; indented blocks in its
|
|
45
|
+
output are typically continuation text, not code. Pyromark (CommonMark)
|
|
46
|
+
converts 4-space-indented paragraphs into code blocks, so we strip
|
|
47
|
+
the leading spaces before conversion.
|
|
48
|
+
|
|
49
|
+
Fenced code blocks are left untouched — only non-fenced segments
|
|
50
|
+
are processed.
|
|
51
|
+
"""
|
|
52
|
+
# Split text into alternating (outside-fence, inside-fence) segments
|
|
53
|
+
parts: list[str] = []
|
|
54
|
+
inside_fence = False
|
|
55
|
+
fence_marker = ""
|
|
56
|
+
last_end = 0
|
|
57
|
+
|
|
58
|
+
for m in _FENCE_RE.finditer(text):
|
|
59
|
+
marker = m.group(1)
|
|
60
|
+
if not inside_fence:
|
|
61
|
+
# Entering a fenced block — process the preceding non-fenced text
|
|
62
|
+
parts.append(_deindent(text[last_end : m.start()], last_end == 0))
|
|
63
|
+
inside_fence = True
|
|
64
|
+
fence_marker = marker # e.g. "```" or "~~~~~"
|
|
65
|
+
last_end = m.start()
|
|
66
|
+
elif marker[0] == fence_marker[0] and len(marker) >= len(fence_marker):
|
|
67
|
+
# Closing fence — keep fenced content verbatim
|
|
68
|
+
end = m.end()
|
|
69
|
+
parts.append(text[last_end:end])
|
|
70
|
+
last_end = end
|
|
71
|
+
inside_fence = False
|
|
72
|
+
fence_marker = ""
|
|
73
|
+
|
|
74
|
+
# Remaining text after last fence (or entire text if no fences)
|
|
75
|
+
tail = text[last_end:]
|
|
76
|
+
if inside_fence:
|
|
77
|
+
# Unclosed fence — keep verbatim
|
|
78
|
+
parts.append(tail)
|
|
79
|
+
else:
|
|
80
|
+
parts.append(_deindent(tail, last_end == 0))
|
|
81
|
+
|
|
82
|
+
return "".join(parts)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _deindent(text: str, is_start: bool) -> str:
|
|
86
|
+
"""Strip 4-space indented code blocks from a non-fenced text segment."""
|
|
87
|
+
if is_start:
|
|
88
|
+
text = re.sub(
|
|
89
|
+
r"^((?: .+\n?)+)",
|
|
90
|
+
lambda m: _INDENTED_LINE_RE.sub("", m.group(0)),
|
|
91
|
+
text,
|
|
92
|
+
)
|
|
93
|
+
return _INDENTED_CODE_RE.sub(
|
|
94
|
+
lambda m: _INDENTED_LINE_RE.sub("", m.group(0)),
|
|
95
|
+
text,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _lib_entity_to_telegram(ent: _LibEntity, offset_shift: int = 0) -> TelegramEntity:
|
|
100
|
+
"""Convert a telegramify_markdown MessageEntity to telegram.MessageEntity."""
|
|
101
|
+
return TelegramEntity(
|
|
102
|
+
type=ent.type,
|
|
103
|
+
offset=ent.offset + offset_shift,
|
|
104
|
+
length=ent.length,
|
|
105
|
+
url=ent.url,
|
|
106
|
+
language=ent.language,
|
|
107
|
+
custom_emoji_id=ent.custom_emoji_id,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _convert_segment(text: str) -> tuple[str, list[TelegramEntity]]:
|
|
112
|
+
"""Convert a markdown segment (no expandable quote sentinels) to entities."""
|
|
113
|
+
preprocessed = _strip_indented_code_blocks(text)
|
|
114
|
+
plain, lib_entities = _tm_convert(preprocessed)
|
|
115
|
+
tg_entities = [_lib_entity_to_telegram(e) for e in lib_entities]
|
|
116
|
+
return plain, tg_entities
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _truncate_quote_text(text: str) -> tuple[str, bool]:
|
|
120
|
+
"""Truncate expandable quote text to fit within budget.
|
|
121
|
+
|
|
122
|
+
Returns (truncated_text, was_truncated).
|
|
123
|
+
"""
|
|
124
|
+
if _utf16_len(text) <= _EXPQUOTE_MAX_RENDERED:
|
|
125
|
+
return text, False
|
|
126
|
+
|
|
127
|
+
lines = text.split("\n")
|
|
128
|
+
built: list[str] = []
|
|
129
|
+
total_len = 0
|
|
130
|
+
suffix = "\n… (truncated)"
|
|
131
|
+
budget = _EXPQUOTE_MAX_RENDERED - _utf16_len(suffix)
|
|
132
|
+
|
|
133
|
+
for line in lines:
|
|
134
|
+
line_cost = _utf16_len(line) + 1 # +1 for newline
|
|
135
|
+
if total_len + line_cost > budget:
|
|
136
|
+
remaining = budget - total_len - 1 # -1 for newline
|
|
137
|
+
if remaining > _MIN_PARTIAL_LINE_LEN:
|
|
138
|
+
built.append(line[:remaining])
|
|
139
|
+
built.append("… (truncated)")
|
|
140
|
+
return "\n".join(built), True
|
|
141
|
+
built.append(line)
|
|
142
|
+
total_len += line_cost
|
|
143
|
+
|
|
144
|
+
return "\n".join(built), True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def convert_to_entities(text: str) -> tuple[str, list[TelegramEntity]]:
|
|
148
|
+
"""Convert markdown text with expandable quote sentinels to plain text + entities.
|
|
149
|
+
|
|
150
|
+
Expandable blockquote sections (marked by sentinel tokens) are extracted
|
|
151
|
+
and converted to expandable_blockquote entities. Non-quote segments are
|
|
152
|
+
converted via telegramify_markdown.convert() for standard formatting.
|
|
153
|
+
|
|
154
|
+
Entity-based formatting uses character offsets — no syntax to parse,
|
|
155
|
+
no parse errors possible.
|
|
156
|
+
"""
|
|
157
|
+
# Split text by expandable quote sentinels
|
|
158
|
+
segments: list[tuple[bool, str]] = [] # (is_quote, inner_content)
|
|
159
|
+
last_end = 0
|
|
160
|
+
for m in _EXPQUOTE_RE.finditer(text):
|
|
161
|
+
if m.start() > last_end:
|
|
162
|
+
segments.append((False, text[last_end : m.start()]))
|
|
163
|
+
segments.append((True, m.group(1))) # Inner content without sentinels
|
|
164
|
+
last_end = m.end()
|
|
165
|
+
if last_end < len(text):
|
|
166
|
+
segments.append((False, text[last_end:]))
|
|
167
|
+
|
|
168
|
+
if not segments:
|
|
169
|
+
return _convert_segment(text)
|
|
170
|
+
|
|
171
|
+
result_text = ""
|
|
172
|
+
result_entities: list[TelegramEntity] = []
|
|
173
|
+
|
|
174
|
+
for is_quote, segment in segments:
|
|
175
|
+
if is_quote:
|
|
176
|
+
quote_text, _was_truncated = _truncate_quote_text(segment)
|
|
177
|
+
offset = _utf16_len(result_text)
|
|
178
|
+
length = _utf16_len(quote_text)
|
|
179
|
+
result_entities.append(
|
|
180
|
+
TelegramEntity(
|
|
181
|
+
type=TelegramEntity.EXPANDABLE_BLOCKQUOTE,
|
|
182
|
+
offset=offset,
|
|
183
|
+
length=length,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
result_text += quote_text
|
|
187
|
+
else:
|
|
188
|
+
plain, entities = _convert_segment(segment)
|
|
189
|
+
offset_shift = _utf16_len(result_text)
|
|
190
|
+
for ent in entities:
|
|
191
|
+
result_entities.append(
|
|
192
|
+
TelegramEntity(
|
|
193
|
+
type=ent.type,
|
|
194
|
+
offset=ent.offset + offset_shift,
|
|
195
|
+
length=ent.length,
|
|
196
|
+
url=ent.url,
|
|
197
|
+
language=ent.language,
|
|
198
|
+
custom_emoji_id=ent.custom_emoji_id,
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
result_text += plain
|
|
202
|
+
|
|
203
|
+
return result_text, result_entities
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
This package contains the Telegram bot handlers split by functionality:
|
|
4
4
|
- callback_data: Callback data constants (CB_* prefixes)
|
|
5
5
|
- message_queue: Per-user message queue management
|
|
6
|
-
- message_sender: Safe message sending helpers with
|
|
6
|
+
- message_sender: Safe message sending helpers with entity-based formatting
|
|
7
7
|
- history: Message history pagination
|
|
8
8
|
- directory_browser: Directory selection UI
|
|
9
9
|
- interactive_ui: Interactive UI (AskUserQuestion, Permission Prompt, etc.)
|
|
@@ -285,8 +285,7 @@ async def handle_interactive_ui(
|
|
|
285
285
|
"Sending interactive UI to user %d for window_id %s", user_id, window_id
|
|
286
286
|
)
|
|
287
287
|
_send_cooldowns[ikey] = now
|
|
288
|
-
# Send as plain text — terminal content
|
|
289
|
-
# that MarkdownV2 conversion would mangle.
|
|
288
|
+
# Send as plain text — terminal content should not be formatted.
|
|
290
289
|
sent: Message | None = None
|
|
291
290
|
await rate_limit_send(chat_id)
|
|
292
291
|
try:
|
|
@@ -24,7 +24,8 @@ from typing import Literal
|
|
|
24
24
|
from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup
|
|
25
25
|
from telegram.error import RetryAfter, TelegramError
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
import contextlib
|
|
28
|
+
|
|
28
29
|
from ..session import session_manager
|
|
29
30
|
from ..providers import get_provider_for_window
|
|
30
31
|
from ..tmux_manager import tmux_manager
|
|
@@ -37,8 +38,7 @@ from .callback_data import (
|
|
|
37
38
|
CB_STATUS_SCREENSHOT,
|
|
38
39
|
NOTIFY_MODE_ICONS,
|
|
39
40
|
)
|
|
40
|
-
from .message_sender import
|
|
41
|
-
import contextlib
|
|
41
|
+
from .message_sender import edit_with_fallback, rate_limit_send_message
|
|
42
42
|
|
|
43
43
|
# Top-level loop resilience: catch any error to keep the worker alive
|
|
44
44
|
_LoopError = (TelegramError, OSError, RuntimeError, ValueError)
|
|
@@ -46,10 +46,10 @@ _LoopError = (TelegramError, OSError, RuntimeError, ValueError)
|
|
|
46
46
|
logger = structlog.get_logger()
|
|
47
47
|
|
|
48
48
|
# Merge limit for content messages
|
|
49
|
-
MERGE_MAX_LENGTH = 3800 # Leave room
|
|
49
|
+
MERGE_MAX_LENGTH = 3800 # Leave room within Telegram's 4096 char message limit
|
|
50
50
|
|
|
51
51
|
# Batch limits for tool call chains
|
|
52
|
-
# Keep conservative: header + entries + result text + separators
|
|
52
|
+
# Keep conservative: header + entries + result text + separators
|
|
53
53
|
# must fit 4096 chars. Worst case: 10 * (250 + 85 + 6) + 20 ≈ 3430 chars.
|
|
54
54
|
BATCH_MAX_LENGTH = 2800
|
|
55
55
|
BATCH_MAX_ENTRIES = 10
|
|
@@ -422,26 +422,13 @@ async def _process_batch_task(bot: Bot, user_id: int, task: MessageTask) -> None
|
|
|
422
422
|
if sent:
|
|
423
423
|
batch.telegram_msg_id = sent.message_id
|
|
424
424
|
else:
|
|
425
|
-
# Edit existing batch message
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
parse_mode="MarkdownV2",
|
|
433
|
-
link_preview_options=NO_LINK_PREVIEW,
|
|
434
|
-
)
|
|
435
|
-
except RetryAfter:
|
|
436
|
-
raise
|
|
437
|
-
except TelegramError:
|
|
438
|
-
with contextlib.suppress(TelegramError):
|
|
439
|
-
await bot.edit_message_text(
|
|
440
|
-
chat_id=chat_id,
|
|
441
|
-
message_id=batch.telegram_msg_id,
|
|
442
|
-
text=batch_text,
|
|
443
|
-
link_preview_options=NO_LINK_PREVIEW,
|
|
444
|
-
)
|
|
425
|
+
# Edit existing batch message with entity-based formatting
|
|
426
|
+
await edit_with_fallback(
|
|
427
|
+
bot,
|
|
428
|
+
chat_id,
|
|
429
|
+
batch.telegram_msg_id,
|
|
430
|
+
batch_text,
|
|
431
|
+
)
|
|
445
432
|
|
|
446
433
|
|
|
447
434
|
async def _flush_batch(bot: Bot, user_id: int, thread_id_or_0: int) -> None:
|
|
@@ -470,25 +457,12 @@ async def _flush_batch(bot: Bot, user_id: int, thread_id_or_0: int) -> None:
|
|
|
470
457
|
return
|
|
471
458
|
|
|
472
459
|
# Final edit with all results resolved
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
parse_mode="MarkdownV2",
|
|
480
|
-
link_preview_options=NO_LINK_PREVIEW,
|
|
481
|
-
)
|
|
482
|
-
except RetryAfter:
|
|
483
|
-
raise
|
|
484
|
-
except TelegramError:
|
|
485
|
-
with contextlib.suppress(TelegramError):
|
|
486
|
-
await bot.edit_message_text(
|
|
487
|
-
chat_id=chat_id,
|
|
488
|
-
message_id=batch.telegram_msg_id,
|
|
489
|
-
text=batch_text,
|
|
490
|
-
link_preview_options=NO_LINK_PREVIEW,
|
|
491
|
-
)
|
|
460
|
+
await edit_with_fallback(
|
|
461
|
+
bot,
|
|
462
|
+
chat_id,
|
|
463
|
+
batch.telegram_msg_id,
|
|
464
|
+
batch_text,
|
|
465
|
+
)
|
|
492
466
|
|
|
493
467
|
|
|
494
468
|
async def _handle_content_task(
|
|
@@ -607,37 +581,17 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No
|
|
|
607
581
|
await _do_clear_status_message(bot, user_id, thread_id)
|
|
608
582
|
# Join all parts for editing (merged content goes together)
|
|
609
583
|
full_text = "\n\n".join(task.parts)
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
)
|
|
584
|
+
success = await edit_with_fallback(
|
|
585
|
+
bot,
|
|
586
|
+
chat_id,
|
|
587
|
+
edit_msg_id,
|
|
588
|
+
full_text,
|
|
589
|
+
)
|
|
590
|
+
if success:
|
|
618
591
|
await _check_and_send_status(bot, user_id, window_id, task.thread_id)
|
|
619
592
|
return
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
except TelegramError:
|
|
623
|
-
try:
|
|
624
|
-
# Fallback: strip markdown
|
|
625
|
-
plain_text = task.text or full_text
|
|
626
|
-
await bot.edit_message_text(
|
|
627
|
-
chat_id=chat_id,
|
|
628
|
-
message_id=edit_msg_id,
|
|
629
|
-
text=plain_text,
|
|
630
|
-
link_preview_options=NO_LINK_PREVIEW,
|
|
631
|
-
)
|
|
632
|
-
await _check_and_send_status(
|
|
633
|
-
bot, user_id, window_id, task.thread_id
|
|
634
|
-
)
|
|
635
|
-
return
|
|
636
|
-
except RetryAfter:
|
|
637
|
-
raise
|
|
638
|
-
except TelegramError:
|
|
639
|
-
logger.debug("Failed to edit tool msg %s, sending new", edit_msg_id)
|
|
640
|
-
# Fall through to send as new message
|
|
593
|
+
logger.debug("Failed to edit tool msg %s, sending new", edit_msg_id)
|
|
594
|
+
# Fall through to send as new message
|
|
641
595
|
|
|
642
596
|
# 2. Send content messages, converting status message to first content part
|
|
643
597
|
first_part = True
|
|
@@ -704,35 +658,17 @@ async def _convert_status_to_content(
|
|
|
704
658
|
return None
|
|
705
659
|
|
|
706
660
|
# Edit status message to show content (remove status buttons)
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
)
|
|
661
|
+
success = await edit_with_fallback(
|
|
662
|
+
bot,
|
|
663
|
+
chat_id,
|
|
664
|
+
msg_id,
|
|
665
|
+
content_text,
|
|
666
|
+
reply_markup=None,
|
|
667
|
+
)
|
|
668
|
+
if success:
|
|
716
669
|
return msg_id
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
except TelegramError:
|
|
720
|
-
try:
|
|
721
|
-
# Fallback to plain text
|
|
722
|
-
await bot.edit_message_text(
|
|
723
|
-
chat_id=chat_id,
|
|
724
|
-
message_id=msg_id,
|
|
725
|
-
text=content_text,
|
|
726
|
-
reply_markup=None,
|
|
727
|
-
link_preview_options=NO_LINK_PREVIEW,
|
|
728
|
-
)
|
|
729
|
-
return msg_id
|
|
730
|
-
except RetryAfter:
|
|
731
|
-
raise
|
|
732
|
-
except TelegramError as e:
|
|
733
|
-
logger.debug("Failed to convert status to content: %s", e)
|
|
734
|
-
# Message might be deleted or too old, caller will send new message
|
|
735
|
-
return None
|
|
670
|
+
# Message might be deleted or too old, caller will send new message
|
|
671
|
+
return None
|
|
736
672
|
|
|
737
673
|
|
|
738
674
|
def _get_idle_history(
|
|
@@ -781,36 +717,20 @@ async def _process_status_update_task(
|
|
|
781
717
|
# Same window, text changed - edit in place
|
|
782
718
|
history = _get_idle_history(user_id, thread_id, status_text)
|
|
783
719
|
keyboard = build_status_keyboard(window_id, history=history)
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
)
|
|
720
|
+
success = await edit_with_fallback(
|
|
721
|
+
bot,
|
|
722
|
+
chat_id,
|
|
723
|
+
msg_id,
|
|
724
|
+
status_text,
|
|
725
|
+
reply_markup=keyboard,
|
|
726
|
+
)
|
|
727
|
+
if success:
|
|
793
728
|
_status_msg_info[skey] = (msg_id, window_id, status_text)
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
chat_id=chat_id,
|
|
800
|
-
message_id=msg_id,
|
|
801
|
-
text=status_text,
|
|
802
|
-
reply_markup=keyboard,
|
|
803
|
-
link_preview_options=NO_LINK_PREVIEW,
|
|
804
|
-
)
|
|
805
|
-
_status_msg_info[skey] = (msg_id, window_id, status_text)
|
|
806
|
-
except RetryAfter:
|
|
807
|
-
raise
|
|
808
|
-
except TelegramError as e:
|
|
809
|
-
logger.debug("Failed to edit status message: %s", e)
|
|
810
|
-
_status_msg_info.pop(skey, None)
|
|
811
|
-
await _do_send_status_message(
|
|
812
|
-
bot, user_id, thread_id, window_id, status_text
|
|
813
|
-
)
|
|
729
|
+
else:
|
|
730
|
+
_status_msg_info.pop(skey, None)
|
|
731
|
+
await _do_send_status_message(
|
|
732
|
+
bot, user_id, thread_id, window_id, status_text
|
|
733
|
+
)
|
|
814
734
|
else:
|
|
815
735
|
# No existing status message, send new
|
|
816
736
|
await _do_send_status_message(bot, user_id, thread_id, window_id, status_text)
|