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.
Files changed (190) hide show
  1. {ccgram-2.2.2 → ccgram-2.2.4}/.claude/rules/architecture.md +3 -3
  2. {ccgram-2.2.2 → ccgram-2.2.4}/CHANGELOG.md +18 -0
  3. {ccgram-2.2.2 → ccgram-2.2.4}/CLAUDE.md +1 -1
  4. {ccgram-2.2.2 → ccgram-2.2.4}/PKG-INFO +2 -2
  5. {ccgram-2.2.2 → ccgram-2.2.4}/README.md +1 -1
  6. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/_version.py +2 -2
  7. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/cc_commands.py +32 -32
  8. ccgram-2.2.4/src/ccgram/entity_formatting.py +203 -0
  9. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/__init__.py +1 -1
  10. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/directory_browser.py +1 -1
  11. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/interactive_ui.py +1 -2
  12. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/message_queue.py +50 -130
  13. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/message_sender.py +72 -64
  14. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/recovery_callbacks.py +2 -2
  15. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/response_builder.py +17 -21
  16. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/resume_command.py +2 -2
  17. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/text_handler.py +3 -21
  18. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/topic_emoji.py +8 -5
  19. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/voice_handler.py +1 -4
  20. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/main.py +4 -1
  21. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/_jsonl.py +1 -0
  22. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/base.py +6 -2
  23. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/claude.py +4 -1
  24. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/codex.py +1 -0
  25. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/gemini.py +1 -0
  26. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/screenshot.py +4 -0
  27. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/session_monitor.py +8 -0
  28. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/transcript_parser.py +13 -4
  29. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/utils.py +11 -0
  30. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/handlers/test_response_builder.py +18 -2
  31. ccgram-2.2.4/tests/ccgram/test_entity_formatting.py +270 -0
  32. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_forward_command.py +1 -1
  33. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_message_sender.py +102 -61
  34. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_tool_batching.py +1 -1
  35. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_utils.py +40 -0
  36. {ccgram-2.2.2 → ccgram-2.2.4}/uv.lock +3 -3
  37. ccgram-2.2.2/src/ccgram/markdown_v2.py +0 -168
  38. ccgram-2.2.2/tests/ccgram/test_markdown_v2.py +0 -126
  39. {ccgram-2.2.2 → ccgram-2.2.4}/.claude/rules/message-handling.md +0 -0
  40. {ccgram-2.2.2 → ccgram-2.2.4}/.claude/rules/topic-architecture.md +0 -0
  41. {ccgram-2.2.2 → ccgram-2.2.4}/.claude/skills/releasing/SKILL.md +0 -0
  42. {ccgram-2.2.2 → ccgram-2.2.4}/.env.example +0 -0
  43. {ccgram-2.2.2 → ccgram-2.2.4}/.github/workflows/ci.yml +0 -0
  44. {ccgram-2.2.2 → ccgram-2.2.4}/.github/workflows/release.yml +0 -0
  45. {ccgram-2.2.2 → ccgram-2.2.4}/.gitignore +0 -0
  46. {ccgram-2.2.2 → ccgram-2.2.4}/LICENSE +0 -0
  47. {ccgram-2.2.2 → ccgram-2.2.4}/Makefile +0 -0
  48. {ccgram-2.2.2 → ccgram-2.2.4}/cliff.toml +0 -0
  49. {ccgram-2.2.2 → ccgram-2.2.4}/docs/ai-agents/README.md +0 -0
  50. {ccgram-2.2.2 → ccgram-2.2.4}/docs/ai-agents/architecture-map.md +0 -0
  51. {ccgram-2.2.2 → ccgram-2.2.4}/docs/ai-agents/codebase-index.md +0 -0
  52. {ccgram-2.2.2 → ccgram-2.2.4}/docs/ai-agents/extension-and-fix-playbook.md +0 -0
  53. {ccgram-2.2.2 → ccgram-2.2.4}/docs/ai-agents/tooling-and-tests.md +0 -0
  54. {ccgram-2.2.2 → ccgram-2.2.4}/docs/guides.md +0 -0
  55. {ccgram-2.2.2 → ccgram-2.2.4}/llm.txt +0 -0
  56. {ccgram-2.2.2 → ccgram-2.2.4}/pyproject.toml +0 -0
  57. {ccgram-2.2.2 → ccgram-2.2.4}/scripts/generate_homebrew_formula.py +0 -0
  58. {ccgram-2.2.2 → ccgram-2.2.4}/scripts/restart.sh +0 -0
  59. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/__init__.py +0 -0
  60. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/bot.py +0 -0
  61. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/cli.py +0 -0
  62. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/codex_status.py +0 -0
  63. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/command_catalog.py +0 -0
  64. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/config.py +0 -0
  65. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/doctor_cmd.py +0 -0
  66. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/fonts/JetBrainsMono-Regular.ttf +0 -0
  67. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/fonts/LICENSE-JetBrainsMono.txt +0 -0
  68. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/fonts/LICENSE-NotoSansMono.txt +0 -0
  69. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/fonts/LICENSE-Symbola.txt +0 -0
  70. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/fonts/NotoSansMonoCJKsc-Regular.otf +0 -0
  71. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/fonts/Symbola.ttf +0 -0
  72. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/callback_data.py +0 -0
  73. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/callback_helpers.py +0 -0
  74. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/cleanup.py +0 -0
  75. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/command_history.py +0 -0
  76. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/directory_callbacks.py +0 -0
  77. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/file_handler.py +0 -0
  78. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/history.py +0 -0
  79. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/history_callbacks.py +0 -0
  80. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/hook_events.py +0 -0
  81. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/interactive_callbacks.py +0 -0
  82. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/restore_command.py +0 -0
  83. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/screenshot_callbacks.py +0 -0
  84. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/sessions_dashboard.py +0 -0
  85. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/status_polling.py +0 -0
  86. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/sync_command.py +0 -0
  87. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/upgrade.py +0 -0
  88. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/user_state.py +0 -0
  89. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/voice_callbacks.py +0 -0
  90. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/handlers/window_callbacks.py +0 -0
  91. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/hook.py +0 -0
  92. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/interactive_prompt_formatter.py +0 -0
  93. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/monitor_state.py +0 -0
  94. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/__init__.py +0 -0
  95. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/providers/registry.py +0 -0
  96. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/screen_buffer.py +0 -0
  97. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/session.py +0 -0
  98. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/state_persistence.py +0 -0
  99. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/status_cmd.py +0 -0
  100. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/telegram_request.py +0 -0
  101. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/telegram_sender.py +0 -0
  102. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/terminal_parser.py +0 -0
  103. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/tmux_manager.py +0 -0
  104. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/whisper/__init__.py +0 -0
  105. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/whisper/base.py +0 -0
  106. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/whisper/httpx_transcriber.py +0 -0
  107. {ccgram-2.2.2 → ccgram-2.2.4}/src/ccgram/window_resolver.py +0 -0
  108. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/conftest.py +0 -0
  109. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/handlers/__init__.py +0 -0
  110. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/handlers/test_command_history.py +0 -0
  111. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/handlers/test_history.py +0 -0
  112. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/handlers/test_voice_handler.py +0 -0
  113. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_bot_callbacks.py +0 -0
  114. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_callback_auth.py +0 -0
  115. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_cc_commands.py +0 -0
  116. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_claude_characterization.py +0 -0
  117. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_cleanup.py +0 -0
  118. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_cli.py +0 -0
  119. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_codex_status.py +0 -0
  120. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_command_catalog.py +0 -0
  121. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_commands_command.py +0 -0
  122. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_config.py +0 -0
  123. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_directory_browser.py +0 -0
  124. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_doctor_cmd.py +0 -0
  125. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_emdash_integration.py +0 -0
  126. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_external_discovery.py +0 -0
  127. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_file_handler.py +0 -0
  128. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_group_filter.py +0 -0
  129. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_handle_new_window.py +0 -0
  130. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_hook.py +0 -0
  131. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_hook_events.py +0 -0
  132. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_interactive_prompt_formatter.py +0 -0
  133. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_interactive_ui.py +0 -0
  134. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_jsonl_providers.py +0 -0
  135. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_kill_command.py +0 -0
  136. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_message_queue_properties.py +0 -0
  137. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_monitor_state.py +0 -0
  138. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_new_command.py +0 -0
  139. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_new_window_sync.py +0 -0
  140. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_provider_autodetect.py +0 -0
  141. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_provider_contracts.py +0 -0
  142. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_provider_registry.py +0 -0
  143. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_provider_selection.py +0 -0
  144. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_recovery_ui.py +0 -0
  145. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_restore_command.py +0 -0
  146. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_resume_command.py +0 -0
  147. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_screen_buffer.py +0 -0
  148. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_session.py +0 -0
  149. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_session_favorites.py +0 -0
  150. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_session_monitor.py +0 -0
  151. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_session_monitor_events.py +0 -0
  152. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_session_notification_mode.py +0 -0
  153. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_sessions_dashboard.py +0 -0
  154. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_state_migration.py +0 -0
  155. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_status_buttons.py +0 -0
  156. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_status_cmd.py +0 -0
  157. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_status_polling.py +0 -0
  158. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_status_recall_callback.py +0 -0
  159. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_sync_command.py +0 -0
  160. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_task_utils.py +0 -0
  161. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_telegram_request.py +0 -0
  162. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_telegram_sender.py +0 -0
  163. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_terminal_parser.py +0 -0
  164. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_text_handler.py +0 -0
  165. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_tmux_autodetect.py +0 -0
  166. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_topic_edited.py +0 -0
  167. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_topic_emoji.py +0 -0
  168. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_transcript_parser.py +0 -0
  169. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_vim_mode.py +0 -0
  170. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/test_window_callbacks.py +0 -0
  171. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/whisper/__init__.py +0 -0
  172. {ccgram-2.2.2 → ccgram-2.2.4}/tests/ccgram/whisper/test_transcriber.py +0 -0
  173. {ccgram-2.2.2 → ccgram-2.2.4}/tests/conftest.py +0 -0
  174. {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/__init__.py +0 -0
  175. {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/_helpers.py +0 -0
  176. {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/conftest.py +0 -0
  177. {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/test_claude_lifecycle.py +0 -0
  178. {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/test_codex_lifecycle.py +0 -0
  179. {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/test_gemini_lifecycle.py +0 -0
  180. {ccgram-2.2.2 → ccgram-2.2.4}/tests/e2e/test_voice_lifecycle.py +0 -0
  181. {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/conftest.py +0 -0
  182. {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_autodetect_integration.py +0 -0
  183. {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_config_integration.py +0 -0
  184. {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_hook_pipeline.py +0 -0
  185. {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_message_dispatch.py +0 -0
  186. {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_monitor_flow.py +0 -0
  187. {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_monitor_state_integration.py +0 -0
  188. {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_state_roundtrip.py +0 -0
  189. {ccgram-2.2.2 → ccgram-2.2.4}/tests/integration/test_tmux_manager.py +0 -0
  190. {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 · MarkdownV2"]
8
- BotSub1["markdown_v2.py\nMD → MarkdownV2 + expandable quotes"]
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
- - **MarkdownV2 with fallback** — All messages go through `safe_reply`/`safe_edit`/`safe_send` which convert via `telegramify-markdown` and fall back to plain text on parse failure.
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
- - **MarkdownV2 only** — use `safe_reply`/`safe_edit`/`safe_send` helpers (auto fallback to plain text). Internal queue/UI code calls bot API directly with its own fallback.
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.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
- - MarkdownV2 formatting with automatic plain text fallback
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
- - MarkdownV2 formatting with automatic plain text fallback
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.2'
32
- __version_tuple__ = version_tuple = (2, 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
- max_attempts = 3
294
- for attempt in range(1, max_attempts + 1):
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
- break
303
- except RetryAfter as e:
304
- retry_secs = min(
305
- 60,
306
- (
307
- e.retry_after
308
- if isinstance(e.retry_after, int)
309
- else int(e.retry_after.total_seconds())
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
- if attempt < max_attempts:
313
- logger.warning(
314
- "Telegram flood control registering commands, retry in %ds (attempt %d/%d)",
315
- retry_secs,
316
- attempt,
317
- max_attempts,
318
- )
319
- await asyncio.sleep(retry_secs)
320
- else:
321
- logger.warning(
322
- "Telegram flood control registering commands, giving up after %d attempts",
323
- max_attempts,
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 MarkdownV2 fallback
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.)
@@ -329,7 +329,7 @@ def build_mode_picker(
329
329
  ],
330
330
  [
331
331
  InlineKeyboardButton(
332
- "🚀 YOLO",
332
+ "🎲 YOLO",
333
333
  callback_data=f"{CB_MODE_SELECT}{provider_name}:yolo",
334
334
  )
335
335
  ],
@@ -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 has characters like (, _, .
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
- from ..markdown_v2 import convert_markdown
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 NO_LINK_PREVIEW, rate_limit_send_message
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 for markdown conversion overhead
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 + MarkdownV2 escaping
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 (MarkdownV2 with plain text fallback)
426
- md_text = convert_markdown(batch_text)
427
- try:
428
- await bot.edit_message_text(
429
- chat_id=chat_id,
430
- message_id=batch.telegram_msg_id,
431
- text=md_text,
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
- md_text = convert_markdown(batch_text)
474
- try:
475
- await bot.edit_message_text(
476
- chat_id=chat_id,
477
- message_id=batch.telegram_msg_id,
478
- text=md_text,
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
- try:
611
- await bot.edit_message_text(
612
- chat_id=chat_id,
613
- message_id=edit_msg_id,
614
- text=full_text,
615
- parse_mode="MarkdownV2",
616
- link_preview_options=NO_LINK_PREVIEW,
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
- except RetryAfter:
621
- raise
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
- try:
708
- await bot.edit_message_text(
709
- chat_id=chat_id,
710
- message_id=msg_id,
711
- text=content_text,
712
- parse_mode="MarkdownV2",
713
- reply_markup=None,
714
- link_preview_options=NO_LINK_PREVIEW,
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
- except RetryAfter:
718
- raise
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
- try:
785
- await bot.edit_message_text(
786
- chat_id=chat_id,
787
- message_id=msg_id,
788
- text=convert_markdown(status_text),
789
- parse_mode="MarkdownV2",
790
- reply_markup=keyboard,
791
- link_preview_options=NO_LINK_PREVIEW,
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
- except RetryAfter:
795
- raise
796
- except TelegramError:
797
- try:
798
- await bot.edit_message_text(
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)