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.
- novacode_cli/__init__.py +29 -0
- novacode_cli/__main__.py +6 -0
- novacode_cli/_lazy_heavy.py +99 -0
- novacode_cli/_version.py +3 -0
- novacode_cli/agent_stream.py +178 -0
- novacode_cli/agents/agent_file.py +250 -0
- novacode_cli/agents/agent_mailbox.py +120 -0
- novacode_cli/agents/approval_decisions.py +146 -0
- novacode_cli/agents/arg_repair.py +196 -0
- novacode_cli/agents/async_agents/__init__.py +6 -0
- novacode_cli/agents/async_agents/_config.py +213 -0
- novacode_cli/agents/async_agents/_mcp_tools.py +192 -0
- novacode_cli/agents/async_agents/_specialist.py +84 -0
- novacode_cli/agents/async_agents/_test_commands.py +89 -0
- novacode_cli/agents/async_agents/_tools.py +432 -0
- novacode_cli/agents/async_agents/general_purpose.py +42 -0
- novacode_cli/agents/async_agents/langgraph.json +9 -0
- novacode_cli/agents/async_agents/research.py +22 -0
- novacode_cli/agents/async_context.py +303 -0
- novacode_cli/agents/async_on_demand.py +52 -0
- novacode_cli/agents/async_task_errors.py +75 -0
- novacode_cli/agents/async_workspace.py +27 -0
- novacode_cli/agents/attribution.py +165 -0
- novacode_cli/agents/browser_mode.py +119 -0
- novacode_cli/agents/btw_agent.py +71 -0
- novacode_cli/agents/caveman.py +352 -0
- novacode_cli/agents/caveman_support.py +370 -0
- novacode_cli/agents/code_mode.py +774 -0
- novacode_cli/agents/code_runtime.py +160 -0
- novacode_cli/agents/context_compaction.py +1339 -0
- novacode_cli/agents/core_agent.py +2710 -0
- novacode_cli/agents/default_subagents/__init__.py +24 -0
- novacode_cli/agents/default_subagents/async_subagents.py +296 -0
- novacode_cli/agents/default_subagents/builtin/browser-automation-agent.md +110 -0
- novacode_cli/agents/default_subagents/builtin/bug-fix-agent.md +22 -0
- novacode_cli/agents/default_subagents/builtin/bug-investigation-agent.md +42 -0
- novacode_cli/agents/default_subagents/builtin/code-explorer.md +37 -0
- novacode_cli/agents/default_subagents/builtin/code-review-agent.md +33 -0
- novacode_cli/agents/default_subagents/builtin/dependency-audit-agent.md +34 -0
- novacode_cli/agents/default_subagents/builtin/documentation-update-agent.md +46 -0
- novacode_cli/agents/default_subagents/builtin/performance-audit-agent.md +41 -0
- novacode_cli/agents/default_subagents/builtin/plan-scout-agent.md +64 -0
- novacode_cli/agents/default_subagents/builtin/refactoring-agent.md +35 -0
- novacode_cli/agents/default_subagents/builtin/refactoring-specialist-agent.md +175 -0
- novacode_cli/agents/default_subagents/builtin/security-audit-agent.md +96 -0
- novacode_cli/agents/default_subagents/builtin/test-generation-agent.md +33 -0
- novacode_cli/agents/default_subagents/builtin/test-runner-agent.md +55 -0
- novacode_cli/agents/default_subagents/prompt.py +88 -0
- novacode_cli/agents/default_subagents/subagents.py +138 -0
- novacode_cli/agents/dream_agent.py +93 -0
- novacode_cli/agents/edit_diagnostics.py +362 -0
- novacode_cli/agents/edit_fallback.py +62 -0
- novacode_cli/agents/handoff.py +166 -0
- novacode_cli/agents/invocation.py +152 -0
- novacode_cli/agents/model_budget.py +42 -0
- novacode_cli/agents/model_retry.py +113 -0
- novacode_cli/agents/model_router.py +420 -0
- novacode_cli/agents/openai_decisions.py +186 -0
- novacode_cli/agents/plan_agent/__init__.py +12 -0
- novacode_cli/agents/plan_agent/plan_agent.py +357 -0
- novacode_cli/agents/plan_agent/plan_mode_middleware.py +64 -0
- novacode_cli/agents/plan_execution.py +237 -0
- novacode_cli/agents/ponytail.py +66 -0
- novacode_cli/agents/prime.py +274 -0
- novacode_cli/agents/provider_caching.py +99 -0
- novacode_cli/agents/server_launcher.py +904 -0
- novacode_cli/agents/strict_tools.py +384 -0
- novacode_cli/agents/task_discipline.py +294 -0
- novacode_cli/agents/tool_call_repair.py +290 -0
- novacode_cli/agents/tool_offload.py +291 -0
- novacode_cli/agents/tool_search.py +806 -0
- novacode_cli/agents/tool_search_variants.py +106 -0
- novacode_cli/agents/tool_verdicts.py +1194 -0
- novacode_cli/agents/user_async_agents.py +312 -0
- novacode_cli/artifacts/__init__.py +10 -0
- novacode_cli/artifacts/registry.py +278 -0
- novacode_cli/artifacts/server.py +426 -0
- novacode_cli/assets/Nova.png +0 -0
- novacode_cli/assets/nova-avatar.png +0 -0
- novacode_cli/audio/__init__.py +301 -0
- novacode_cli/audio/capture.py +137 -0
- novacode_cli/audio/downloads.py +273 -0
- novacode_cli/audio/pipeline.py +391 -0
- novacode_cli/audio/playback.py +87 -0
- novacode_cli/audio/providers.py +208 -0
- novacode_cli/audio/speakable.py +78 -0
- novacode_cli/audio/stt.py +132 -0
- novacode_cli/audio/stt_deepgram.py +69 -0
- novacode_cli/audio/stt_elevenlabs.py +89 -0
- novacode_cli/audio/stt_parakeet.py +94 -0
- novacode_cli/audio/stt_whistle.py +61 -0
- novacode_cli/audio/summarize.py +92 -0
- novacode_cli/audio/tts.py +85 -0
- novacode_cli/audio/tts_kokoro.py +73 -0
- novacode_cli/audio/tts_orpheus.py +180 -0
- novacode_cli/audio/tts_pocket.py +131 -0
- novacode_cli/audio/tts_sano.py +69 -0
- novacode_cli/audio/vad.py +145 -0
- novacode_cli/backends/__init__.py +17 -0
- novacode_cli/backends/filesystem.py +894 -0
- novacode_cli/backends/native_search.py +92 -0
- novacode_cli/bootstrap/__init__.py +9 -0
- novacode_cli/bootstrap/env_snapshot.py +240 -0
- novacode_cli/bootstrap/steering.py +464 -0
- novacode_cli/bootstrap/vision_router.py +604 -0
- novacode_cli/brand.py +362 -0
- novacode_cli/cli_args.py +718 -0
- novacode_cli/commands/__init__.py +237 -0
- novacode_cli/commands/agents_commands.py +231 -0
- novacode_cli/commands/auth_handler.py +40 -0
- novacode_cli/commands/browser_use_handler.py +75 -0
- novacode_cli/commands/cache_handler.py +32 -0
- novacode_cli/commands/chat_handler.py +1357 -0
- novacode_cli/commands/ci_fix.py +171 -0
- novacode_cli/commands/claude_plan.py +331 -0
- novacode_cli/commands/codex_plan.py +195 -0
- novacode_cli/commands/commands.py +1113 -0
- novacode_cli/commands/create_handler.py +124 -0
- novacode_cli/commands/create_server.py +1887 -0
- novacode_cli/commands/cron_handler.py +119 -0
- novacode_cli/commands/dream_handler.py +203 -0
- novacode_cli/commands/effort_handler.py +65 -0
- novacode_cli/commands/evolution_handler.py +214 -0
- novacode_cli/commands/file_commands.py +381 -0
- novacode_cli/commands/handoff_handler.py +68 -0
- novacode_cli/commands/hooks_handler.py +269 -0
- novacode_cli/commands/init_handler.py +146 -0
- novacode_cli/commands/learning_handler.py +160 -0
- novacode_cli/commands/lite_mode_handler.py +76 -0
- novacode_cli/commands/log_commands.py +363 -0
- novacode_cli/commands/mcp_handler.py +29 -0
- novacode_cli/commands/model_handler.py +29 -0
- novacode_cli/commands/notifications_handler.py +158 -0
- novacode_cli/commands/onboard_handler.py +121 -0
- novacode_cli/commands/plan_handler.py +440 -0
- novacode_cli/commands/planner_usage.py +250 -0
- novacode_cli/commands/plugin_install_handler.py +38 -0
- novacode_cli/commands/plugins_handler.py +35 -0
- novacode_cli/commands/prime_handler.py +455 -0
- novacode_cli/commands/prompt_handler.py +149 -0
- novacode_cli/commands/provider_usage.py +160 -0
- novacode_cli/commands/ralph_events.py +106 -0
- novacode_cli/commands/ralph_handler.py +1293 -0
- novacode_cli/commands/refine_handler.py +213 -0
- novacode_cli/commands/research_handler.py +202 -0
- novacode_cli/commands/rootapp_remote.py +83 -0
- novacode_cli/commands/router_handler.py +36 -0
- novacode_cli/commands/server_commands.py +249 -0
- novacode_cli/commands/session_commands.py +203 -0
- novacode_cli/commands/share_handler.py +450 -0
- novacode_cli/commands/side_commands.py +366 -0
- novacode_cli/commands/skill_invoke.py +420 -0
- novacode_cli/commands/skills_commands.py +48 -0
- novacode_cli/commands/trace_handler.py +322 -0
- novacode_cli/commands/trello_handler.py +226 -0
- novacode_cli/commands/trello_remote.py +177 -0
- novacode_cli/commands/trello_server.py +1214 -0
- novacode_cli/commands/vision_handler.py +127 -0
- novacode_cli/commands/voice_handler.py +447 -0
- novacode_cli/commands/webhook_handler.py +134 -0
- novacode_cli/commands/wiki_commands.py +227 -0
- novacode_cli/compaction.py +753 -0
- novacode_cli/computation_cache.py +159 -0
- novacode_cli/computer/__init__.py +1 -0
- novacode_cli/computer/persistent.py +234 -0
- novacode_cli/computer/preview.py +253 -0
- novacode_cli/computer/runtime.py +474 -0
- novacode_cli/computer/windows_console.py +152 -0
- novacode_cli/computer/windows_qemu.py +211 -0
- novacode_cli/config/browser_mode.py +159 -0
- novacode_cli/config/catalog_models.py +95 -0
- novacode_cli/config/config.py +1388 -0
- novacode_cli/config/credentials.py +540 -0
- novacode_cli/config/google_oauth_auth.py +281 -0
- novacode_cli/config/model_capabilities.py +343 -0
- novacode_cli/config/model_catalog.py +275 -0
- novacode_cli/config/model_create.py +1325 -0
- novacode_cli/config/model_manager.py +695 -0
- novacode_cli/config/nova_config.py +1283 -0
- novacode_cli/config/openai_chatgpt_auth.py +384 -0
- novacode_cli/config/opencode_gateway.py +25 -0
- novacode_cli/config/plan_mode.py +188 -0
- novacode_cli/config/provider_auth.py +438 -0
- novacode_cli/config/provider_catalog.json +5127 -0
- novacode_cli/config/provider_registry.py +38 -0
- novacode_cli/config/provider_transports.py +258 -0
- novacode_cli/config/role_models.py +175 -0
- novacode_cli/context/__init__.py +50 -0
- novacode_cli/context/_analysis.py +799 -0
- novacode_cli/context/_budget.py +116 -0
- novacode_cli/context/_conversation.py +73 -0
- novacode_cli/context/_dynamic.py +264 -0
- novacode_cli/context/_model_config.py +380 -0
- novacode_cli/context/_models_dev.py +99 -0
- novacode_cli/context/archives.py +248 -0
- novacode_cli/context/estimates.py +65 -0
- novacode_cli/context/history.py +17 -0
- novacode_cli/context/knowledge_cache.py +292 -0
- novacode_cli/context/manager.py +101 -0
- novacode_cli/context/observations.py +65 -0
- novacode_cli/context/pressure.py +168 -0
- novacode_cli/context/provider_accounting.py +109 -0
- novacode_cli/context/snapshot.py +493 -0
- novacode_cli/context/strategies.py +279 -0
- novacode_cli/context/task_progress.py +122 -0
- novacode_cli/core/__init__.py +0 -0
- novacode_cli/core/agent_loop.py +1821 -0
- novacode_cli/core/autonomous_loop.py +277 -0
- novacode_cli/core/code_approvals.py +57 -0
- novacode_cli/core/input_preparation.py +276 -0
- novacode_cli/core/reasoning.py +67 -0
- novacode_cli/core/streaming.py +326 -0
- novacode_cli/core/subagent_tasks.py +696 -0
- novacode_cli/core/subagent_tracking.py +235 -0
- novacode_cli/core/tool_results.py +116 -0
- novacode_cli/core/verification_loop.py +272 -0
- novacode_cli/council.py +595 -0
- novacode_cli/council_consensus.py +115 -0
- novacode_cli/council_decision.py +157 -0
- novacode_cli/council_planner.py +1421 -0
- novacode_cli/council_settings.py +208 -0
- novacode_cli/council_web_settings.py +41 -0
- novacode_cli/council_workspace.py +56 -0
- novacode_cli/cowork/__init__.py +10 -0
- novacode_cli/cowork/broker_middleware.py +162 -0
- novacode_cli/cowork/launcher.py +102 -0
- novacode_cli/cowork/policy.py +284 -0
- novacode_cli/cowork/ui.py +826 -0
- novacode_cli/daemons/__init__.py +1 -0
- novacode_cli/daemons/registry.py +342 -0
- novacode_cli/desktop_notifications.py +302 -0
- novacode_cli/doctor.py +251 -0
- novacode_cli/embeddings.py +109 -0
- novacode_cli/entrypoint.py +56 -0
- novacode_cli/errors/__init__.py +19 -0
- novacode_cli/errors/handlers.py +648 -0
- novacode_cli/errors/provider_errors.py +249 -0
- novacode_cli/errors/taxonomy.py +80 -0
- novacode_cli/events.py +179 -0
- novacode_cli/file_ops.py +714 -0
- novacode_cli/git_safety.py +281 -0
- novacode_cli/handoff.py +383 -0
- novacode_cli/headless/__init__.py +18 -0
- novacode_cli/headless/input.py +58 -0
- novacode_cli/headless/output.py +224 -0
- novacode_cli/headless/pipe.py +532 -0
- novacode_cli/headless/runner.py +461 -0
- novacode_cli/hermes/__init__.py +39 -0
- novacode_cli/hermes/activation.py +297 -0
- novacode_cli/hermes/config.py +129 -0
- novacode_cli/hermes/curator.py +199 -0
- novacode_cli/hermes/evolution.py +374 -0
- novacode_cli/hermes/ledger.py +364 -0
- novacode_cli/hermes/memory_tiers.py +969 -0
- novacode_cli/hermes/middleware.py +678 -0
- novacode_cli/hermes/overview.py +174 -0
- novacode_cli/hermes/prompt_evolution.py +503 -0
- novacode_cli/hermes/refine_loop.py +643 -0
- novacode_cli/hermes/refinement_log.py +190 -0
- novacode_cli/hermes/review.py +674 -0
- novacode_cli/hermes/skill_debate.py +135 -0
- novacode_cli/hermes/skill_discovery.py +450 -0
- novacode_cli/hermes/skill_manager.py +335 -0
- novacode_cli/hermes/skill_proposals.py +43 -0
- novacode_cli/hermes/test_gaming.py +119 -0
- novacode_cli/hermes/tracker.py +435 -0
- novacode_cli/hermes/tuner.py +206 -0
- novacode_cli/hermes/verification_memory.py +134 -0
- novacode_cli/hermes/verifier.py +277 -0
- novacode_cli/hitl/__init__.py +22 -0
- novacode_cli/hitl/interrupts.py +493 -0
- novacode_cli/hooks.py +359 -0
- novacode_cli/image_utils.py +517 -0
- novacode_cli/init/__init__.py +7 -0
- novacode_cli/init/events.py +43 -0
- novacode_cli/input_utils.py +526 -0
- novacode_cli/install_commands.py +87 -0
- novacode_cli/integrations/__init__.py +82 -0
- novacode_cli/integrations/daytona.py +122 -0
- novacode_cli/integrations/docker.py +309 -0
- novacode_cli/integrations/host_path.py +111 -0
- novacode_cli/integrations/langsmith.py +151 -0
- novacode_cli/integrations/modal.py +134 -0
- novacode_cli/integrations/runloop.py +130 -0
- novacode_cli/integrations/sandbox_factory.py +1388 -0
- novacode_cli/integrations/sandbox_registry.py +312 -0
- novacode_cli/integrations/workdir_backend.py +589 -0
- novacode_cli/main.py +2078 -0
- novacode_cli/mcp/__init__.py +73 -0
- novacode_cli/mcp/auth.py +189 -0
- novacode_cli/mcp/client.py +571 -0
- novacode_cli/mcp/code_mode.py +5 -0
- novacode_cli/mcp/code_runtime.py +5 -0
- novacode_cli/mcp/commands.py +902 -0
- novacode_cli/mcp/config.py +406 -0
- novacode_cli/mcp/generic.py +128 -0
- novacode_cli/mcp/middleware.py +993 -0
- novacode_cli/mcp/presets.py +367 -0
- novacode_cli/memory/__init__.py +1 -0
- novacode_cli/memory/agent_memory.py +1312 -0
- novacode_cli/memory/builtin_lessons.py +60 -0
- novacode_cli/memory/dream_gates.py +192 -0
- novacode_cli/memory/limits.py +59 -0
- novacode_cli/memory/parsed_cache.py +106 -0
- novacode_cli/memory/recall_stats.py +97 -0
- novacode_cli/memory/semantic.py +116 -0
- novacode_cli/memory/store.py +335 -0
- novacode_cli/memory/wiki.py +274 -0
- novacode_cli/migrate.py +185 -0
- novacode_cli/onboarding.py +649 -0
- novacode_cli/onboarding_check.py +119 -0
- novacode_cli/path_approval.py +297 -0
- novacode_cli/plan_archive.py +148 -0
- novacode_cli/plugins/__init__.py +121 -0
- novacode_cli/plugins/claude_plugins.py +538 -0
- novacode_cli/plugins/cli.py +168 -0
- novacode_cli/plugins/components.py +413 -0
- novacode_cli/plugins/hooks.py +356 -0
- novacode_cli/plugins/loader.py +317 -0
- novacode_cli/plugins/marketplaces.py +199 -0
- novacode_cli/plugins/storage.py +70 -0
- novacode_cli/prime.py +759 -0
- novacode_cli/prime_factory/__init__.py +15 -0
- novacode_cli/prime_factory/container.py +198 -0
- novacode_cli/prime_factory/controller.py +565 -0
- novacode_cli/prime_factory/knowledge.py +261 -0
- novacode_cli/prime_factory/publication.py +218 -0
- novacode_cli/prime_factory/recovery.py +50 -0
- novacode_cli/prime_factory/research.py +370 -0
- novacode_cli/prime_factory/runner.py +608 -0
- novacode_cli/prime_factory/runtime.py +261 -0
- novacode_cli/prime_factory/state.py +201 -0
- novacode_cli/prime_factory/store.py +128 -0
- novacode_cli/prime_factory/worker.py +129 -0
- novacode_cli/prime_factory/workflow.py +899 -0
- novacode_cli/prime_factory/workspace.py +317 -0
- novacode_cli/prime_memory.py +290 -0
- novacode_cli/process_manager.py +585 -0
- novacode_cli/project_onboard.py +537 -0
- novacode_cli/prompts/System_Prompt_Nova.jinja +23 -0
- novacode_cli/prompts/__init__.py +168 -0
- novacode_cli/prompts/agent_definitions.jinja +11 -0
- novacode_cli/prompts/agent_generation.jinja +125 -0
- novacode_cli/prompts/async_agents/general_purpose.jinja +22 -0
- novacode_cli/prompts/claude_plan_mode.jinja +12 -0
- novacode_cli/prompts/coding_workflow.jinja +18 -0
- novacode_cli/prompts/computer_use.jinja +17 -0
- novacode_cli/prompts/core_agent_system.jinja +81 -0
- novacode_cli/prompts/delegation.jinja +15 -0
- novacode_cli/prompts/desktop_power.jinja +4 -0
- novacode_cli/prompts/file_paths.jinja +15 -0
- novacode_cli/prompts/file_tracker.jinja +23 -0
- novacode_cli/prompts/init_agents_md.jinja +56 -0
- novacode_cli/prompts/init_exploration.jinja +174 -0
- novacode_cli/prompts/init_semantic_orchestrate.jinja +37 -0
- novacode_cli/prompts/instruction_scope.jinja +5 -0
- novacode_cli/prompts/learnings_extraction.jinja +45 -0
- novacode_cli/prompts/longterm_memory.jinja +54 -0
- novacode_cli/prompts/mcp.jinja +84 -0
- novacode_cli/prompts/memory_consolidation.jinja +40 -0
- novacode_cli/prompts/nova_evolution.jinja +73 -0
- novacode_cli/prompts/nova_review.jinja +158 -0
- novacode_cli/prompts/nova_skill_debate.jinja +36 -0
- novacode_cli/prompts/nova_standing_instructions.jinja +26 -0
- novacode_cli/prompts/nova_verify.jinja +100 -0
- novacode_cli/prompts/onboard_dashboard.jinja +378 -0
- novacode_cli/prompts/plan_agent.jinja +87 -0
- novacode_cli/prompts/plan_scouts.jinja +5 -0
- novacode_cli/prompts/plan_storage.jinja +8 -0
- novacode_cli/prompts/planning.jinja +82 -0
- novacode_cli/prompts/ponytail/LICENSE +21 -0
- novacode_cli/prompts/ponytail/UPSTREAM.json +15 -0
- novacode_cli/prompts/ponytail/hooks/ponytail-config.js +175 -0
- novacode_cli/prompts/ponytail/hooks/ponytail-instructions.js +98 -0
- novacode_cli/prompts/ponytail/skills/ponytail/SKILL.md +52 -0
- novacode_cli/prompts/ponytail/skills/ponytail-audit/SKILL.md +103 -0
- novacode_cli/prompts/ponytail/skills/ponytail-debt/SKILL.md +44 -0
- novacode_cli/prompts/ponytail/skills/ponytail-gain/SKILL.md +52 -0
- novacode_cli/prompts/ponytail/skills/ponytail-help/SKILL.md +76 -0
- novacode_cli/prompts/ponytail/skills/ponytail-review/SKILL.md +101 -0
- novacode_cli/prompts/prompt_evolution.jinja +27 -0
- novacode_cli/prompts/ralph_iteration.jinja +75 -0
- novacode_cli/prompts/refine_gate.jinja +31 -0
- novacode_cli/prompts/refine_plan.jinja +64 -0
- novacode_cli/prompts/refine_review.jinja +34 -0
- novacode_cli/prompts/research_delivery.jinja +9 -0
- novacode_cli/prompts/research_swarm.jinja +130 -0
- novacode_cli/prompts/response_style.jinja +5 -0
- novacode_cli/prompts/session_summarization.jinja +86 -0
- novacode_cli/prompts/skill_creation.jinja +85 -0
- novacode_cli/prompts/subagents/fact_checker.jinja +48 -0
- novacode_cli/prompts/subagents/financial_analyst.jinja +50 -0
- novacode_cli/prompts/subagents/literature_reviewer.jinja +52 -0
- novacode_cli/prompts/subagents/market_analyst.jinja +48 -0
- novacode_cli/prompts/subagents/research_synthesizer.jinja +57 -0
- novacode_cli/prompts/subagents/technical_researcher.jinja +56 -0
- novacode_cli/prompts/subagents/web_researcher.jinja +46 -0
- novacode_cli/prompts/summarization.jinja +107 -0
- novacode_cli/prompts/summarization_answers.jinja +23 -0
- novacode_cli/prompts/summarization_questions.jinja +32 -0
- novacode_cli/prompts/vision_caption.jinja +21 -0
- novacode_cli/prompts/voice_summary.jinja +65 -0
- novacode_cli/prompts/wiki_file.jinja +57 -0
- novacode_cli/prompts/wiki_ingest.jinja +87 -0
- novacode_cli/recovery.py +383 -0
- novacode_cli/remote/__init__.py +45 -0
- novacode_cli/remote/approvals.py +282 -0
- novacode_cli/remote/async_task_clients.py +97 -0
- novacode_cli/remote/async_task_watcher.py +240 -0
- novacode_cli/remote/bridge.py +905 -0
- novacode_cli/remote/command_menu.py +26 -0
- novacode_cli/remote/command_ui.py +243 -0
- novacode_cli/remote/config.py +199 -0
- novacode_cli/remote/discord_bridge.py +530 -0
- novacode_cli/remote/discord_format.py +31 -0
- novacode_cli/remote/discord_threads.py +225 -0
- novacode_cli/remote/images.py +97 -0
- novacode_cli/remote/processor.py +601 -0
- novacode_cli/remote/rootapp_bot/bot.cjs +16 -0
- novacode_cli/remote/rootapp_bot/package-lock.json +2683 -0
- novacode_cli/remote/rootapp_bot/package.json +18 -0
- novacode_cli/remote/rootapp_bot/transport.cjs +80 -0
- novacode_cli/remote/rootapp_bridge.py +370 -0
- novacode_cli/remote/rootapp_setup.py +89 -0
- novacode_cli/remote/routing.py +103 -0
- novacode_cli/remote/scheduler.py +302 -0
- novacode_cli/remote/session.py +164 -0
- novacode_cli/remote/status.py +411 -0
- novacode_cli/remote/streaming.py +188 -0
- novacode_cli/remote/telegram_bridge.py +1118 -0
- novacode_cli/remote/telegram_format.py +215 -0
- novacode_cli/remote/telegram_hub.py +256 -0
- novacode_cli/remote/voice_notes.py +120 -0
- novacode_cli/remote/webhook_adapters.py +167 -0
- novacode_cli/remote/webhook_server.py +154 -0
- novacode_cli/rewind.py +150 -0
- novacode_cli/rooms/__init__.py +1 -0
- novacode_cli/rooms/bridge.py +86 -0
- novacode_cli/rooms/cli.py +231 -0
- novacode_cli/rooms/nova.py +227 -0
- novacode_cli/rooms/runtime.py +299 -0
- novacode_cli/rooms/store.py +538 -0
- novacode_cli/rooms/workspace.py +94 -0
- novacode_cli/security/__init__.py +29 -0
- novacode_cli/security/approval_context.py +45 -0
- novacode_cli/security/delegated_approval.py +68 -0
- novacode_cli/security/local_http.py +85 -0
- novacode_cli/security/middleware.py +87 -0
- novacode_cli/security/paths.py +35 -0
- novacode_cli/security/policy.py +535 -0
- novacode_cli/security/policy_writer.py +90 -0
- novacode_cli/security/prompt_injection.py +203 -0
- novacode_cli/security/remember.py +81 -0
- novacode_cli/security/rule_synthesis.py +116 -0
- novacode_cli/security/secret_files.py +61 -0
- novacode_cli/security/session_allow.py +114 -0
- novacode_cli/security/unicode_security.py +516 -0
- novacode_cli/security/validator.py +173 -0
- novacode_cli/server/__init__.py +9 -0
- novacode_cli/server/app.py +632 -0
- novacode_cli/server/event_adapter.py +189 -0
- novacode_cli/server/session_manager.py +90 -0
- novacode_cli/server_main.py +64 -0
- novacode_cli/server_runner/dev_server.py +530 -0
- novacode_cli/server_runner/test_runner.py +470 -0
- novacode_cli/session/__init__.py +1 -0
- novacode_cli/session/adapters/__init__.py +240 -0
- novacode_cli/session/adapters/claude_code.py +102 -0
- novacode_cli/session/adapters/codex.py +77 -0
- novacode_cli/session/adapters/harness_native.py +75 -0
- novacode_cli/session/checkpoint_coordinator.py +448 -0
- novacode_cli/session/context_restore.py +25 -0
- novacode_cli/session/history_export.py +228 -0
- novacode_cli/session/history_tree.py +174 -0
- novacode_cli/session/imported_context.py +267 -0
- novacode_cli/session/runtime_identity.py +32 -0
- novacode_cli/session/session_persistence.py +1168 -0
- novacode_cli/session/session_prompt_builder.py +279 -0
- novacode_cli/session/session_restore.py +351 -0
- novacode_cli/session/session_summarization.py +20 -0
- novacode_cli/session/transcript_journal.py +153 -0
- novacode_cli/sessions/__init__.py +11 -0
- novacode_cli/sessions/gateway.py +84 -0
- novacode_cli/sessions/launch.py +51 -0
- novacode_cli/sessions/lease.py +213 -0
- novacode_cli/sessions/messaging.py +255 -0
- novacode_cli/sessions/protocol.py +216 -0
- novacode_cli/sessions/resume.py +31 -0
- novacode_cli/sessions/supervisor.py +498 -0
- novacode_cli/sessions/tasks.py +73 -0
- novacode_cli/sessions/tui_messaging.py +413 -0
- novacode_cli/sessions/worker.py +652 -0
- novacode_cli/sessions/worktree.py +262 -0
- novacode_cli/shell/__init__.py +27 -0
- novacode_cli/shell/jobs.py +475 -0
- novacode_cli/shell/middleware.py +1568 -0
- novacode_cli/shell/os_sandbox.py +205 -0
- novacode_cli/shell/output.py +181 -0
- novacode_cli/shell/patterns.py +275 -0
- novacode_cli/shell/processes.py +87 -0
- novacode_cli/shell/utils.py +236 -0
- novacode_cli/skills/__init__.py +26 -0
- novacode_cli/skills/builtin/handoff/SKILL.md +15 -0
- novacode_cli/skills/curation_middleware.py +69 -0
- novacode_cli/skills/executable.py +161 -0
- novacode_cli/skills/libraries.py +102 -0
- novacode_cli/skills/load.py +248 -0
- novacode_cli/skills/prune.py +215 -0
- novacode_cli/skills/refreshing_middleware.py +896 -0
- novacode_cli/skills/retrieval.py +248 -0
- novacode_cli/skills/runtime.py +118 -0
- novacode_cli/skills/schema.py +254 -0
- novacode_cli/skills/skill_creation.py +1549 -0
- novacode_cli/skills/skill_lock.py +75 -0
- novacode_cli/skills/skill_system_prompt.py +16 -0
- novacode_cli/skills/skills_prefs.py +126 -0
- novacode_cli/skills/tool_resolver.py +31 -0
- novacode_cli/skills/upstream.py +50 -0
- novacode_cli/skills/versioning.py +291 -0
- novacode_cli/states/Session.py +847 -0
- novacode_cli/states/__init__.py +31 -0
- novacode_cli/states/slices/__init__.py +17 -0
- novacode_cli/states/slices/agent_runtime.py +249 -0
- novacode_cli/states/slices/background_tasks.py +21 -0
- novacode_cli/states/slices/notifications.py +74 -0
- novacode_cli/states/slices/remote_bridge.py +56 -0
- novacode_cli/states/slices/ui_settings.py +49 -0
- novacode_cli/states/slices/wiki.py +25 -0
- novacode_cli/teammates/__init__.py +1 -0
- novacode_cli/teammates/controller.py +557 -0
- novacode_cli/teammates/graph.py +104 -0
- novacode_cli/teammates/registry.py +175 -0
- novacode_cli/teammates/service.py +254 -0
- novacode_cli/teammates/worker.py +19 -0
- novacode_cli/teams/__init__.py +1 -0
- novacode_cli/teams/adapters/__init__.py +1 -0
- novacode_cli/teams/adapters/container.py +316 -0
- novacode_cli/teams/adapters/inprocess.py +206 -0
- novacode_cli/teams/adapters/process.py +364 -0
- novacode_cli/teams/bootstrap.py +246 -0
- novacode_cli/teams/container_git.py +67 -0
- novacode_cli/teams/context.py +34 -0
- novacode_cli/teams/coordinator.py +815 -0
- novacode_cli/teams/gateway.py +200 -0
- novacode_cli/teams/host.py +64 -0
- novacode_cli/teams/legacy.py +52 -0
- novacode_cli/teams/middleware.py +176 -0
- novacode_cli/teams/models.py +104 -0
- novacode_cli/teams/output.py +47 -0
- novacode_cli/teams/protocol.py +50 -0
- novacode_cli/teams/recovery.py +80 -0
- novacode_cli/teams/scheduler.py +44 -0
- novacode_cli/teams/store.py +631 -0
- novacode_cli/teams/worker.py +251 -0
- novacode_cli/teams/workspace.py +100 -0
- novacode_cli/token_utils.py +256 -0
- novacode_cli/tools/__init__.py +104 -0
- novacode_cli/tools/_shared.py +97 -0
- novacode_cli/tools/_types.py +24 -0
- novacode_cli/tools/artifact_tools.py +137 -0
- novacode_cli/tools/code_search_tools.py +330 -0
- novacode_cli/tools/computer_tools.py +77 -0
- novacode_cli/tools/cron_tools.py +65 -0
- novacode_cli/tools/daemon_tool.py +290 -0
- novacode_cli/tools/desktop_tools.py +161 -0
- novacode_cli/tools/fetch_tools.py +530 -0
- novacode_cli/tools/http_tools.py +279 -0
- novacode_cli/tools/inspection_tools.py +107 -0
- novacode_cli/tools/job_tools.py +143 -0
- novacode_cli/tools/knowledge_tools.py +427 -0
- novacode_cli/tools/memory_tools.py +461 -0
- novacode_cli/tools/memory_wiki_tools.py +40 -0
- novacode_cli/tools/notebook_tools.py +238 -0
- novacode_cli/tools/oracle_tool.py +83 -0
- novacode_cli/tools/plan_archive_tools.py +80 -0
- novacode_cli/tools/plan_mode_tools.py +315 -0
- novacode_cli/tools/python_kernel_loop.py +165 -0
- novacode_cli/tools/python_kernel_tool.py +222 -0
- novacode_cli/tools/reflection_tools.py +34 -0
- novacode_cli/tools/scraper_tools.py +377 -0
- novacode_cli/tools/skill_tools.py +275 -0
- novacode_cli/tools/speak_tool.py +51 -0
- novacode_cli/tools/store_memory_tools.py +227 -0
- novacode_cli/tools/ui_tools.py +62 -0
- novacode_cli/tools/waiter_tools.py +408 -0
- novacode_cli/tools/web_tools.py +349 -0
- novacode_cli/tools/wiki_tools.py +152 -0
- novacode_cli/tools/worktree_tools.py +219 -0
- novacode_cli/tracking/__init__.py +10 -0
- novacode_cli/tracking/file_tracker.py +826 -0
- novacode_cli/tracking/local_metrics.py +170 -0
- novacode_cli/tracking/loop_guard.py +507 -0
- novacode_cli/tracking/request_metrics.py +60 -0
- novacode_cli/tracking/tracing.py +692 -0
- novacode_cli/tracking/usage_tree.py +677 -0
- novacode_cli/tracking/workspace_anchoring.py +231 -0
- novacode_cli/tui/__init__.py +11 -0
- novacode_cli/tui/animation_rate.py +9 -0
- novacode_cli/tui/animations.py +332 -0
- novacode_cli/tui/app.py +19315 -0
- novacode_cli/tui/auth_display.py +87 -0
- novacode_cli/tui/auth_screens.py +826 -0
- novacode_cli/tui/avatar.py +294 -0
- novacode_cli/tui/avatar_frames.py +21 -0
- novacode_cli/tui/background_tasks.py +35 -0
- novacode_cli/tui/browser_settings_screen.py +70 -0
- novacode_cli/tui/caveman_screen.py +185 -0
- novacode_cli/tui/council_settings_screen.py +182 -0
- novacode_cli/tui/council_view.py +535 -0
- novacode_cli/tui/decision_settings.py +268 -0
- novacode_cli/tui/diff_preview.py +199 -0
- novacode_cli/tui/gc_tuning.py +63 -0
- novacode_cli/tui/handoff_screen.py +206 -0
- novacode_cli/tui/harness.py +244 -0
- novacode_cli/tui/history_tree_screen.py +179 -0
- novacode_cli/tui/home.py +328 -0
- novacode_cli/tui/home_scene.py +94 -0
- novacode_cli/tui/icons.py +81 -0
- novacode_cli/tui/interface_styles.tcss +327 -0
- novacode_cli/tui/matrix3.py +108 -0
- novacode_cli/tui/mcp_tools_screen.py +131 -0
- novacode_cli/tui/motion.py +178 -0
- novacode_cli/tui/output_body.py +136 -0
- novacode_cli/tui/output_buffer.py +59 -0
- novacode_cli/tui/palette.py +334 -0
- novacode_cli/tui/pickers.py +237 -0
- novacode_cli/tui/prime_panel.py +506 -0
- novacode_cli/tui/provider_signin.py +213 -0
- novacode_cli/tui/pty_shell.py +159 -0
- novacode_cli/tui/reference_screens.py +444 -0
- novacode_cli/tui/resume_picker.py +346 -0
- novacode_cli/tui/rooms.py +597 -0
- novacode_cli/tui/screens.py +7088 -0
- novacode_cli/tui/session_import.py +333 -0
- novacode_cli/tui/session_pane.py +242 -0
- novacode_cli/tui/session_tab.py +42 -0
- novacode_cli/tui/session_transcript.py +118 -0
- novacode_cli/tui/shell_output.py +7 -0
- novacode_cli/tui/stall_watch.py +174 -0
- novacode_cli/tui/statusline.py +40 -0
- novacode_cli/tui/statusline_screen.py +91 -0
- novacode_cli/tui/teammate_updates.py +162 -0
- novacode_cli/tui/teammates.py +548 -0
- novacode_cli/tui/terminal.py +289 -0
- novacode_cli/tui/terminal_view.py +235 -0
- novacode_cli/tui/themes.py +118 -0
- novacode_cli/tui/update_notice.py +84 -0
- novacode_cli/tui/voice_downloads.py +113 -0
- novacode_cli/tui/widgets.py +2038 -0
- novacode_cli/tui/windows_input.py +38 -0
- novacode_cli/tui/workspace_approval.py +165 -0
- novacode_cli/ui/council_web.py +113 -0
- novacode_cli/ui/council_workspace.py +72 -0
- novacode_cli/ui/cowork_web.py +41 -0
- novacode_cli/ui/diff_highlight.py +160 -0
- novacode_cli/ui/execution.py +1396 -0
- novacode_cli/ui/exit_summary.py +65 -0
- novacode_cli/ui/hitl_approval.py +196 -0
- novacode_cli/ui/input_preparation.py +2 -0
- novacode_cli/ui/interrupt_handlers.py +215 -0
- novacode_cli/ui/question_prompt.py +151 -0
- novacode_cli/ui/status_phrases.py +157 -0
- novacode_cli/ui/stream_preview.py +66 -0
- novacode_cli/ui/streaming.py +2 -0
- novacode_cli/ui/subagent_tracking.py +2 -0
- novacode_cli/ui/ui_elements.py +1585 -0
- novacode_cli/ui/web_workspace.py +244 -0
- novacode_cli/ui_events.py +299 -0
- novacode_cli/update_ui.py +243 -0
- novacode_cli/updates.py +489 -0
- novacode_cli/utils/backend_patches.py +399 -0
- novacode_cli/utils/backend_paths.py +467 -0
- novacode_cli/utils/lazy_providers.py +51 -0
- novacode_cli/utils/model_info.py +223 -0
- novacode_cli/utils/pdf_extraction.py +183 -0
- novacode_cli/utils/startup.py +29 -0
- novacode_cli/utils/tasks.py +42 -0
- novacode_cli/utils/windows.py +9 -0
- novacode_cli/vixie/hook_adapter.py +127 -0
- novacode_cli/vixie/server.py +375 -0
- novacode_cli/wiki/__init__.py +15 -0
- novacode_cli/wiki/ask.py +56 -0
- novacode_cli/wiki/file.py +71 -0
- novacode_cli/wiki/ingest.py +184 -0
- novacode_cli/wiki/manager.py +261 -0
- novacode_cli-1.2.0.dist-info/METADATA +271 -0
- novacode_cli-1.2.0.dist-info/RECORD +691 -0
- novacode_cli-1.2.0.dist-info/WHEEL +5 -0
- novacode_cli-1.2.0.dist-info/entry_points.txt +2 -0
- novacode_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|