novacode-cli 1.2.0__py3-none-any.whl

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 (691) hide show
  1. novacode_cli/__init__.py +29 -0
  2. novacode_cli/__main__.py +6 -0
  3. novacode_cli/_lazy_heavy.py +99 -0
  4. novacode_cli/_version.py +3 -0
  5. novacode_cli/agent_stream.py +178 -0
  6. novacode_cli/agents/agent_file.py +250 -0
  7. novacode_cli/agents/agent_mailbox.py +120 -0
  8. novacode_cli/agents/approval_decisions.py +146 -0
  9. novacode_cli/agents/arg_repair.py +196 -0
  10. novacode_cli/agents/async_agents/__init__.py +6 -0
  11. novacode_cli/agents/async_agents/_config.py +213 -0
  12. novacode_cli/agents/async_agents/_mcp_tools.py +192 -0
  13. novacode_cli/agents/async_agents/_specialist.py +84 -0
  14. novacode_cli/agents/async_agents/_test_commands.py +89 -0
  15. novacode_cli/agents/async_agents/_tools.py +432 -0
  16. novacode_cli/agents/async_agents/general_purpose.py +42 -0
  17. novacode_cli/agents/async_agents/langgraph.json +9 -0
  18. novacode_cli/agents/async_agents/research.py +22 -0
  19. novacode_cli/agents/async_context.py +303 -0
  20. novacode_cli/agents/async_on_demand.py +52 -0
  21. novacode_cli/agents/async_task_errors.py +75 -0
  22. novacode_cli/agents/async_workspace.py +27 -0
  23. novacode_cli/agents/attribution.py +165 -0
  24. novacode_cli/agents/browser_mode.py +119 -0
  25. novacode_cli/agents/btw_agent.py +71 -0
  26. novacode_cli/agents/caveman.py +352 -0
  27. novacode_cli/agents/caveman_support.py +370 -0
  28. novacode_cli/agents/code_mode.py +774 -0
  29. novacode_cli/agents/code_runtime.py +160 -0
  30. novacode_cli/agents/context_compaction.py +1339 -0
  31. novacode_cli/agents/core_agent.py +2710 -0
  32. novacode_cli/agents/default_subagents/__init__.py +24 -0
  33. novacode_cli/agents/default_subagents/async_subagents.py +296 -0
  34. novacode_cli/agents/default_subagents/builtin/browser-automation-agent.md +110 -0
  35. novacode_cli/agents/default_subagents/builtin/bug-fix-agent.md +22 -0
  36. novacode_cli/agents/default_subagents/builtin/bug-investigation-agent.md +42 -0
  37. novacode_cli/agents/default_subagents/builtin/code-explorer.md +37 -0
  38. novacode_cli/agents/default_subagents/builtin/code-review-agent.md +33 -0
  39. novacode_cli/agents/default_subagents/builtin/dependency-audit-agent.md +34 -0
  40. novacode_cli/agents/default_subagents/builtin/documentation-update-agent.md +46 -0
  41. novacode_cli/agents/default_subagents/builtin/performance-audit-agent.md +41 -0
  42. novacode_cli/agents/default_subagents/builtin/plan-scout-agent.md +64 -0
  43. novacode_cli/agents/default_subagents/builtin/refactoring-agent.md +35 -0
  44. novacode_cli/agents/default_subagents/builtin/refactoring-specialist-agent.md +175 -0
  45. novacode_cli/agents/default_subagents/builtin/security-audit-agent.md +96 -0
  46. novacode_cli/agents/default_subagents/builtin/test-generation-agent.md +33 -0
  47. novacode_cli/agents/default_subagents/builtin/test-runner-agent.md +55 -0
  48. novacode_cli/agents/default_subagents/prompt.py +88 -0
  49. novacode_cli/agents/default_subagents/subagents.py +138 -0
  50. novacode_cli/agents/dream_agent.py +93 -0
  51. novacode_cli/agents/edit_diagnostics.py +362 -0
  52. novacode_cli/agents/edit_fallback.py +62 -0
  53. novacode_cli/agents/handoff.py +166 -0
  54. novacode_cli/agents/invocation.py +152 -0
  55. novacode_cli/agents/model_budget.py +42 -0
  56. novacode_cli/agents/model_retry.py +113 -0
  57. novacode_cli/agents/model_router.py +420 -0
  58. novacode_cli/agents/openai_decisions.py +186 -0
  59. novacode_cli/agents/plan_agent/__init__.py +12 -0
  60. novacode_cli/agents/plan_agent/plan_agent.py +357 -0
  61. novacode_cli/agents/plan_agent/plan_mode_middleware.py +64 -0
  62. novacode_cli/agents/plan_execution.py +237 -0
  63. novacode_cli/agents/ponytail.py +66 -0
  64. novacode_cli/agents/prime.py +274 -0
  65. novacode_cli/agents/provider_caching.py +99 -0
  66. novacode_cli/agents/server_launcher.py +904 -0
  67. novacode_cli/agents/strict_tools.py +384 -0
  68. novacode_cli/agents/task_discipline.py +294 -0
  69. novacode_cli/agents/tool_call_repair.py +290 -0
  70. novacode_cli/agents/tool_offload.py +291 -0
  71. novacode_cli/agents/tool_search.py +806 -0
  72. novacode_cli/agents/tool_search_variants.py +106 -0
  73. novacode_cli/agents/tool_verdicts.py +1194 -0
  74. novacode_cli/agents/user_async_agents.py +312 -0
  75. novacode_cli/artifacts/__init__.py +10 -0
  76. novacode_cli/artifacts/registry.py +278 -0
  77. novacode_cli/artifacts/server.py +426 -0
  78. novacode_cli/assets/Nova.png +0 -0
  79. novacode_cli/assets/nova-avatar.png +0 -0
  80. novacode_cli/audio/__init__.py +301 -0
  81. novacode_cli/audio/capture.py +137 -0
  82. novacode_cli/audio/downloads.py +273 -0
  83. novacode_cli/audio/pipeline.py +391 -0
  84. novacode_cli/audio/playback.py +87 -0
  85. novacode_cli/audio/providers.py +208 -0
  86. novacode_cli/audio/speakable.py +78 -0
  87. novacode_cli/audio/stt.py +132 -0
  88. novacode_cli/audio/stt_deepgram.py +69 -0
  89. novacode_cli/audio/stt_elevenlabs.py +89 -0
  90. novacode_cli/audio/stt_parakeet.py +94 -0
  91. novacode_cli/audio/stt_whistle.py +61 -0
  92. novacode_cli/audio/summarize.py +92 -0
  93. novacode_cli/audio/tts.py +85 -0
  94. novacode_cli/audio/tts_kokoro.py +73 -0
  95. novacode_cli/audio/tts_orpheus.py +180 -0
  96. novacode_cli/audio/tts_pocket.py +131 -0
  97. novacode_cli/audio/tts_sano.py +69 -0
  98. novacode_cli/audio/vad.py +145 -0
  99. novacode_cli/backends/__init__.py +17 -0
  100. novacode_cli/backends/filesystem.py +894 -0
  101. novacode_cli/backends/native_search.py +92 -0
  102. novacode_cli/bootstrap/__init__.py +9 -0
  103. novacode_cli/bootstrap/env_snapshot.py +240 -0
  104. novacode_cli/bootstrap/steering.py +464 -0
  105. novacode_cli/bootstrap/vision_router.py +604 -0
  106. novacode_cli/brand.py +362 -0
  107. novacode_cli/cli_args.py +718 -0
  108. novacode_cli/commands/__init__.py +237 -0
  109. novacode_cli/commands/agents_commands.py +231 -0
  110. novacode_cli/commands/auth_handler.py +40 -0
  111. novacode_cli/commands/browser_use_handler.py +75 -0
  112. novacode_cli/commands/cache_handler.py +32 -0
  113. novacode_cli/commands/chat_handler.py +1357 -0
  114. novacode_cli/commands/ci_fix.py +171 -0
  115. novacode_cli/commands/claude_plan.py +331 -0
  116. novacode_cli/commands/codex_plan.py +195 -0
  117. novacode_cli/commands/commands.py +1113 -0
  118. novacode_cli/commands/create_handler.py +124 -0
  119. novacode_cli/commands/create_server.py +1887 -0
  120. novacode_cli/commands/cron_handler.py +119 -0
  121. novacode_cli/commands/dream_handler.py +203 -0
  122. novacode_cli/commands/effort_handler.py +65 -0
  123. novacode_cli/commands/evolution_handler.py +214 -0
  124. novacode_cli/commands/file_commands.py +381 -0
  125. novacode_cli/commands/handoff_handler.py +68 -0
  126. novacode_cli/commands/hooks_handler.py +269 -0
  127. novacode_cli/commands/init_handler.py +146 -0
  128. novacode_cli/commands/learning_handler.py +160 -0
  129. novacode_cli/commands/lite_mode_handler.py +76 -0
  130. novacode_cli/commands/log_commands.py +363 -0
  131. novacode_cli/commands/mcp_handler.py +29 -0
  132. novacode_cli/commands/model_handler.py +29 -0
  133. novacode_cli/commands/notifications_handler.py +158 -0
  134. novacode_cli/commands/onboard_handler.py +121 -0
  135. novacode_cli/commands/plan_handler.py +440 -0
  136. novacode_cli/commands/planner_usage.py +250 -0
  137. novacode_cli/commands/plugin_install_handler.py +38 -0
  138. novacode_cli/commands/plugins_handler.py +35 -0
  139. novacode_cli/commands/prime_handler.py +455 -0
  140. novacode_cli/commands/prompt_handler.py +149 -0
  141. novacode_cli/commands/provider_usage.py +160 -0
  142. novacode_cli/commands/ralph_events.py +106 -0
  143. novacode_cli/commands/ralph_handler.py +1293 -0
  144. novacode_cli/commands/refine_handler.py +213 -0
  145. novacode_cli/commands/research_handler.py +202 -0
  146. novacode_cli/commands/rootapp_remote.py +83 -0
  147. novacode_cli/commands/router_handler.py +36 -0
  148. novacode_cli/commands/server_commands.py +249 -0
  149. novacode_cli/commands/session_commands.py +203 -0
  150. novacode_cli/commands/share_handler.py +450 -0
  151. novacode_cli/commands/side_commands.py +366 -0
  152. novacode_cli/commands/skill_invoke.py +420 -0
  153. novacode_cli/commands/skills_commands.py +48 -0
  154. novacode_cli/commands/trace_handler.py +322 -0
  155. novacode_cli/commands/trello_handler.py +226 -0
  156. novacode_cli/commands/trello_remote.py +177 -0
  157. novacode_cli/commands/trello_server.py +1214 -0
  158. novacode_cli/commands/vision_handler.py +127 -0
  159. novacode_cli/commands/voice_handler.py +447 -0
  160. novacode_cli/commands/webhook_handler.py +134 -0
  161. novacode_cli/commands/wiki_commands.py +227 -0
  162. novacode_cli/compaction.py +753 -0
  163. novacode_cli/computation_cache.py +159 -0
  164. novacode_cli/computer/__init__.py +1 -0
  165. novacode_cli/computer/persistent.py +234 -0
  166. novacode_cli/computer/preview.py +253 -0
  167. novacode_cli/computer/runtime.py +474 -0
  168. novacode_cli/computer/windows_console.py +152 -0
  169. novacode_cli/computer/windows_qemu.py +211 -0
  170. novacode_cli/config/browser_mode.py +159 -0
  171. novacode_cli/config/catalog_models.py +95 -0
  172. novacode_cli/config/config.py +1388 -0
  173. novacode_cli/config/credentials.py +540 -0
  174. novacode_cli/config/google_oauth_auth.py +281 -0
  175. novacode_cli/config/model_capabilities.py +343 -0
  176. novacode_cli/config/model_catalog.py +275 -0
  177. novacode_cli/config/model_create.py +1325 -0
  178. novacode_cli/config/model_manager.py +695 -0
  179. novacode_cli/config/nova_config.py +1283 -0
  180. novacode_cli/config/openai_chatgpt_auth.py +384 -0
  181. novacode_cli/config/opencode_gateway.py +25 -0
  182. novacode_cli/config/plan_mode.py +188 -0
  183. novacode_cli/config/provider_auth.py +438 -0
  184. novacode_cli/config/provider_catalog.json +5127 -0
  185. novacode_cli/config/provider_registry.py +38 -0
  186. novacode_cli/config/provider_transports.py +258 -0
  187. novacode_cli/config/role_models.py +175 -0
  188. novacode_cli/context/__init__.py +50 -0
  189. novacode_cli/context/_analysis.py +799 -0
  190. novacode_cli/context/_budget.py +116 -0
  191. novacode_cli/context/_conversation.py +73 -0
  192. novacode_cli/context/_dynamic.py +264 -0
  193. novacode_cli/context/_model_config.py +380 -0
  194. novacode_cli/context/_models_dev.py +99 -0
  195. novacode_cli/context/archives.py +248 -0
  196. novacode_cli/context/estimates.py +65 -0
  197. novacode_cli/context/history.py +17 -0
  198. novacode_cli/context/knowledge_cache.py +292 -0
  199. novacode_cli/context/manager.py +101 -0
  200. novacode_cli/context/observations.py +65 -0
  201. novacode_cli/context/pressure.py +168 -0
  202. novacode_cli/context/provider_accounting.py +109 -0
  203. novacode_cli/context/snapshot.py +493 -0
  204. novacode_cli/context/strategies.py +279 -0
  205. novacode_cli/context/task_progress.py +122 -0
  206. novacode_cli/core/__init__.py +0 -0
  207. novacode_cli/core/agent_loop.py +1821 -0
  208. novacode_cli/core/autonomous_loop.py +277 -0
  209. novacode_cli/core/code_approvals.py +57 -0
  210. novacode_cli/core/input_preparation.py +276 -0
  211. novacode_cli/core/reasoning.py +67 -0
  212. novacode_cli/core/streaming.py +326 -0
  213. novacode_cli/core/subagent_tasks.py +696 -0
  214. novacode_cli/core/subagent_tracking.py +235 -0
  215. novacode_cli/core/tool_results.py +116 -0
  216. novacode_cli/core/verification_loop.py +272 -0
  217. novacode_cli/council.py +595 -0
  218. novacode_cli/council_consensus.py +115 -0
  219. novacode_cli/council_decision.py +157 -0
  220. novacode_cli/council_planner.py +1421 -0
  221. novacode_cli/council_settings.py +208 -0
  222. novacode_cli/council_web_settings.py +41 -0
  223. novacode_cli/council_workspace.py +56 -0
  224. novacode_cli/cowork/__init__.py +10 -0
  225. novacode_cli/cowork/broker_middleware.py +162 -0
  226. novacode_cli/cowork/launcher.py +102 -0
  227. novacode_cli/cowork/policy.py +284 -0
  228. novacode_cli/cowork/ui.py +826 -0
  229. novacode_cli/daemons/__init__.py +1 -0
  230. novacode_cli/daemons/registry.py +342 -0
  231. novacode_cli/desktop_notifications.py +302 -0
  232. novacode_cli/doctor.py +251 -0
  233. novacode_cli/embeddings.py +109 -0
  234. novacode_cli/entrypoint.py +56 -0
  235. novacode_cli/errors/__init__.py +19 -0
  236. novacode_cli/errors/handlers.py +648 -0
  237. novacode_cli/errors/provider_errors.py +249 -0
  238. novacode_cli/errors/taxonomy.py +80 -0
  239. novacode_cli/events.py +179 -0
  240. novacode_cli/file_ops.py +714 -0
  241. novacode_cli/git_safety.py +281 -0
  242. novacode_cli/handoff.py +383 -0
  243. novacode_cli/headless/__init__.py +18 -0
  244. novacode_cli/headless/input.py +58 -0
  245. novacode_cli/headless/output.py +224 -0
  246. novacode_cli/headless/pipe.py +532 -0
  247. novacode_cli/headless/runner.py +461 -0
  248. novacode_cli/hermes/__init__.py +39 -0
  249. novacode_cli/hermes/activation.py +297 -0
  250. novacode_cli/hermes/config.py +129 -0
  251. novacode_cli/hermes/curator.py +199 -0
  252. novacode_cli/hermes/evolution.py +374 -0
  253. novacode_cli/hermes/ledger.py +364 -0
  254. novacode_cli/hermes/memory_tiers.py +969 -0
  255. novacode_cli/hermes/middleware.py +678 -0
  256. novacode_cli/hermes/overview.py +174 -0
  257. novacode_cli/hermes/prompt_evolution.py +503 -0
  258. novacode_cli/hermes/refine_loop.py +643 -0
  259. novacode_cli/hermes/refinement_log.py +190 -0
  260. novacode_cli/hermes/review.py +674 -0
  261. novacode_cli/hermes/skill_debate.py +135 -0
  262. novacode_cli/hermes/skill_discovery.py +450 -0
  263. novacode_cli/hermes/skill_manager.py +335 -0
  264. novacode_cli/hermes/skill_proposals.py +43 -0
  265. novacode_cli/hermes/test_gaming.py +119 -0
  266. novacode_cli/hermes/tracker.py +435 -0
  267. novacode_cli/hermes/tuner.py +206 -0
  268. novacode_cli/hermes/verification_memory.py +134 -0
  269. novacode_cli/hermes/verifier.py +277 -0
  270. novacode_cli/hitl/__init__.py +22 -0
  271. novacode_cli/hitl/interrupts.py +493 -0
  272. novacode_cli/hooks.py +359 -0
  273. novacode_cli/image_utils.py +517 -0
  274. novacode_cli/init/__init__.py +7 -0
  275. novacode_cli/init/events.py +43 -0
  276. novacode_cli/input_utils.py +526 -0
  277. novacode_cli/install_commands.py +87 -0
  278. novacode_cli/integrations/__init__.py +82 -0
  279. novacode_cli/integrations/daytona.py +122 -0
  280. novacode_cli/integrations/docker.py +309 -0
  281. novacode_cli/integrations/host_path.py +111 -0
  282. novacode_cli/integrations/langsmith.py +151 -0
  283. novacode_cli/integrations/modal.py +134 -0
  284. novacode_cli/integrations/runloop.py +130 -0
  285. novacode_cli/integrations/sandbox_factory.py +1388 -0
  286. novacode_cli/integrations/sandbox_registry.py +312 -0
  287. novacode_cli/integrations/workdir_backend.py +589 -0
  288. novacode_cli/main.py +2078 -0
  289. novacode_cli/mcp/__init__.py +73 -0
  290. novacode_cli/mcp/auth.py +189 -0
  291. novacode_cli/mcp/client.py +571 -0
  292. novacode_cli/mcp/code_mode.py +5 -0
  293. novacode_cli/mcp/code_runtime.py +5 -0
  294. novacode_cli/mcp/commands.py +902 -0
  295. novacode_cli/mcp/config.py +406 -0
  296. novacode_cli/mcp/generic.py +128 -0
  297. novacode_cli/mcp/middleware.py +993 -0
  298. novacode_cli/mcp/presets.py +367 -0
  299. novacode_cli/memory/__init__.py +1 -0
  300. novacode_cli/memory/agent_memory.py +1312 -0
  301. novacode_cli/memory/builtin_lessons.py +60 -0
  302. novacode_cli/memory/dream_gates.py +192 -0
  303. novacode_cli/memory/limits.py +59 -0
  304. novacode_cli/memory/parsed_cache.py +106 -0
  305. novacode_cli/memory/recall_stats.py +97 -0
  306. novacode_cli/memory/semantic.py +116 -0
  307. novacode_cli/memory/store.py +335 -0
  308. novacode_cli/memory/wiki.py +274 -0
  309. novacode_cli/migrate.py +185 -0
  310. novacode_cli/onboarding.py +649 -0
  311. novacode_cli/onboarding_check.py +119 -0
  312. novacode_cli/path_approval.py +297 -0
  313. novacode_cli/plan_archive.py +148 -0
  314. novacode_cli/plugins/__init__.py +121 -0
  315. novacode_cli/plugins/claude_plugins.py +538 -0
  316. novacode_cli/plugins/cli.py +168 -0
  317. novacode_cli/plugins/components.py +413 -0
  318. novacode_cli/plugins/hooks.py +356 -0
  319. novacode_cli/plugins/loader.py +317 -0
  320. novacode_cli/plugins/marketplaces.py +199 -0
  321. novacode_cli/plugins/storage.py +70 -0
  322. novacode_cli/prime.py +759 -0
  323. novacode_cli/prime_factory/__init__.py +15 -0
  324. novacode_cli/prime_factory/container.py +198 -0
  325. novacode_cli/prime_factory/controller.py +565 -0
  326. novacode_cli/prime_factory/knowledge.py +261 -0
  327. novacode_cli/prime_factory/publication.py +218 -0
  328. novacode_cli/prime_factory/recovery.py +50 -0
  329. novacode_cli/prime_factory/research.py +370 -0
  330. novacode_cli/prime_factory/runner.py +608 -0
  331. novacode_cli/prime_factory/runtime.py +261 -0
  332. novacode_cli/prime_factory/state.py +201 -0
  333. novacode_cli/prime_factory/store.py +128 -0
  334. novacode_cli/prime_factory/worker.py +129 -0
  335. novacode_cli/prime_factory/workflow.py +899 -0
  336. novacode_cli/prime_factory/workspace.py +317 -0
  337. novacode_cli/prime_memory.py +290 -0
  338. novacode_cli/process_manager.py +585 -0
  339. novacode_cli/project_onboard.py +537 -0
  340. novacode_cli/prompts/System_Prompt_Nova.jinja +23 -0
  341. novacode_cli/prompts/__init__.py +168 -0
  342. novacode_cli/prompts/agent_definitions.jinja +11 -0
  343. novacode_cli/prompts/agent_generation.jinja +125 -0
  344. novacode_cli/prompts/async_agents/general_purpose.jinja +22 -0
  345. novacode_cli/prompts/claude_plan_mode.jinja +12 -0
  346. novacode_cli/prompts/coding_workflow.jinja +18 -0
  347. novacode_cli/prompts/computer_use.jinja +17 -0
  348. novacode_cli/prompts/core_agent_system.jinja +81 -0
  349. novacode_cli/prompts/delegation.jinja +15 -0
  350. novacode_cli/prompts/desktop_power.jinja +4 -0
  351. novacode_cli/prompts/file_paths.jinja +15 -0
  352. novacode_cli/prompts/file_tracker.jinja +23 -0
  353. novacode_cli/prompts/init_agents_md.jinja +56 -0
  354. novacode_cli/prompts/init_exploration.jinja +174 -0
  355. novacode_cli/prompts/init_semantic_orchestrate.jinja +37 -0
  356. novacode_cli/prompts/instruction_scope.jinja +5 -0
  357. novacode_cli/prompts/learnings_extraction.jinja +45 -0
  358. novacode_cli/prompts/longterm_memory.jinja +54 -0
  359. novacode_cli/prompts/mcp.jinja +84 -0
  360. novacode_cli/prompts/memory_consolidation.jinja +40 -0
  361. novacode_cli/prompts/nova_evolution.jinja +73 -0
  362. novacode_cli/prompts/nova_review.jinja +158 -0
  363. novacode_cli/prompts/nova_skill_debate.jinja +36 -0
  364. novacode_cli/prompts/nova_standing_instructions.jinja +26 -0
  365. novacode_cli/prompts/nova_verify.jinja +100 -0
  366. novacode_cli/prompts/onboard_dashboard.jinja +378 -0
  367. novacode_cli/prompts/plan_agent.jinja +87 -0
  368. novacode_cli/prompts/plan_scouts.jinja +5 -0
  369. novacode_cli/prompts/plan_storage.jinja +8 -0
  370. novacode_cli/prompts/planning.jinja +82 -0
  371. novacode_cli/prompts/ponytail/LICENSE +21 -0
  372. novacode_cli/prompts/ponytail/UPSTREAM.json +15 -0
  373. novacode_cli/prompts/ponytail/hooks/ponytail-config.js +175 -0
  374. novacode_cli/prompts/ponytail/hooks/ponytail-instructions.js +98 -0
  375. novacode_cli/prompts/ponytail/skills/ponytail/SKILL.md +52 -0
  376. novacode_cli/prompts/ponytail/skills/ponytail-audit/SKILL.md +103 -0
  377. novacode_cli/prompts/ponytail/skills/ponytail-debt/SKILL.md +44 -0
  378. novacode_cli/prompts/ponytail/skills/ponytail-gain/SKILL.md +52 -0
  379. novacode_cli/prompts/ponytail/skills/ponytail-help/SKILL.md +76 -0
  380. novacode_cli/prompts/ponytail/skills/ponytail-review/SKILL.md +101 -0
  381. novacode_cli/prompts/prompt_evolution.jinja +27 -0
  382. novacode_cli/prompts/ralph_iteration.jinja +75 -0
  383. novacode_cli/prompts/refine_gate.jinja +31 -0
  384. novacode_cli/prompts/refine_plan.jinja +64 -0
  385. novacode_cli/prompts/refine_review.jinja +34 -0
  386. novacode_cli/prompts/research_delivery.jinja +9 -0
  387. novacode_cli/prompts/research_swarm.jinja +130 -0
  388. novacode_cli/prompts/response_style.jinja +5 -0
  389. novacode_cli/prompts/session_summarization.jinja +86 -0
  390. novacode_cli/prompts/skill_creation.jinja +85 -0
  391. novacode_cli/prompts/subagents/fact_checker.jinja +48 -0
  392. novacode_cli/prompts/subagents/financial_analyst.jinja +50 -0
  393. novacode_cli/prompts/subagents/literature_reviewer.jinja +52 -0
  394. novacode_cli/prompts/subagents/market_analyst.jinja +48 -0
  395. novacode_cli/prompts/subagents/research_synthesizer.jinja +57 -0
  396. novacode_cli/prompts/subagents/technical_researcher.jinja +56 -0
  397. novacode_cli/prompts/subagents/web_researcher.jinja +46 -0
  398. novacode_cli/prompts/summarization.jinja +107 -0
  399. novacode_cli/prompts/summarization_answers.jinja +23 -0
  400. novacode_cli/prompts/summarization_questions.jinja +32 -0
  401. novacode_cli/prompts/vision_caption.jinja +21 -0
  402. novacode_cli/prompts/voice_summary.jinja +65 -0
  403. novacode_cli/prompts/wiki_file.jinja +57 -0
  404. novacode_cli/prompts/wiki_ingest.jinja +87 -0
  405. novacode_cli/recovery.py +383 -0
  406. novacode_cli/remote/__init__.py +45 -0
  407. novacode_cli/remote/approvals.py +282 -0
  408. novacode_cli/remote/async_task_clients.py +97 -0
  409. novacode_cli/remote/async_task_watcher.py +240 -0
  410. novacode_cli/remote/bridge.py +905 -0
  411. novacode_cli/remote/command_menu.py +26 -0
  412. novacode_cli/remote/command_ui.py +243 -0
  413. novacode_cli/remote/config.py +199 -0
  414. novacode_cli/remote/discord_bridge.py +530 -0
  415. novacode_cli/remote/discord_format.py +31 -0
  416. novacode_cli/remote/discord_threads.py +225 -0
  417. novacode_cli/remote/images.py +97 -0
  418. novacode_cli/remote/processor.py +601 -0
  419. novacode_cli/remote/rootapp_bot/bot.cjs +16 -0
  420. novacode_cli/remote/rootapp_bot/package-lock.json +2683 -0
  421. novacode_cli/remote/rootapp_bot/package.json +18 -0
  422. novacode_cli/remote/rootapp_bot/transport.cjs +80 -0
  423. novacode_cli/remote/rootapp_bridge.py +370 -0
  424. novacode_cli/remote/rootapp_setup.py +89 -0
  425. novacode_cli/remote/routing.py +103 -0
  426. novacode_cli/remote/scheduler.py +302 -0
  427. novacode_cli/remote/session.py +164 -0
  428. novacode_cli/remote/status.py +411 -0
  429. novacode_cli/remote/streaming.py +188 -0
  430. novacode_cli/remote/telegram_bridge.py +1118 -0
  431. novacode_cli/remote/telegram_format.py +215 -0
  432. novacode_cli/remote/telegram_hub.py +256 -0
  433. novacode_cli/remote/voice_notes.py +120 -0
  434. novacode_cli/remote/webhook_adapters.py +167 -0
  435. novacode_cli/remote/webhook_server.py +154 -0
  436. novacode_cli/rewind.py +150 -0
  437. novacode_cli/rooms/__init__.py +1 -0
  438. novacode_cli/rooms/bridge.py +86 -0
  439. novacode_cli/rooms/cli.py +231 -0
  440. novacode_cli/rooms/nova.py +227 -0
  441. novacode_cli/rooms/runtime.py +299 -0
  442. novacode_cli/rooms/store.py +538 -0
  443. novacode_cli/rooms/workspace.py +94 -0
  444. novacode_cli/security/__init__.py +29 -0
  445. novacode_cli/security/approval_context.py +45 -0
  446. novacode_cli/security/delegated_approval.py +68 -0
  447. novacode_cli/security/local_http.py +85 -0
  448. novacode_cli/security/middleware.py +87 -0
  449. novacode_cli/security/paths.py +35 -0
  450. novacode_cli/security/policy.py +535 -0
  451. novacode_cli/security/policy_writer.py +90 -0
  452. novacode_cli/security/prompt_injection.py +203 -0
  453. novacode_cli/security/remember.py +81 -0
  454. novacode_cli/security/rule_synthesis.py +116 -0
  455. novacode_cli/security/secret_files.py +61 -0
  456. novacode_cli/security/session_allow.py +114 -0
  457. novacode_cli/security/unicode_security.py +516 -0
  458. novacode_cli/security/validator.py +173 -0
  459. novacode_cli/server/__init__.py +9 -0
  460. novacode_cli/server/app.py +632 -0
  461. novacode_cli/server/event_adapter.py +189 -0
  462. novacode_cli/server/session_manager.py +90 -0
  463. novacode_cli/server_main.py +64 -0
  464. novacode_cli/server_runner/dev_server.py +530 -0
  465. novacode_cli/server_runner/test_runner.py +470 -0
  466. novacode_cli/session/__init__.py +1 -0
  467. novacode_cli/session/adapters/__init__.py +240 -0
  468. novacode_cli/session/adapters/claude_code.py +102 -0
  469. novacode_cli/session/adapters/codex.py +77 -0
  470. novacode_cli/session/adapters/harness_native.py +75 -0
  471. novacode_cli/session/checkpoint_coordinator.py +448 -0
  472. novacode_cli/session/context_restore.py +25 -0
  473. novacode_cli/session/history_export.py +228 -0
  474. novacode_cli/session/history_tree.py +174 -0
  475. novacode_cli/session/imported_context.py +267 -0
  476. novacode_cli/session/runtime_identity.py +32 -0
  477. novacode_cli/session/session_persistence.py +1168 -0
  478. novacode_cli/session/session_prompt_builder.py +279 -0
  479. novacode_cli/session/session_restore.py +351 -0
  480. novacode_cli/session/session_summarization.py +20 -0
  481. novacode_cli/session/transcript_journal.py +153 -0
  482. novacode_cli/sessions/__init__.py +11 -0
  483. novacode_cli/sessions/gateway.py +84 -0
  484. novacode_cli/sessions/launch.py +51 -0
  485. novacode_cli/sessions/lease.py +213 -0
  486. novacode_cli/sessions/messaging.py +255 -0
  487. novacode_cli/sessions/protocol.py +216 -0
  488. novacode_cli/sessions/resume.py +31 -0
  489. novacode_cli/sessions/supervisor.py +498 -0
  490. novacode_cli/sessions/tasks.py +73 -0
  491. novacode_cli/sessions/tui_messaging.py +413 -0
  492. novacode_cli/sessions/worker.py +652 -0
  493. novacode_cli/sessions/worktree.py +262 -0
  494. novacode_cli/shell/__init__.py +27 -0
  495. novacode_cli/shell/jobs.py +475 -0
  496. novacode_cli/shell/middleware.py +1568 -0
  497. novacode_cli/shell/os_sandbox.py +205 -0
  498. novacode_cli/shell/output.py +181 -0
  499. novacode_cli/shell/patterns.py +275 -0
  500. novacode_cli/shell/processes.py +87 -0
  501. novacode_cli/shell/utils.py +236 -0
  502. novacode_cli/skills/__init__.py +26 -0
  503. novacode_cli/skills/builtin/handoff/SKILL.md +15 -0
  504. novacode_cli/skills/curation_middleware.py +69 -0
  505. novacode_cli/skills/executable.py +161 -0
  506. novacode_cli/skills/libraries.py +102 -0
  507. novacode_cli/skills/load.py +248 -0
  508. novacode_cli/skills/prune.py +215 -0
  509. novacode_cli/skills/refreshing_middleware.py +896 -0
  510. novacode_cli/skills/retrieval.py +248 -0
  511. novacode_cli/skills/runtime.py +118 -0
  512. novacode_cli/skills/schema.py +254 -0
  513. novacode_cli/skills/skill_creation.py +1549 -0
  514. novacode_cli/skills/skill_lock.py +75 -0
  515. novacode_cli/skills/skill_system_prompt.py +16 -0
  516. novacode_cli/skills/skills_prefs.py +126 -0
  517. novacode_cli/skills/tool_resolver.py +31 -0
  518. novacode_cli/skills/upstream.py +50 -0
  519. novacode_cli/skills/versioning.py +291 -0
  520. novacode_cli/states/Session.py +847 -0
  521. novacode_cli/states/__init__.py +31 -0
  522. novacode_cli/states/slices/__init__.py +17 -0
  523. novacode_cli/states/slices/agent_runtime.py +249 -0
  524. novacode_cli/states/slices/background_tasks.py +21 -0
  525. novacode_cli/states/slices/notifications.py +74 -0
  526. novacode_cli/states/slices/remote_bridge.py +56 -0
  527. novacode_cli/states/slices/ui_settings.py +49 -0
  528. novacode_cli/states/slices/wiki.py +25 -0
  529. novacode_cli/teammates/__init__.py +1 -0
  530. novacode_cli/teammates/controller.py +557 -0
  531. novacode_cli/teammates/graph.py +104 -0
  532. novacode_cli/teammates/registry.py +175 -0
  533. novacode_cli/teammates/service.py +254 -0
  534. novacode_cli/teammates/worker.py +19 -0
  535. novacode_cli/teams/__init__.py +1 -0
  536. novacode_cli/teams/adapters/__init__.py +1 -0
  537. novacode_cli/teams/adapters/container.py +316 -0
  538. novacode_cli/teams/adapters/inprocess.py +206 -0
  539. novacode_cli/teams/adapters/process.py +364 -0
  540. novacode_cli/teams/bootstrap.py +246 -0
  541. novacode_cli/teams/container_git.py +67 -0
  542. novacode_cli/teams/context.py +34 -0
  543. novacode_cli/teams/coordinator.py +815 -0
  544. novacode_cli/teams/gateway.py +200 -0
  545. novacode_cli/teams/host.py +64 -0
  546. novacode_cli/teams/legacy.py +52 -0
  547. novacode_cli/teams/middleware.py +176 -0
  548. novacode_cli/teams/models.py +104 -0
  549. novacode_cli/teams/output.py +47 -0
  550. novacode_cli/teams/protocol.py +50 -0
  551. novacode_cli/teams/recovery.py +80 -0
  552. novacode_cli/teams/scheduler.py +44 -0
  553. novacode_cli/teams/store.py +631 -0
  554. novacode_cli/teams/worker.py +251 -0
  555. novacode_cli/teams/workspace.py +100 -0
  556. novacode_cli/token_utils.py +256 -0
  557. novacode_cli/tools/__init__.py +104 -0
  558. novacode_cli/tools/_shared.py +97 -0
  559. novacode_cli/tools/_types.py +24 -0
  560. novacode_cli/tools/artifact_tools.py +137 -0
  561. novacode_cli/tools/code_search_tools.py +330 -0
  562. novacode_cli/tools/computer_tools.py +77 -0
  563. novacode_cli/tools/cron_tools.py +65 -0
  564. novacode_cli/tools/daemon_tool.py +290 -0
  565. novacode_cli/tools/desktop_tools.py +161 -0
  566. novacode_cli/tools/fetch_tools.py +530 -0
  567. novacode_cli/tools/http_tools.py +279 -0
  568. novacode_cli/tools/inspection_tools.py +107 -0
  569. novacode_cli/tools/job_tools.py +143 -0
  570. novacode_cli/tools/knowledge_tools.py +427 -0
  571. novacode_cli/tools/memory_tools.py +461 -0
  572. novacode_cli/tools/memory_wiki_tools.py +40 -0
  573. novacode_cli/tools/notebook_tools.py +238 -0
  574. novacode_cli/tools/oracle_tool.py +83 -0
  575. novacode_cli/tools/plan_archive_tools.py +80 -0
  576. novacode_cli/tools/plan_mode_tools.py +315 -0
  577. novacode_cli/tools/python_kernel_loop.py +165 -0
  578. novacode_cli/tools/python_kernel_tool.py +222 -0
  579. novacode_cli/tools/reflection_tools.py +34 -0
  580. novacode_cli/tools/scraper_tools.py +377 -0
  581. novacode_cli/tools/skill_tools.py +275 -0
  582. novacode_cli/tools/speak_tool.py +51 -0
  583. novacode_cli/tools/store_memory_tools.py +227 -0
  584. novacode_cli/tools/ui_tools.py +62 -0
  585. novacode_cli/tools/waiter_tools.py +408 -0
  586. novacode_cli/tools/web_tools.py +349 -0
  587. novacode_cli/tools/wiki_tools.py +152 -0
  588. novacode_cli/tools/worktree_tools.py +219 -0
  589. novacode_cli/tracking/__init__.py +10 -0
  590. novacode_cli/tracking/file_tracker.py +826 -0
  591. novacode_cli/tracking/local_metrics.py +170 -0
  592. novacode_cli/tracking/loop_guard.py +507 -0
  593. novacode_cli/tracking/request_metrics.py +60 -0
  594. novacode_cli/tracking/tracing.py +692 -0
  595. novacode_cli/tracking/usage_tree.py +677 -0
  596. novacode_cli/tracking/workspace_anchoring.py +231 -0
  597. novacode_cli/tui/__init__.py +11 -0
  598. novacode_cli/tui/animation_rate.py +9 -0
  599. novacode_cli/tui/animations.py +332 -0
  600. novacode_cli/tui/app.py +19315 -0
  601. novacode_cli/tui/auth_display.py +87 -0
  602. novacode_cli/tui/auth_screens.py +826 -0
  603. novacode_cli/tui/avatar.py +294 -0
  604. novacode_cli/tui/avatar_frames.py +21 -0
  605. novacode_cli/tui/background_tasks.py +35 -0
  606. novacode_cli/tui/browser_settings_screen.py +70 -0
  607. novacode_cli/tui/caveman_screen.py +185 -0
  608. novacode_cli/tui/council_settings_screen.py +182 -0
  609. novacode_cli/tui/council_view.py +535 -0
  610. novacode_cli/tui/decision_settings.py +268 -0
  611. novacode_cli/tui/diff_preview.py +199 -0
  612. novacode_cli/tui/gc_tuning.py +63 -0
  613. novacode_cli/tui/handoff_screen.py +206 -0
  614. novacode_cli/tui/harness.py +244 -0
  615. novacode_cli/tui/history_tree_screen.py +179 -0
  616. novacode_cli/tui/home.py +328 -0
  617. novacode_cli/tui/home_scene.py +94 -0
  618. novacode_cli/tui/icons.py +81 -0
  619. novacode_cli/tui/interface_styles.tcss +327 -0
  620. novacode_cli/tui/matrix3.py +108 -0
  621. novacode_cli/tui/mcp_tools_screen.py +131 -0
  622. novacode_cli/tui/motion.py +178 -0
  623. novacode_cli/tui/output_body.py +136 -0
  624. novacode_cli/tui/output_buffer.py +59 -0
  625. novacode_cli/tui/palette.py +334 -0
  626. novacode_cli/tui/pickers.py +237 -0
  627. novacode_cli/tui/prime_panel.py +506 -0
  628. novacode_cli/tui/provider_signin.py +213 -0
  629. novacode_cli/tui/pty_shell.py +159 -0
  630. novacode_cli/tui/reference_screens.py +444 -0
  631. novacode_cli/tui/resume_picker.py +346 -0
  632. novacode_cli/tui/rooms.py +597 -0
  633. novacode_cli/tui/screens.py +7088 -0
  634. novacode_cli/tui/session_import.py +333 -0
  635. novacode_cli/tui/session_pane.py +242 -0
  636. novacode_cli/tui/session_tab.py +42 -0
  637. novacode_cli/tui/session_transcript.py +118 -0
  638. novacode_cli/tui/shell_output.py +7 -0
  639. novacode_cli/tui/stall_watch.py +174 -0
  640. novacode_cli/tui/statusline.py +40 -0
  641. novacode_cli/tui/statusline_screen.py +91 -0
  642. novacode_cli/tui/teammate_updates.py +162 -0
  643. novacode_cli/tui/teammates.py +548 -0
  644. novacode_cli/tui/terminal.py +289 -0
  645. novacode_cli/tui/terminal_view.py +235 -0
  646. novacode_cli/tui/themes.py +118 -0
  647. novacode_cli/tui/update_notice.py +84 -0
  648. novacode_cli/tui/voice_downloads.py +113 -0
  649. novacode_cli/tui/widgets.py +2038 -0
  650. novacode_cli/tui/windows_input.py +38 -0
  651. novacode_cli/tui/workspace_approval.py +165 -0
  652. novacode_cli/ui/council_web.py +113 -0
  653. novacode_cli/ui/council_workspace.py +72 -0
  654. novacode_cli/ui/cowork_web.py +41 -0
  655. novacode_cli/ui/diff_highlight.py +160 -0
  656. novacode_cli/ui/execution.py +1396 -0
  657. novacode_cli/ui/exit_summary.py +65 -0
  658. novacode_cli/ui/hitl_approval.py +196 -0
  659. novacode_cli/ui/input_preparation.py +2 -0
  660. novacode_cli/ui/interrupt_handlers.py +215 -0
  661. novacode_cli/ui/question_prompt.py +151 -0
  662. novacode_cli/ui/status_phrases.py +157 -0
  663. novacode_cli/ui/stream_preview.py +66 -0
  664. novacode_cli/ui/streaming.py +2 -0
  665. novacode_cli/ui/subagent_tracking.py +2 -0
  666. novacode_cli/ui/ui_elements.py +1585 -0
  667. novacode_cli/ui/web_workspace.py +244 -0
  668. novacode_cli/ui_events.py +299 -0
  669. novacode_cli/update_ui.py +243 -0
  670. novacode_cli/updates.py +489 -0
  671. novacode_cli/utils/backend_patches.py +399 -0
  672. novacode_cli/utils/backend_paths.py +467 -0
  673. novacode_cli/utils/lazy_providers.py +51 -0
  674. novacode_cli/utils/model_info.py +223 -0
  675. novacode_cli/utils/pdf_extraction.py +183 -0
  676. novacode_cli/utils/startup.py +29 -0
  677. novacode_cli/utils/tasks.py +42 -0
  678. novacode_cli/utils/windows.py +9 -0
  679. novacode_cli/vixie/hook_adapter.py +127 -0
  680. novacode_cli/vixie/server.py +375 -0
  681. novacode_cli/wiki/__init__.py +15 -0
  682. novacode_cli/wiki/ask.py +56 -0
  683. novacode_cli/wiki/file.py +71 -0
  684. novacode_cli/wiki/ingest.py +184 -0
  685. novacode_cli/wiki/manager.py +261 -0
  686. novacode_cli-1.2.0.dist-info/METADATA +271 -0
  687. novacode_cli-1.2.0.dist-info/RECORD +691 -0
  688. novacode_cli-1.2.0.dist-info/WHEEL +5 -0
  689. novacode_cli-1.2.0.dist-info/entry_points.txt +2 -0
  690. novacode_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
  691. novacode_cli-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1118 @@
1
+ """Telegram bridge — forwards messages between Telegram and the Nova-Code agent.
2
+
3
+ Uses the Telegram Bot HTTP API directly via ``aiohttp`` (already a
4
+ dependency), so no additional packages are needed. Long-polling via
5
+ ``getUpdates`` means we don't need webhooks or incoming ports.
6
+
7
+ Message Flow
8
+ ------------
9
+ 1. Background task calls ``getUpdates`` in a loop (long-polling with
10
+ 30-second timeout).
11
+ 2. Incoming messages from the allowlisted chat are wrapped as
12
+ ``RemoteMessage`` and put on the shared queue.
13
+ 3. The CLI's main loop runs ``execute_task()`` with the message text.
14
+ 4. The ``reply_fn`` uses ``sendMessage`` to return the response.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import hashlib
21
+ import logging
22
+ import re
23
+ import time
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING, Any
26
+ from urllib.parse import unquote, urlsplit
27
+
28
+ import aiohttp
29
+
30
+ from novacode_cli.remote.bridge import (
31
+ BridgeConfig,
32
+ RemoteMessage,
33
+ RemotePlatform,
34
+ )
35
+ from novacode_cli.remote.telegram_format import image_sources
36
+ from novacode_cli.remote.telegram_format import render as render_markdown
37
+ from novacode_cli.remote.telegram_format import to_plain
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ if TYPE_CHECKING:
42
+ from collections.abc import Awaitable, Callable
43
+
44
+ _TELEGRAM_API = "https://api.telegram.org"
45
+
46
+
47
+ _LONG_POLL_SERVER_TIMEOUT = 30 # seconds Telegram holds the connection open
48
+ _LONG_POLL_CLIENT_TIMEOUT = aiohttp.ClientTimeout(
49
+ total=_LONG_POLL_SERVER_TIMEOUT + 5
50
+ ) # client-side deadline: give server 5 s extra, then treat as a network hang
51
+
52
+
53
+ class TelegramBridge:
54
+ """Telegram bot bridge for Nova-Code.
55
+
56
+ Connects to the Telegram Bot API via long-polling. No additional
57
+ dependencies beyond ``aiohttp`` (already installed).
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ config: BridgeConfig,
63
+ message_queue: asyncio.Queue[RemoteMessage],
64
+ ) -> None:
65
+ self._config = config
66
+ self._queue = message_queue
67
+ self._session: aiohttp.ClientSession | None = None
68
+ self._offset: int = 0 # last update_id + 1 for long-polling
69
+ self._running = False
70
+ self.is_forum: bool | None = None # topics enabled? (detected on first use)
71
+ self._owner: dict[int, str] = {} # sent message_id -> session id that sent it
72
+ self.bot_user: str | None = None # set after successful getMe
73
+ self._retry_at = 0.0
74
+ self._last_api_error: str | None = None
75
+ self._session_topics: dict[str, tuple[str, int]] = {}
76
+ self._session_topic_titles: dict[str, tuple[int, str]] = {}
77
+ self._topic_handlers: dict[int, Any] = {}
78
+ self._topic_lock = asyncio.Lock()
79
+ self._keyboard_lock = asyncio.Lock()
80
+ self._command_menu_lock = asyncio.Lock()
81
+ self._command_menu: list[dict[str, str]] | None = None
82
+ self._shared_polling = None
83
+ self._on_status = None
84
+ self._last_sender_warning = 0.0
85
+
86
+ def enable_shared_polling(self) -> None:
87
+ from novacode_cli.remote.telegram_hub import SharedTelegramPolling
88
+
89
+ async def fetch(offset):
90
+ self._offset = offset
91
+ return await self._get_updates()
92
+
93
+ self._shared_polling = SharedTelegramPolling(self._config.token, fetch)
94
+ self._shared_polling.bindings = [
95
+ (int(self._config.chat_id), 0),
96
+ *[(int(self._config.chat_id), tid) for tid in self._topic_handlers],
97
+ ]
98
+
99
+ async def sync_command_menu(self, commands: list[dict[str, str]]) -> bool:
100
+ """Publish autocomplete for this chat, retrying failures on the next refresh."""
101
+ async with self._command_menu_lock:
102
+ if commands == self._command_menu:
103
+ return True
104
+ result = await self._api_call(
105
+ "setMyCommands",
106
+ {"commands": commands, "scope": {"type": "chat", "chat_id": self._config.chat_id}},
107
+ req_timeout=aiohttp.ClientTimeout(total=10),
108
+ )
109
+ if not result or not result.get("ok"):
110
+ logger.warning("Telegram command menu could not be updated")
111
+ return False
112
+ self._command_menu = [dict(command) for command in commands]
113
+ return True
114
+
115
+ def set_topic_handler(self, topic_id: int, handler) -> None:
116
+ """Reserve a non-conversation topic's messages for a dedicated feature."""
117
+ self._topic_handlers[topic_id] = handler
118
+
119
+ async def _dispatch_topic_message(self, msg) -> bool:
120
+ if str(msg.chat_id) != str(self._config.chat_id):
121
+ return False
122
+ handler = self._topic_handlers.get(msg.thread_id)
123
+ if handler is None:
124
+ return False
125
+ try:
126
+ await asyncio.wait_for(handler(msg), timeout=10)
127
+ except Exception as error:
128
+ logger.warning("Dedicated Telegram topic handler failed (%s)", type(error).__name__)
129
+ # Fail closed: an intake error must never turn a card into a prompt.
130
+ try:
131
+ await asyncio.wait_for(
132
+ msg.reply_fn("Could not confirm this task. Check the board before resending."),
133
+ timeout=5,
134
+ )
135
+ except Exception:
136
+ pass
137
+ return True
138
+
139
+ async def ensure_session_topic(self, sid: str, session_id: str, name: str, *, verify: bool = False) -> int | None:
140
+ """Bind a process-local route to a durable, bot-scoped session topic."""
141
+ from novacode_cli.memory.store import get_durable_store
142
+
143
+ async with self._topic_lock:
144
+ title = f"NovaCode · {' '.join(name.split()) or session_id[:8]}"[:128]
145
+ cached = self._session_topics.get(sid)
146
+ if cached and cached[0] == session_id and not verify:
147
+ await self._sync_session_topic_title(sid, cached[1], title)
148
+ return cached[1]
149
+ if self._shared_polling:
150
+ await self._shared_polling.reserve_session(self._config.chat_id, session_id)
151
+ store = await asyncio.to_thread(get_durable_store)
152
+ bot = hashlib.sha256(self._config.token.encode()).hexdigest()
153
+ namespace = ("nova", "telegram_topics", bot, str(self._config.chat_id))
154
+ saved = await store.aget(namespace, session_id)
155
+ tid = saved.value.get("topic") if saved else None
156
+ if isinstance(tid, int) and not isinstance(tid, bool) and tid > 0:
157
+ # Reopening validates the saved ID and makes a closed session
158
+ # usable again. Already-open topics report TOPIC_NOT_MODIFIED.
159
+ result = await self._api_call(
160
+ "reopenForumTopic",
161
+ {"chat_id": self._config.chat_id, "message_thread_id": tid},
162
+ req_timeout=aiohttp.ClientTimeout(total=10),
163
+ return_errors=True,
164
+ )
165
+ description = str((result or {}).get("description", "")).upper()
166
+ bad_request = (result or {}).get("error_code") == 400
167
+ missing = bad_request and any(
168
+ marker in description
169
+ for marker in (
170
+ "TOPIC_ID_INVALID",
171
+ "MESSAGE_THREAD_NOT_FOUND",
172
+ "MESSAGE THREAD NOT FOUND",
173
+ "TOPIC_NOT_FOUND",
174
+ "TOPIC NOT FOUND",
175
+ )
176
+ )
177
+ already_open = bad_request and any(
178
+ marker in description
179
+ for marker in (
180
+ "TOPIC_NOT_MODIFIED",
181
+ "TOPIC_NOT_CLOSED",
182
+ )
183
+ )
184
+ if missing:
185
+ tid = None
186
+ elif not result or (not result.get("ok") and not already_open):
187
+ raise RuntimeError(
188
+ "Could not verify this session's Telegram topic. "
189
+ + (description or "Try reconnecting.")
190
+ )
191
+ elif saved.value.get("closed"):
192
+ await store.aput(namespace, session_id, {"topic": tid})
193
+ created = not isinstance(tid, int) or isinstance(tid, bool) or tid <= 0
194
+ if created:
195
+ tid = await self.open_topic(title)
196
+ if tid is None:
197
+ return None
198
+ await store.aput(namespace, session_id, {"topic": tid})
199
+ proposed = {**self._session_topics, sid: (session_id, tid)}
200
+ if self._shared_polling:
201
+ await self._shared_polling.subscribe(
202
+ [
203
+ (int(self._config.chat_id), 0),
204
+ *[(int(self._config.chat_id), value[1]) for value in proposed.values()],
205
+ *[(int(self._config.chat_id), tid) for tid in self._topic_handlers],
206
+ ]
207
+ )
208
+ self._session_topics = proposed
209
+ if created:
210
+ self._session_topic_titles[sid] = (tid, title)
211
+ else:
212
+ await self._sync_session_topic_title(sid, tid, title)
213
+ return tid
214
+
215
+ async def _sync_session_topic_title(self, sid: str, tid: int, title: str) -> None:
216
+ """Update reused topics without replacing their history or route.
217
+
218
+ Called under the topic lock. Failed edits remain eligible for retry.
219
+ """
220
+ if self._session_topic_titles.get(sid) == (tid, title):
221
+ return
222
+ result = await self._api_call(
223
+ "editForumTopic",
224
+ {"chat_id": self._config.chat_id, "message_thread_id": tid, "name": title},
225
+ req_timeout=aiohttp.ClientTimeout(total=10),
226
+ return_errors=True,
227
+ )
228
+ unchanged = (
229
+ (result or {}).get("error_code") == 400
230
+ and "TOPIC_NOT_MODIFIED" in str((result or {}).get("description", "")).upper()
231
+ )
232
+ if (result or {}).get("ok") or unchanged:
233
+ self._session_topic_titles[sid] = (tid, title)
234
+ else:
235
+ logger.warning("Telegram topic title update failed; retaining the existing topic.")
236
+
237
+ @property
238
+ def is_connected(self) -> bool:
239
+ """True once the bot is verified and the poll loop is running."""
240
+ return self._running and self.bot_user is not None
241
+
242
+ def _ensure_session(self) -> aiohttp.ClientSession:
243
+ """Return the current session, creating a fresh one if needed."""
244
+ if self._session is None or self._session.closed:
245
+ self._session = aiohttp.ClientSession()
246
+ return self._session
247
+
248
+ #: Attempts for a call Telegram refuses with 429. It names the wait, and a
249
+ #: refused call was not carried out, so resending cannot duplicate it.
250
+ _RATE_LIMIT_ATTEMPTS = 3
251
+
252
+ @property
253
+ def cooling_down(self) -> bool:
254
+ """Telegram asked for a pause (429) that has not elapsed yet."""
255
+ return self._retry_at > time.monotonic()
256
+
257
+ async def _api_call(
258
+ self,
259
+ method: str,
260
+ payload: dict[str, Any],
261
+ *,
262
+ req_timeout: aiohttp.ClientTimeout | None = None,
263
+ return_errors: bool = False,
264
+ photo: tuple[bytes, str, str] | None = None,
265
+ ) -> dict[str, Any] | None:
266
+ """:meth:`_api_call_once`, resending a call refused as rate-limited.
267
+
268
+ It used to return ``None`` for that call and move on: when the call was
269
+ a response's message or its final edit, the response never arrived.
270
+ """
271
+ attempts = 1 if method == "getUpdates" else self._RATE_LIMIT_ATTEMPTS
272
+ for attempt in range(attempts):
273
+ result = await self._api_call_once(
274
+ method, payload, req_timeout=req_timeout, return_errors=return_errors, photo=photo
275
+ )
276
+ limited = (self._last_api_error or "").lower().startswith("too many requests")
277
+ if result is not None and not (return_errors and limited):
278
+ return result
279
+ if not limited or attempt == attempts - 1:
280
+ return result
281
+ # _api_call_once waits out the named pause before the next attempt.
282
+ return None
283
+
284
+ async def _api_call_once(
285
+ self,
286
+ method: str,
287
+ payload: dict[str, Any],
288
+ *,
289
+ req_timeout: aiohttp.ClientTimeout | None = None,
290
+ return_errors: bool = False,
291
+ photo: tuple[bytes, str, str] | None = None,
292
+ ) -> dict[str, Any] | None:
293
+ """Make a Telegram Bot API call.
294
+
295
+ Args:
296
+ method: API method name (e.g., "getUpdates", "sendMessage").
297
+ payload: Request body as a dict.
298
+ req_timeout: Optional per-request aiohttp timeout. Defaults to the
299
+ session's default (300 s). Long-poll callers should pass
300
+ ``_LONG_POLL_CLIENT_TIMEOUT`` so a network hang is detected
301
+ within ~35 seconds instead of ~5 minutes.
302
+ return_errors: Return Telegram's error response for topic validation,
303
+ so missing topics can be distinguished from other failures.
304
+
305
+ Returns:
306
+ Parsed JSON response, or None on error.
307
+ """
308
+ session = self._ensure_session()
309
+ delay = self._retry_at - time.monotonic()
310
+ if delay > 0:
311
+ await asyncio.sleep(delay)
312
+ url = f"{_TELEGRAM_API}/bot{self._config.token}/{method}"
313
+ try:
314
+ request_data: aiohttp.FormData | None = None
315
+ if photo is not None:
316
+ request_data = aiohttp.FormData()
317
+ for key, value in payload.items():
318
+ request_data.add_field(key, str(value))
319
+ content, filename, mime_type = photo
320
+ request_data.add_field(
321
+ "photo", content, filename=filename, content_type=mime_type
322
+ )
323
+ request = (
324
+ session.post(url, data=request_data, timeout=req_timeout)
325
+ if request_data is not None
326
+ else session.post(url, json=payload, timeout=req_timeout)
327
+ )
328
+ async with request as resp:
329
+ data = await resp.json()
330
+ self._last_api_error = None
331
+ if not data.get("ok"):
332
+ description = str(data.get("description", ""))
333
+ self._last_api_error = description
334
+ if data.get("error_code") == 429:
335
+ retry_after = (data.get("parameters") or {}).get("retry_after", 1)
336
+ self._retry_at = time.monotonic() + max(1, float(retry_after))
337
+ if (
338
+ method == "editMessageText"
339
+ and "message is not modified" in description.lower()
340
+ ):
341
+ return {"ok": True, "result": {"message_id": payload.get("message_id")}}
342
+ if return_errors:
343
+ return data
344
+ logger.error(f"Telegram API error: {data.get('description')}")
345
+ return None
346
+ return data
347
+ except aiohttp.ClientError as e:
348
+ logger.error(f"Telegram HTTP error ({method}): {e}")
349
+ # Close the stale session so the next call opens a fresh connection.
350
+ try:
351
+ await session.close()
352
+ except Exception: # noqa: BLE001
353
+ pass
354
+ self._session = None
355
+ return None
356
+ except TimeoutError:
357
+ logger.debug(f"Telegram long-poll timeout on {method} — normal, retrying")
358
+ try:
359
+ await session.close()
360
+ except Exception: # noqa: BLE001
361
+ pass
362
+ self._session = None
363
+ return None
364
+ except Exception as e: # noqa: BLE001
365
+ logger.error(f"Telegram API call error ({method}): {e}")
366
+ return None
367
+
368
+ async def _get_updates(self) -> list[dict[str, Any]]:
369
+ """Long-poll for updates from Telegram.
370
+
371
+ The server holds the connection open for up to ``_LONG_POLL_SERVER_TIMEOUT``
372
+ seconds while waiting for new messages, then responds with an empty list.
373
+ We set a client-side deadline of server_timeout + 5 s so any network hang
374
+ is detected quickly rather than waiting for aiohttp's 5-minute default.
375
+ """
376
+ result = await self._api_call(
377
+ "getUpdates",
378
+ {
379
+ "offset": self._offset,
380
+ "timeout": _LONG_POLL_SERVER_TIMEOUT,
381
+ "allowed_updates": ["message", "callback_query"],
382
+ },
383
+ req_timeout=_LONG_POLL_CLIENT_TIMEOUT,
384
+ )
385
+ if result is None:
386
+ return []
387
+
388
+ updates = result.get("result", [])
389
+ if updates:
390
+ self._offset = updates[-1]["update_id"] + 1
391
+ return updates
392
+
393
+ # ── topics: one per Nova session (forum supergroups) ─────────────────
394
+
395
+ async def detect_forum(self) -> bool:
396
+ """Whether the chat is a forum supergroup (topics enabled). Cached."""
397
+ if self.is_forum is None:
398
+ info = await self._api_call("getChat", {"chat_id": self._config.chat_id})
399
+ if info is None:
400
+ return False # transient API errors must not disable topics permanently
401
+ chat = info.get("result", {})
402
+ if chat.get("type") == "private":
403
+ me = await self._api_call("getMe", {})
404
+ if me is None:
405
+ return False
406
+ self.is_forum = bool(me.get("result", {}).get("has_topics_enabled"))
407
+ else:
408
+ self.is_forum = bool(chat.get("is_forum"))
409
+ return self.is_forum
410
+
411
+ async def open_topic(self, name: str) -> int | None:
412
+ """Create a topic for a session; its ``message_thread_id``, or None
413
+ when the chat has no topics or the bot may not manage them."""
414
+ if not await self.detect_forum():
415
+ return None
416
+ result = await self._api_call(
417
+ "createForumTopic", {"chat_id": self._config.chat_id, "name": name[:128]}
418
+ )
419
+ return (result or {}).get("result", {}).get("message_thread_id")
420
+
421
+ async def close_topic(self, thread_id: int) -> None:
422
+ """Close (not delete) a session's topic: its history stays readable."""
423
+ result = await self._api_call(
424
+ "closeForumTopic", {"chat_id": self._config.chat_id, "message_thread_id": thread_id}
425
+ )
426
+ if result:
427
+ from novacode_cli.memory.store import get_durable_store
428
+
429
+ store = await asyncio.to_thread(get_durable_store)
430
+ bot = hashlib.sha256(self._config.token.encode()).hexdigest()
431
+ namespace = ("nova", "telegram_topics", bot, str(self._config.chat_id))
432
+ for sid, (session_id, tid) in list(self._session_topics.items()):
433
+ if tid != thread_id:
434
+ continue
435
+ await store.aput(namespace, session_id, {"topic": tid, "closed": True})
436
+ del self._session_topics[sid]
437
+ self._session_topic_titles.pop(sid, None)
438
+ if self._shared_polling:
439
+ await self._shared_polling.release_session(
440
+ self._config.chat_id,
441
+ session_id,
442
+ [
443
+ (int(self._config.chat_id), 0),
444
+ *[
445
+ (int(self._config.chat_id), value[1])
446
+ for value in self._session_topics.values()
447
+ ],
448
+ *[(int(self._config.chat_id), tid) for tid in self._topic_handlers],
449
+ ],
450
+ )
451
+
452
+ async def post(self, text: str, *, thread_id: int | None = None, sid: str = "root") -> None:
453
+ """Send markdown ``text`` unprompted (e.g. "session started") to a topic."""
454
+ await self._send_message(self._config.chat_id, text, thread_id=thread_id, sid=sid)
455
+
456
+ async def post_keyboard(
457
+ self,
458
+ text: str,
459
+ choices: list[list[str]],
460
+ *,
461
+ thread_id: int | None = None,
462
+ sid: str = "root",
463
+ reply_to_message_id: int | None = None,
464
+ ) -> bool:
465
+ """Send a plain-text prompt with a Telegram reply keyboard."""
466
+ keyboard = [[{"text": choice} for choice in row] for row in choices if row]
467
+ payload = self._thread_params(
468
+ {
469
+ "chat_id": self._config.chat_id,
470
+ "text": text,
471
+ "reply_markup": {
472
+ "keyboard": keyboard,
473
+ "resize_keyboard": True,
474
+ "one_time_keyboard": True,
475
+ "is_persistent": False,
476
+ },
477
+ },
478
+ thread_id,
479
+ )
480
+ self._target_keyboard_reply(payload, reply_to_message_id)
481
+ return await self._send_keyboard_payload(payload, sid)
482
+
483
+ async def _send_keyboard_payload(self, payload: dict, sid: str) -> bool:
484
+ # Preserve UI transition order when expiry and incoming commands overlap.
485
+ async with self._keyboard_lock:
486
+ sent = await self._api_call("sendMessage", payload)
487
+ self._remember(sent, sid)
488
+ return sent is not None
489
+
490
+ @staticmethod
491
+ def _target_keyboard_reply(payload: dict, message_id: int | None) -> None:
492
+ if message_id is not None:
493
+ payload["reply_parameters"] = {"message_id": message_id}
494
+ payload["reply_markup"]["selective"] = True
495
+
496
+ async def remove_keyboard(
497
+ self,
498
+ text: str,
499
+ *,
500
+ thread_id: int | None = None,
501
+ sid: str = "root",
502
+ reply_to_message_id: int | None = None,
503
+ ) -> bool:
504
+ """Retire a picker rather than merely hiding its buttons."""
505
+ payload = self._thread_params(
506
+ {
507
+ "chat_id": self._config.chat_id,
508
+ "text": text,
509
+ "reply_markup": {"remove_keyboard": True},
510
+ },
511
+ thread_id,
512
+ )
513
+ self._target_keyboard_reply(payload, reply_to_message_id)
514
+ return await self._send_keyboard_payload(payload, sid)
515
+
516
+ def _remember(self, sent: dict | None, sid: str | None) -> None:
517
+ """Note which session sent a message, so replying to it reaches that session."""
518
+ mid = (sent or {}).get("result", {}).get("message_id")
519
+ if mid is None or not sid:
520
+ return
521
+ self._owner[mid] = sid
522
+ if len(self._owner) > 5000: # bounded: forget the oldest half
523
+ for key in list(self._owner)[:2500]:
524
+ del self._owner[key]
525
+
526
+ def _thread_params(self, base: dict[str, Any], thread_id: int | None = None) -> dict[str, Any]:
527
+ """Inject ``message_thread_id`` into an API payload for a topic."""
528
+ if thread_id is not None:
529
+ return {**base, "message_thread_id": thread_id}
530
+ return base
531
+
532
+ async def _send_html(self, method: str, params: dict[str, Any], body: str) -> dict | None:
533
+ """Send/edit ``body`` (Telegram HTML); on a parse refusal, resend it plain.
534
+
535
+ Without the fallback a message Telegram rejects is simply lost.
536
+ """
537
+ sent = await self._api_call(method, {**params, "text": body, "parse_mode": "HTML"})
538
+ if sent is None and not self._last_api_error and self._retry_at <= time.monotonic():
539
+ sent = await self._api_call(method, {**params, "text": to_plain(body)})
540
+ elif sent is None and self._last_api_error and "parse" in self._last_api_error.lower():
541
+ sent = await self._api_call(method, {**params, "text": to_plain(body)})
542
+ return sent
543
+
544
+ async def _send_message(
545
+ self,
546
+ chat_id: int,
547
+ text: str,
548
+ *,
549
+ thread_id: int | None = None,
550
+ sid: str | None = None,
551
+ require_complete: bool = False,
552
+ reply_markup: dict[str, Any] | None = None,
553
+ ) -> list[int]:
554
+ """Send markdown ``text`` as Telegram HTML (chunked, topic-aware).
555
+
556
+ Returns the ids of the messages sent, so a caller can delete them.
557
+ ``require_complete`` retracts delivered chunks if any remaining chunk fails.
558
+ """
559
+ ids: list[int] = []
560
+ delivered_all = False
561
+ try:
562
+ chunks = await asyncio.to_thread(render_markdown, text)
563
+ for index, chunk in enumerate(chunks):
564
+ params = self._thread_params({"chat_id": chat_id}, thread_id)
565
+ if reply_markup is not None and index == len(chunks) - 1:
566
+ params["reply_markup"] = reply_markup
567
+ sent = await self._send_html("sendMessage", params, chunk)
568
+ self._remember(sent, sid)
569
+ mid = (sent or {}).get("result", {}).get("message_id")
570
+ if mid is not None:
571
+ ids.append(mid)
572
+ elif require_complete:
573
+ error_message = "Telegram could not deliver the complete approval prompt"
574
+ raise RuntimeError(error_message)
575
+ delivered_all = True
576
+ finally:
577
+ if require_complete and not delivered_all and ids:
578
+ await self._delete_messages(chat_id, ids)
579
+ return ids
580
+
581
+ async def _send_photo(
582
+ self,
583
+ chat_id: int,
584
+ path: Path,
585
+ *,
586
+ thread_id: int | None,
587
+ sid: str | None,
588
+ ) -> None:
589
+ """Send one already validated, Telegram-sized image as a native photo."""
590
+ from novacode_cli.remote.images import prepare_telegram_photo
591
+
592
+ photo = await asyncio.to_thread(prepare_telegram_photo, path)
593
+ payload = self._thread_params(
594
+ {"chat_id": chat_id, "caption": path.name[:200]}, thread_id
595
+ )
596
+ sent = await self._api_call("sendPhoto", payload, photo=photo)
597
+ if sent is None:
598
+ logger.warning("Telegram could not send image preview for %s", path.name)
599
+ self._remember(sent, sid)
600
+
601
+ async def _send_image_previews(
602
+ self,
603
+ chat_id: int,
604
+ markdown: str,
605
+ workspace_root: str | None,
606
+ *,
607
+ thread_id: int | None,
608
+ sid: str | None,
609
+ ) -> None:
610
+ """Send Markdown images that resolve inside the active workspace."""
611
+ if not workspace_root:
612
+ return
613
+ root = Path(workspace_root).expanduser().resolve()
614
+ sources = (await asyncio.to_thread(image_sources, markdown))[:5]
615
+ for source in sources:
616
+ windows_absolute = bool(re.match(r"^[A-Za-z]:[\\/]", source))
617
+ parsed = urlsplit(source)
618
+ if (parsed.scheme and not windows_absolute) or parsed.netloc:
619
+ continue
620
+ raw_path = unquote(source if windows_absolute else parsed.path).strip()
621
+ if not raw_path:
622
+ continue
623
+ candidate = Path(raw_path)
624
+ # Agent virtual paths such as /diagram.png map to the workspace root.
625
+ if raw_path.startswith(("/", "\\")) and not candidate.is_relative_to(root):
626
+ candidate = root / raw_path.lstrip("/\\")
627
+ elif not candidate.is_absolute():
628
+ candidate = root / candidate
629
+ try:
630
+ resolved = candidate.resolve(strict=True)
631
+ resolved.relative_to(root)
632
+ except (OSError, ValueError):
633
+ continue
634
+ from novacode_cli.image_utils import is_image_file
635
+
636
+ if not resolved.is_file() or not is_image_file(resolved):
637
+ continue
638
+ try:
639
+ await self._send_photo(
640
+ chat_id, resolved, thread_id=thread_id, sid=sid
641
+ )
642
+ except Exception:
643
+ logger.warning(
644
+ "Telegram image preview skipped for %s", resolved.name, exc_info=True
645
+ )
646
+
647
+ async def _delete_messages(self, chat_id: int, message_ids: list[int]) -> None:
648
+ """Delete the bot's own messages. Best-effort: Telegram refuses after 48 h."""
649
+ for mid in message_ids:
650
+ try:
651
+ await self._api_call("deleteMessage", {"chat_id": chat_id, "message_id": mid})
652
+ except Exception as e: # noqa: BLE001 — a leftover message is not worth a crash
653
+ logger.debug(f"Telegram deleteMessage failed for {mid}: {e}")
654
+ self._owner.pop(mid, None)
655
+
656
+ def _approval_check(
657
+ self, chat_id: int, thread_id: int | None = None,
658
+ ) -> Callable[[str], bool]:
659
+ """A private-chat peer owns the chat; shared-chat approvals require current grants."""
660
+
661
+ def authorized(sender_id: str) -> bool:
662
+ if not self.is_connected:
663
+ return False
664
+ if chat_id != self._config.chat_id and chat_id not in self._config.allowed_ids:
665
+ return False
666
+ if thread_id is not None and (
667
+ thread_id not in self._topic_handlers
668
+ and all(tid != thread_id for _, tid in self._session_topics.values())
669
+ ):
670
+ return False
671
+ if chat_id > 0:
672
+ return str(sender_id) == str(chat_id)
673
+ return str(sender_id) in {str(user) for user in self._config.allowed_user_ids}
674
+
675
+ return authorized
676
+
677
+ def _asker(
678
+ self, chat_id: int, route: dict[str, Any],
679
+ *, reply_markup: dict[str, Any] | None = None,
680
+ ) -> Callable[[str], Awaitable[Callable[[], Awaitable[None]]]]:
681
+ """Deliver a retractable prompt, surfacing connection and zero-delivery failures."""
682
+
683
+ async def ask(text: str) -> Callable[[], Awaitable[None]]:
684
+ if not self.is_connected:
685
+ message = "Telegram bridge is disconnected"
686
+ raise RuntimeError(message)
687
+ ids = await self._send_message(
688
+ chat_id, text, thread_id=route.get("thread"), sid=route.get("sid"),
689
+ require_complete=True, reply_markup=reply_markup,
690
+ )
691
+ if not ids:
692
+ message = "Telegram could not deliver the approval prompt"
693
+ raise RuntimeError(message)
694
+
695
+ async def retract() -> None:
696
+ await self._delete_messages(chat_id, ids)
697
+
698
+ return retract
699
+
700
+ return ask
701
+
702
+ def _approval_asker(
703
+ self,
704
+ chat_id: int,
705
+ route: dict[str, Any],
706
+ ) -> Callable[[str, str], Awaitable[Callable[[], Awaitable[None]]]]:
707
+ """Show per-request inline buttons after delivering the complete details."""
708
+
709
+ async def ask(text: str, nonce: str) -> Callable[[], Awaitable[None]]:
710
+ markup = {
711
+ "inline_keyboard": [
712
+ [
713
+ {"text": "Approve", "callback_data": f"nova:approve:{nonce}"},
714
+ {"text": "Reject", "callback_data": f"nova:reject:{nonce}"},
715
+ ]
716
+ ],
717
+ }
718
+ return await self._asker(chat_id, route, reply_markup=markup)(text)
719
+
720
+ return ask
721
+
722
+ async def _approval_callback(self, callback: dict[str, Any]) -> dict[str, Any] | None:
723
+ """Convert a button press to the same scoped response as a typed command."""
724
+ callback_id = callback.get("id")
725
+ if callback_id:
726
+ await self._api_call("answerCallbackQuery", {"callback_query_id": callback_id})
727
+ data = callback.get("data")
728
+ match = (
729
+ re.fullmatch(r"nova:(approve|reject):([a-f0-9]{32})", data)
730
+ if isinstance(data, str)
731
+ else None
732
+ )
733
+ message = callback.get("message")
734
+ if match is None or not isinstance(message, dict):
735
+ return None
736
+ # Telegram's message author is the bot. Authorization must use the human
737
+ # who pressed the button, never the message's author or original requester.
738
+ return {
739
+ **message,
740
+ "from": callback.get("from") or {},
741
+ "text": f"{match[1]} {match[2]}",
742
+ "reply_to_message": message,
743
+ }
744
+
745
+ def _editor(self, chat_id: int, route: dict):
746
+ from novacode_cli.remote.streaming import PagedEditor
747
+
748
+ async def send(body):
749
+ sent = await self._send_html(
750
+ "sendMessage", self._thread_params({"chat_id": chat_id}, route.get("thread")), body
751
+ )
752
+ self._remember(sent, route.get("sid"))
753
+ mid = (sent or {}).get("result", {}).get("message_id")
754
+ if mid is None:
755
+ raise RuntimeError("Telegram could not send the streamed message")
756
+ return mid
757
+
758
+ async def edit(mid, body):
759
+ sent = await self._send_html(
760
+ "editMessageText", {"chat_id": chat_id, "message_id": mid}, body
761
+ )
762
+ if sent is None:
763
+ raise RuntimeError("Telegram could not edit the streamed message")
764
+
765
+ async def delete(mid):
766
+ await self._delete_messages(chat_id, [mid])
767
+
768
+ return PagedEditor(render_markdown, send, edit, delete)
769
+
770
+ async def stream_target(self, *, sid: str = "root") -> RemoteMessage | None:
771
+ if not self.is_connected:
772
+ return None
773
+ topic = self._session_topics.get(sid)
774
+ thread = topic[1] if topic else None
775
+ route = {"sid": sid, "thread": thread}
776
+ chat_id = int(self._config.chat_id)
777
+
778
+ async def reply(text):
779
+ if self.is_connected:
780
+ await self._send_message(chat_id, text, sid=sid, thread_id=thread)
781
+
782
+ from novacode_cli.remote.streaming import connected_editor
783
+
784
+ return RemoteMessage(
785
+ platform=RemotePlatform.TELEGRAM,
786
+ chat_id=chat_id,
787
+ user_name="Nova",
788
+ text="",
789
+ reply_fn=reply,
790
+ edit_fn=connected_editor(self, self._editor(chat_id, route)),
791
+ answer_edit_fn=connected_editor(self, self._editor(chat_id, route)),
792
+ image_preview_fn=lambda markdown, root: self._send_image_previews(
793
+ chat_id, markdown, root, thread_id=thread, sid=sid
794
+ ),
795
+ route=route,
796
+ )
797
+
798
+ async def run(self) -> None:
799
+ """Start the Telegram long-polling loop.
800
+
801
+ This is intended to be run as an ``asyncio.Task``.
802
+ """
803
+ self._running = True
804
+
805
+ # Get bot info to verify token
806
+ me = await self._api_call("getMe", {})
807
+ if me is None:
808
+ logger.error("Telegram bot token is invalid or API is unreachable")
809
+ self._running = False
810
+ if self._session and not self._session.closed:
811
+ await self._session.close()
812
+ return
813
+
814
+ bot_name = me.get("result", {}).get("username", "unknown")
815
+ self.bot_user = bot_name
816
+ logger.info(f"Telegram bridge connected as @{bot_name}")
817
+
818
+ _consecutive_errors = 0
819
+
820
+ try:
821
+ while self._running:
822
+ updates = (
823
+ await self._shared_polling.next_updates()
824
+ if self._shared_polling
825
+ else await self._get_updates()
826
+ )
827
+
828
+ if not updates:
829
+ # No messages (normal) or a network error (also returns []).
830
+ # Back off briefly after repeated failures to avoid spinning.
831
+ if _consecutive_errors > 0:
832
+ await asyncio.sleep(min(2**_consecutive_errors, 30))
833
+ _consecutive_errors += 1
834
+ else:
835
+ _consecutive_errors = 0
836
+
837
+ for update in updates:
838
+ message = (
839
+ await self._approval_callback(update["callback_query"])
840
+ if "callback_query" in update
841
+ else update.get("message")
842
+ )
843
+ if not message:
844
+ continue
845
+
846
+ chat = message.get("chat", {})
847
+ chat_id = chat.get("id")
848
+ from_user = message.get("from", {})
849
+ user_name = from_user.get("username") or from_user.get("first_name", "unknown")
850
+ text = (message.get("text") or message.get("caption") or "").strip()
851
+ # A voice note carries no `text`; it is transcribed below,
852
+ # AFTER the allowlist check, so an unauthorized chat can
853
+ # never make us fetch and decode a file.
854
+ voice = message.get("voice") or {}
855
+ photos = message.get("photo") or []
856
+ document = message.get("document") or {}
857
+ image_file = (
858
+ document
859
+ if str(document.get("mime_type", "")).startswith("image/")
860
+ else None
861
+ )
862
+ photo = (
863
+ max(photos, key=lambda p: p.get("width", 0) * p.get("height", 0))
864
+ if photos
865
+ else None
866
+ )
867
+ attachment = photo or image_file
868
+
869
+ if chat_id is None or (not text and not voice and not attachment):
870
+ continue
871
+
872
+ # Only process messages from the allowlisted chat.
873
+ # When in a forum topic, also accept messages from the topic thread.
874
+ if chat_id != self._config.chat_id and chat_id not in self._config.allowed_ids:
875
+ continue
876
+ sender = message.get("from") or {}
877
+ if sender.get("is_bot"):
878
+ continue
879
+ if message.get("chat", {}).get("type") in {
880
+ "group",
881
+ "supergroup",
882
+ "channel",
883
+ } and str(sender.get("id")) not in {
884
+ str(i) for i in self._config.allowed_user_ids
885
+ }:
886
+ if self._on_status and time.monotonic() - self._last_sender_warning > 10:
887
+ self._last_sender_warning = time.monotonic()
888
+ await self._on_status(
889
+ f"Telegram group message blocked: user {sender.get('id')} is not authorized. "
890
+ f"In Nova, run /remote allow-user telegram {sender.get('id')} to allow this user."
891
+ )
892
+ continue
893
+
894
+ # Registered topics remain identifiable when Telegram omits
895
+ # is_topic_message; ordinary reply chains aren't topics.
896
+ thread_id = (
897
+ message.get("message_thread_id")
898
+ if message.get("is_topic_message")
899
+ or any(
900
+ tid == message.get("message_thread_id")
901
+ for _, tid in self._session_topics.values()
902
+ )
903
+ or message.get("message_thread_id") in self._topic_handlers
904
+ else None
905
+ )
906
+ reply_to = message.get("reply_to_message") or {}
907
+ reply_owner = self._owner.get(reply_to.get("message_id"))
908
+
909
+ images = []
910
+ if attachment:
911
+ image = await self._receive_image(chat_id, attachment, thread_id)
912
+ if image is None:
913
+ continue
914
+ images.append(image)
915
+ if not text:
916
+ text = "Please describe this image."
917
+ elif not text:
918
+ text = await self._transcribe_voice(chat_id, voice, thread_id)
919
+ if not text:
920
+ continue # nothing usable; the sender was told why
921
+
922
+ logger.info(
923
+ f"Telegram message from {user_name}: "
924
+ f"{text[:80]}{'...' if len(text) > 80 else ''}"
925
+ )
926
+
927
+ # The router fills ``route`` before anything is sent back:
928
+ # the session that answers ("sid") and where ("thread").
929
+ route: dict = {"sid": None, "thread": thread_id}
930
+
931
+ async def reply_fn(
932
+ response_text: str, _chat_id: int = chat_id, _route: dict = route
933
+ ) -> None:
934
+ await self._send_message(
935
+ _chat_id, response_text, thread_id=_route["thread"], sid=_route["sid"]
936
+ )
937
+
938
+ async def image_preview_fn(
939
+ markdown: str,
940
+ workspace_root: str | None,
941
+ _chat_id: int = chat_id,
942
+ _route: dict = route,
943
+ ) -> None:
944
+ await self._send_image_previews(
945
+ _chat_id,
946
+ markdown,
947
+ workspace_root,
948
+ thread_id=_route["thread"],
949
+ sid=_route["sid"],
950
+ )
951
+
952
+ async def typing_fn(_chat_id: int = chat_id, _route: dict = route) -> None:
953
+ await self._trigger_typing(_chat_id, _route["thread"])
954
+
955
+ edit_fn = self._editor(chat_id, route)
956
+ answer_edit_fn = self._editor(chat_id, route)
957
+
958
+ tg_username = from_user.get("username")
959
+ user_mention = f"@{tg_username}" if tg_username else None
960
+
961
+ remote_msg = RemoteMessage(
962
+ platform=RemotePlatform.TELEGRAM,
963
+ chat_id=chat_id,
964
+ user_name=user_name,
965
+ text=text,
966
+ reply_fn=reply_fn,
967
+ typing_fn=typing_fn,
968
+ edit_fn=edit_fn,
969
+ answer_edit_fn=answer_edit_fn,
970
+ image_preview_fn=image_preview_fn,
971
+ ask_fn=self._asker(chat_id, route),
972
+ approval_fn=self._approval_asker(chat_id, route),
973
+ approval_check=self._approval_check(chat_id, thread_id),
974
+ user_mention=user_mention,
975
+ thread_id=thread_id,
976
+ reply_to_owner=reply_owner,
977
+ route=route,
978
+ sender_id=str(from_user.get("id"))
979
+ if from_user.get("id") is not None
980
+ else None,
981
+ message_id=message.get("message_id"),
982
+ images=images,
983
+ )
984
+
985
+ if not await self._dispatch_topic_message(remote_msg):
986
+ await self._queue.put(remote_msg)
987
+
988
+ except asyncio.CancelledError:
989
+ logger.info("Telegram bridge cancelled, shutting down")
990
+ except Exception as e: # noqa: BLE001
991
+ logger.error(f"Telegram bridge error: {e}")
992
+ finally:
993
+ self._running = False
994
+ self.bot_user = None
995
+ if self._shared_polling:
996
+ await self._shared_polling.close()
997
+ if self._session and not self._session.closed:
998
+ await self._session.close()
999
+ logger.info("Telegram bridge stopped")
1000
+
1001
+ async def _receive_image(self, chat_id: int, attachment: dict, thread_id: int | None):
1002
+ """Fetch images only after chat and sender authorization."""
1003
+ from novacode_cli.image_utils import MAX_IMAGE_SIZE_BYTES
1004
+ from novacode_cli.remote.images import decode_image
1005
+
1006
+ try:
1007
+ if attachment.get("file_size", 0) > MAX_IMAGE_SIZE_BYTES:
1008
+ raise ValueError("Images must be no larger than 20 MB.")
1009
+ file_id = attachment.get("file_id")
1010
+ if not file_id:
1011
+ raise ValueError("Telegram did not provide an image file ID. Please resend it.")
1012
+ data = await self._download_file(file_id, max_bytes=MAX_IMAGE_SIZE_BYTES)
1013
+ if data is None:
1014
+ raise ValueError("Could not download the image. Please resend it.")
1015
+ return await asyncio.to_thread(decode_image, data)
1016
+ except ValueError as exc:
1017
+ await self._send_message(chat_id, f"Image not sent to Nova: {exc}", thread_id=thread_id)
1018
+ except Exception: # noqa: BLE001 — one bad attachment must not stop polling
1019
+ logger.warning("Could not receive Telegram image", exc_info=False)
1020
+ await self._send_message(
1021
+ chat_id, "Image not sent to Nova. Please resend it.", thread_id=thread_id
1022
+ )
1023
+ return None
1024
+
1025
+ async def _download_file(self, file_id: str, *, max_bytes: int | None = None) -> bytes | None:
1026
+ """Fetch a Telegram file's bytes by ``file_id`` (two-step: getFile, GET)."""
1027
+ info = await self._api_call("getFile", {"file_id": file_id})
1028
+ file_path = (info or {}).get("result", {}).get("file_path")
1029
+ if not file_path:
1030
+ return None
1031
+ if max_bytes is not None and (info or {}).get("result", {}).get("file_size", 0) > max_bytes:
1032
+ raise ValueError("Images must be no larger than 20 MB.")
1033
+ url = f"{_TELEGRAM_API}/file/bot{self._config.token}/{file_path}"
1034
+ try:
1035
+ session = self._ensure_session()
1036
+ async with session.get(url) as resp:
1037
+ if resp.status != 200: # noqa: PLR2004
1038
+ logger.error("Telegram file download failed: HTTP %s", resp.status)
1039
+ return None
1040
+ if max_bytes is None:
1041
+ return await resp.read()
1042
+ data = bytearray()
1043
+ async for chunk in resp.content.iter_chunked(64 * 1024):
1044
+ data.extend(chunk)
1045
+ if len(data) > max_bytes:
1046
+ raise ValueError("Images must be no larger than 20 MB.")
1047
+ return bytes(data)
1048
+ except ValueError:
1049
+ raise
1050
+ except Exception as e: # noqa: BLE001 — a bad download must not kill the poll loop
1051
+ logger.error("Telegram file download error: %s", type(e).__name__)
1052
+ return None
1053
+
1054
+ async def _transcribe_voice(
1055
+ self, chat_id: int, voice: dict[str, Any], thread_id: int | None = None
1056
+ ) -> str:
1057
+ """Transcribe a voice note, or explain to the sender why it could not be.
1058
+
1059
+ Every failure path replies. The sender is on a phone, not at the
1060
+ terminal: a voice note that vanishes silently is indistinguishable from
1061
+ a dead bot, so "I could not hear that" is itself the feature.
1062
+ """
1063
+ from novacode_cli.remote.voice_notes import (
1064
+ VoiceNotesUnavailable,
1065
+ VoiceNoteTooLong,
1066
+ transcribe_voice_note,
1067
+ )
1068
+
1069
+ file_id = voice.get("file_id")
1070
+ if not file_id:
1071
+ return ""
1072
+
1073
+ # Transcribing is slow enough to look like a hang; show activity first.
1074
+ await self._trigger_typing(chat_id)
1075
+
1076
+ data = await self._download_file(file_id)
1077
+ if not data:
1078
+ await self._send_message(
1079
+ chat_id, thread_id=thread_id, text="🎙 Could not download that voice note."
1080
+ )
1081
+ return ""
1082
+
1083
+ try:
1084
+ text = await transcribe_voice_note(data, duration=voice.get("duration"))
1085
+ except (VoiceNotesUnavailable, VoiceNoteTooLong) as e:
1086
+ await self._send_message(chat_id, thread_id=thread_id, text=f"🎙 {e}")
1087
+ return ""
1088
+ except Exception:
1089
+ # Never kill the poll loop: one bad clip must not stop every later
1090
+ # message from being answered.
1091
+ logger.exception("Voice note transcription failed")
1092
+ await self._send_message(
1093
+ chat_id, thread_id=thread_id, text="🎙 Could not transcribe that voice note."
1094
+ )
1095
+ return ""
1096
+
1097
+ if not text:
1098
+ await self._send_message(
1099
+ chat_id, "🎙 I could not make out any speech in that note.", thread_id=thread_id
1100
+ )
1101
+ return ""
1102
+
1103
+ # Echo what was heard before acting on it. Transcription is fallible,
1104
+ # and a wrong transcript that silently becomes a prompt is worse than a
1105
+ # visible one the sender can correct.
1106
+ await self._send_message(chat_id, thread_id=thread_id, text=f"🎙 “{text}”")
1107
+ return text
1108
+
1109
+ async def _trigger_typing(self, chat_id: int, thread_id: int | None = None) -> None:
1110
+ """Send a 'typing' chat action to Telegram."""
1111
+ await self._api_call(
1112
+ "sendChatAction",
1113
+ self._thread_params({"chat_id": chat_id, "action": "typing"}, thread_id),
1114
+ )
1115
+
1116
+ async def stop(self) -> None:
1117
+ """Gracefully stop the Telegram bridge."""
1118
+ self._running = False