aria-code 0.167.0__py3-none-win_amd64.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.
- aria_bots/__init__.py +7 -0
- aria_bots/channels/README.md +97 -0
- aria_bots/channels/__init__.py +1 -0
- aria_bots/channels/approvals.py +263 -0
- aria_bots/channels/conversation.py +280 -0
- aria_bots/channels/digest.py +186 -0
- aria_bots/channels/registry.py +95 -0
- aria_bots/engine.py +144 -0
- aria_bots/feishu_bot.py +1385 -0
- aria_bots/logistics/__init__.py +1 -0
- aria_bots/logistics/carriers.py +253 -0
- aria_bots/logistics/freight.py +154 -0
- aria_bots/logistics/inventory.py +242 -0
- aria_bots/logistics/mcp_server.py +156 -0
- aria_bots/logistics/records.py +119 -0
- aria_bots/logistics/tenancy.py +152 -0
- aria_bots/market.py +91 -0
- aria_bots/relay_client.py +326 -0
- aria_bots/telegram_bot.py +299 -0
- aria_code/__init__.py +111 -0
- aria_code/_bin/aria-native.exe +0 -0
- aria_code/_bin/aria-runtime.exe +0 -0
- aria_code/_version.py +20 -0
- aria_code/account_memory.py +296 -0
- aria_code/account_session.py +166 -0
- aria_code/adk_apps/__init__.py +1 -0
- aria_code/adk_apps/aria_code_review/__init__.py +1 -0
- aria_code/adk_apps/aria_code_review/agent.py +32 -0
- aria_code/adk_apps/aria_research/__init__.py +1 -0
- aria_code/adk_apps/aria_research/agent.py +31 -0
- aria_code/agents/__init__.py +49 -0
- aria_code/agents/base.py +329 -0
- aria_code/agents/code_review.py +200 -0
- aria_code/agents/deep/__init__.py +37 -0
- aria_code/agents/deep/calibration_loop.py +144 -0
- aria_code/agents/deep/critic.py +125 -0
- aria_code/agents/deep/deepen.py +193 -0
- aria_code/agents/deep/models.py +149 -0
- aria_code/agents/deep/pipeline.py +164 -0
- aria_code/agents/deep/quant_fusion.py +192 -0
- aria_code/agents/deep/themes.py +95 -0
- aria_code/agents/deep/tiers.py +106 -0
- aria_code/agents/engineering/__init__.py +14 -0
- aria_code/agents/engineering/coder.py +222 -0
- aria_code/agents/engineering/tester.py +145 -0
- aria_code/agents/financial/__init__.py +21 -0
- aria_code/agents/financial/cashflow_burn.py +119 -0
- aria_code/agents/financial/catalyst.py +279 -0
- aria_code/agents/financial/corporate_finance.py +175 -0
- aria_code/agents/financial/debate.py +224 -0
- aria_code/agents/financial/earnings.py +315 -0
- aria_code/agents/financial/fundamental.py +178 -0
- aria_code/agents/financial/macro.py +111 -0
- aria_code/agents/financial/news.py +231 -0
- aria_code/agents/financial/risk.py +146 -0
- aria_code/agents/financial/sector.py +279 -0
- aria_code/agents/financial/strategist.py +121 -0
- aria_code/agents/financial/stripe_revenue.py +127 -0
- aria_code/agents/financial/synthesis.py +274 -0
- aria_code/agents/financial/technical.py +277 -0
- aria_code/agents/orchestrator.py +44 -0
- aria_code/agents/portfolio_agent.py +392 -0
- aria_code/agents/realty/__init__.py +62 -0
- aria_code/agents/realty/asset_diagnosis.py +152 -0
- aria_code/agents/realty/business_match.py +166 -0
- aria_code/agents/realty/cashflow_verify.py +209 -0
- aria_code/agents/realty/contract_rules.py +210 -0
- aria_code/agents/realty/energy_anomaly.py +189 -0
- aria_code/agents/realty/exit_settlement.py +208 -0
- aria_code/agents/realty/fulfillment_risk.py +206 -0
- aria_code/agents/realty/ops_optimize.py +160 -0
- aria_code/agents/realty/revenue_share.py +215 -0
- aria_code/agents/registry.py +168 -0
- aria_code/agents/signal_scheme.py +111 -0
- aria_code/agents/sports/__init__.py +0 -0
- aria_code/agents/sports/football_agent.py +197 -0
- aria_code/agents/supervisor.py +113 -0
- aria_code/agents/team.py +500 -0
- aria_code/agents/video/__init__.py +16 -0
- aria_code/agents/video/director.py +376 -0
- aria_code/agents/video/qa_agent.py +143 -0
- aria_code/agents/video/script_doctor.py +112 -0
- aria_code/agents/warehouse/__init__.py +25 -0
- aria_code/agents/warehouse/contracts.py +26 -0
- aria_code/agents/warehouse/dashboard.py +155 -0
- aria_code/agents/warehouse/fulfillment_leadtime.py +95 -0
- aria_code/agents/warehouse/inbound_exceptions.py +25 -0
- aria_code/agents/warehouse/inventory_health.py +24 -0
- aria_code/agents/warehouse/local_run.py +71 -0
- aria_code/agents/warehouse/local_snapshot.py +132 -0
- aria_code/agents/warehouse/logistics_cost.py +84 -0
- aria_code/agents/warehouse/logistics_sync.py +21 -0
- aria_code/agents/warehouse/navigation.schema.json +110 -0
- aria_code/agents/warehouse/task_agent.py +172 -0
- aria_code/agents/warehouse/workflow.py +31 -0
- aria_code/aliyun_data_client.py +4 -0
- aria_code/apps/README.md +12 -0
- aria_code/apps/__init__.py +2 -0
- aria_code/apps/channels/README.md +9 -0
- aria_code/apps/channels/__init__.py +17 -0
- aria_code/apps/channels/approvals.py +11 -0
- aria_code/apps/channels/conversation.py +11 -0
- aria_code/apps/channels/digest.py +11 -0
- aria_code/apps/channels/intake.py +92 -0
- aria_code/apps/channels/registry.py +11 -0
- aria_code/apps/channels/tradingview.py +94 -0
- aria_code/apps/cli/README.md +13 -0
- aria_code/apps/cli/__init__.py +2 -0
- aria_code/apps/cli/_optional.py +120 -0
- aria_code/apps/cli/app_server.py +656 -0
- aria_code/apps/cli/auto_update.py +125 -0
- aria_code/apps/cli/bootstrap.py +206 -0
- aria_code/apps/cli/broker_render.py +206 -0
- aria_code/apps/cli/chat_turn.py +1327 -0
- aria_code/apps/cli/cloud_models.py +231 -0
- aria_code/apps/cli/codegen_paths.py +29 -0
- aria_code/apps/cli/commands/__init__.py +34 -0
- aria_code/apps/cli/commands/_business_render.py +78 -0
- aria_code/apps/cli/commands/_ui.py +71 -0
- aria_code/apps/cli/commands/analysis_cmds.py +378 -0
- aria_code/apps/cli/commands/ashare_prediction_cmds.py +327 -0
- aria_code/apps/cli/commands/auth_cmds.py +215 -0
- aria_code/apps/cli/commands/backtest_cmds.py +2518 -0
- aria_code/apps/cli/commands/broker_cmds.py +1236 -0
- aria_code/apps/cli/commands/business_workflow_cmds.py +379 -0
- aria_code/apps/cli/commands/canvas_cmds.py +77 -0
- aria_code/apps/cli/commands/catalog.py +189 -0
- aria_code/apps/cli/commands/core_cmds.py +2748 -0
- aria_code/apps/cli/commands/data_cmds.py +521 -0
- aria_code/apps/cli/commands/diagnostic_cmds.py +285 -0
- aria_code/apps/cli/commands/diagnostic_ops_cmds.py +813 -0
- aria_code/apps/cli/commands/engineering_cmds.py +399 -0
- aria_code/apps/cli/commands/file_cmds.py +312 -0
- aria_code/apps/cli/commands/finance_service_cmds.py +60 -0
- aria_code/apps/cli/commands/fx_commodity_cmds.py +123 -0
- aria_code/apps/cli/commands/market.py +544 -0
- aria_code/apps/cli/commands/market_cmds.py +1419 -0
- aria_code/apps/cli/commands/market_context.py +432 -0
- aria_code/apps/cli/commands/market_render.py +7 -0
- aria_code/apps/cli/commands/model_cmds.py +2374 -0
- aria_code/apps/cli/commands/ops_cmds.py +721 -0
- aria_code/apps/cli/commands/orchestrator_cmds.py +181 -0
- aria_code/apps/cli/commands/pdf_export_cmds.py +125 -0
- aria_code/apps/cli/commands/portfolio_cmds.py +1316 -0
- aria_code/apps/cli/commands/report.py +476 -0
- aria_code/apps/cli/commands/scaffold_templates.py +617 -0
- aria_code/apps/cli/commands/session_cmds.py +236 -0
- aria_code/apps/cli/commands/session_ux_cmds.py +359 -0
- aria_code/apps/cli/commands/team.py +723 -0
- aria_code/apps/cli/commands/team_render.py +8 -0
- aria_code/apps/cli/commands/ui_cmds.py +401 -0
- aria_code/apps/cli/commands/warehouse_cmds.py +297 -0
- aria_code/apps/cli/commands/workflow_cmds.py +629 -0
- aria_code/apps/cli/commands/workspace_cmds.py +1543 -0
- aria_code/apps/cli/config_paths.py +73 -0
- aria_code/apps/cli/config_store.py +158 -0
- aria_code/apps/cli/constants.py +88 -0
- aria_code/apps/cli/context.py +34 -0
- aria_code/apps/cli/conversation_policy.py +53 -0
- aria_code/apps/cli/deterministic.py +200 -0
- aria_code/apps/cli/direct.py +48 -0
- aria_code/apps/cli/earnings_view.py +172 -0
- aria_code/apps/cli/endpoint_migration.py +64 -0
- aria_code/apps/cli/exec_events.py +255 -0
- aria_code/apps/cli/file_references.py +155 -0
- aria_code/apps/cli/finance_service_catalog.py +111 -0
- aria_code/apps/cli/football_reports.py +231 -0
- aria_code/apps/cli/github_app_auth.py +135 -0
- aria_code/apps/cli/google_login.py +239 -0
- aria_code/apps/cli/handlers/__init__.py +11 -0
- aria_code/apps/cli/handlers/broker_handlers.py +122 -0
- aria_code/apps/cli/handlers/chart_handlers.py +1314 -0
- aria_code/apps/cli/handlers/market_handlers.py +2570 -0
- aria_code/apps/cli/handlers/realty_handlers.py +114 -0
- aria_code/apps/cli/handlers/sports_handlers.py +22 -0
- aria_code/apps/cli/handlers/strategy_advice.py +82 -0
- aria_code/apps/cli/headless.py +352 -0
- aria_code/apps/cli/helpers.py +326 -0
- aria_code/apps/cli/hooks.py +180 -0
- aria_code/apps/cli/i18n.py +310 -0
- aria_code/apps/cli/intent.py +136 -0
- aria_code/apps/cli/intent_router.py +305 -0
- aria_code/apps/cli/intent_shadow.py +72 -0
- aria_code/apps/cli/intent_signals.py +347 -0
- aria_code/apps/cli/lifecycle_hooks.py +48 -0
- aria_code/apps/cli/local_tool_schemas.py +550 -0
- aria_code/apps/cli/main.py +39 -0
- aria_code/apps/cli/market_hours.py +100 -0
- aria_code/apps/cli/market_metadata.py +135 -0
- aria_code/apps/cli/market_universe.py +487 -0
- aria_code/apps/cli/message_processing.py +310 -0
- aria_code/apps/cli/mixin_binding.py +32 -0
- aria_code/apps/cli/model_catalog.py +652 -0
- aria_code/apps/cli/model_probe.py +169 -0
- aria_code/apps/cli/model_retirement.py +65 -0
- aria_code/apps/cli/native_branding.py +12 -0
- aria_code/apps/cli/native_bridge.py +118 -0
- aria_code/apps/cli/native_entry.py +58 -0
- aria_code/apps/cli/pdf_report.py +977 -0
- aria_code/apps/cli/plan_mode.py +139 -0
- aria_code/apps/cli/plotly_html.py +15 -0
- aria_code/apps/cli/prediction_feedback.py +202 -0
- aria_code/apps/cli/preflight.py +502 -0
- aria_code/apps/cli/project_aria.py +60 -0
- aria_code/apps/cli/prompt_assembly.py +78 -0
- aria_code/apps/cli/prompts/__init__.py +0 -0
- aria_code/apps/cli/prompts/coding.py +71 -0
- aria_code/apps/cli/prompts/select.py +193 -0
- aria_code/apps/cli/prompts/system_prompts.py +562 -0
- aria_code/apps/cli/prompts/ui.py +436 -0
- aria_code/apps/cli/provider_endpoints.py +237 -0
- aria_code/apps/cli/provider_keys.py +73 -0
- aria_code/apps/cli/providers/__init__.py +1 -0
- aria_code/apps/cli/providers/base.py +775 -0
- aria_code/apps/cli/providers/chat_routing.py +139 -0
- aria_code/apps/cli/providers/collaboration.py +98 -0
- aria_code/apps/cli/providers/llm/__init__.py +1 -0
- aria_code/apps/cli/providers/llm/offline_responses.py +68 -0
- aria_code/apps/cli/providers/llm/ollama_stream.py +1574 -0
- aria_code/apps/cli/providers/llm/response_cache.py +60 -0
- aria_code/apps/cli/providers/llm/sse_stream.py +298 -0
- aria_code/apps/cli/providers/runtime_bridge.py +586 -0
- aria_code/apps/cli/providers/vertexai_stream.py +592 -0
- aria_code/apps/cli/release_download.py +159 -0
- aria_code/apps/cli/review_runner.py +111 -0
- aria_code/apps/cli/runtime_consumer.py +769 -0
- aria_code/apps/cli/sandbox.py +32 -0
- aria_code/apps/cli/session_export.py +120 -0
- aria_code/apps/cli/session_jsonl.py +208 -0
- aria_code/apps/cli/session_refresh.py +123 -0
- aria_code/apps/cli/session_store.py +125 -0
- aria_code/apps/cli/skills_catalog.py +273 -0
- aria_code/apps/cli/task_isolation.py +211 -0
- aria_code/apps/cli/terminal_chrome.py +412 -0
- aria_code/apps/cli/todo_tracker.py +190 -0
- aria_code/apps/cli/tool_executor.py +932 -0
- aria_code/apps/cli/tool_registry.py +34 -0
- aria_code/apps/cli/tool_scope.py +113 -0
- aria_code/apps/cli/tools/__init__.py +40 -0
- aria_code/apps/cli/tools/context.py +46 -0
- aria_code/apps/cli/tools/file_tools.py +181 -0
- aria_code/apps/cli/tools/market_tools.py +626 -0
- aria_code/apps/cli/tools/notebook_tools.py +111 -0
- aria_code/apps/cli/tools/system_tools.py +656 -0
- aria_code/apps/cli/tools/write_tools.py +961 -0
- aria_code/apps/cli/tradingview_bridge.py +529 -0
- aria_code/apps/cli/transactions.py +269 -0
- aria_code/apps/cli/turn_planning.py +137 -0
- aria_code/apps/cli/update_check.py +257 -0
- aria_code/apps/cli/updater.py +373 -0
- aria_code/apps/cli/utils/__init__.py +0 -0
- aria_code/apps/cli/utils/market_detect.py +1663 -0
- aria_code/apps/cli/web_login.py +136 -0
- aria_code/apps/cli/workflow_runner.py +127 -0
- aria_code/apps/cli/workspace_route.py +121 -0
- aria_code/apps/daemon/README.md +14 -0
- aria_code/apps/vscode/README.md +115 -0
- aria_code/apps/vscode/package.json +70 -0
- aria_code/aria_cli.py +5244 -0
- aria_code/aria_daemon.py +1372 -0
- aria_code/aria_feishu_bot.py +16 -0
- aria_code/aria_mcp_server.py +61 -0
- aria_code/aria_relay_client.py +4 -0
- aria_code/aria_telegram_bot.py +11 -0
- aria_code/ariarc.py +385 -0
- aria_code/artifacts.py +581 -0
- aria_code/backtest_engine.py +4 -0
- aria_code/backtest_report.py +4 -0
- aria_code/brokers/__init__.py +78 -0
- aria_code/brokers/automation.py +193 -0
- aria_code/brokers/base.py +231 -0
- aria_code/brokers/capabilities.py +264 -0
- aria_code/brokers/cn/__init__.py +10 -0
- aria_code/brokers/cn/easytrader_broker.py +193 -0
- aria_code/brokers/cn/futu_broker.py +194 -0
- aria_code/brokers/cn/longbridge_broker.py +190 -0
- aria_code/brokers/cn/tiger_broker.py +196 -0
- aria_code/brokers/cn/xtquant_broker.py +225 -0
- aria_code/brokers/config.py +414 -0
- aria_code/brokers/intl/__init__.py +5 -0
- aria_code/brokers/intl/alpaca_broker.py +183 -0
- aria_code/brokers/intl/ibkr_broker.py +215 -0
- aria_code/brokers/intl/trading212_broker.py +183 -0
- aria_code/brokers/intl/webull_broker.py +156 -0
- aria_code/brokers/paper_broker.py +266 -0
- aria_code/brokers/planning.py +296 -0
- aria_code/brokers/registry.py +213 -0
- aria_code/brokers/trading.py +267 -0
- aria_code/canva_client.py +4 -0
- aria_code/change_store.py +127 -0
- aria_code/clients/__init__.py +0 -0
- aria_code/clients/aliyun_data_client.py +660 -0
- aria_code/clients/aria_relay_client.py +16 -0
- aria_code/clients/canva_client.py +284 -0
- aria_code/clients/figma_client.py +100 -0
- aria_code/clients/football_data_client.py +1671 -0
- aria_code/clients/kling_video_client.py +182 -0
- aria_code/clients/market_data_client.py +1985 -0
- aria_code/clients/openai_image_client.py +242 -0
- aria_code/clients/runway_video_client.py +157 -0
- aria_code/clients/warehouse_erp_client.py +105 -0
- aria_code/cloud_memory.py +190 -0
- aria_code/command_safety.py +19 -0
- aria_code/computer_use_server.py +276 -0
- aria_code/computer_use_tools.py +4 -0
- aria_code/dashboard_generator.py +579 -0
- aria_code/data_analysis_tools.py +4 -0
- aria_code/data_cleaner.py +483 -0
- aria_code/data_service.py +513 -0
- aria_code/datasources/__init__.py +23 -0
- aria_code/datasources/base.py +166 -0
- aria_code/datasources/router.py +253 -0
- aria_code/datasources/sec_statements.py +217 -0
- aria_code/datasources/sources/__init__.py +15 -0
- aria_code/datasources/sources/akshare_source.py +269 -0
- aria_code/datasources/sources/alpha_vantage_source.py +203 -0
- aria_code/datasources/sources/edgar_source.py +219 -0
- aria_code/datasources/sources/finnhub_source.py +198 -0
- aria_code/datasources/sources/fred_source.py +223 -0
- aria_code/datasources/sources/tushare_source.py +143 -0
- aria_code/datasources/sources/web_scraper_source.py +278 -0
- aria_code/datasources/sources/world_bank_source.py +206 -0
- aria_code/datasources/sources/yfinance_source.py +152 -0
- aria_code/doctor.py +860 -0
- aria_code/domain/__init__.py +0 -0
- aria_code/domain/_finance_core/__init__.py +5 -0
- aria_code/domain/_finance_core/backtest_engine.py +325 -0
- aria_code/domain/_finance_core/performance_stats.py +101 -0
- aria_code/domain/backtest_engine.py +75 -0
- aria_code/domain/backtest_report.py +488 -0
- aria_code/domain/finance_formulas.py +389 -0
- aria_code/domain/performance_stats.py +6 -0
- aria_code/domain/portfolio_ledger.py +307 -0
- aria_code/domain/strategy_vault.py +421 -0
- aria_code/evals/__init__.py +37 -0
- aria_code/evals/behavior.py +113 -0
- aria_code/evals/gap.py +93 -0
- aria_code/evals/harness.py +949 -0
- aria_code/evals/review_probe.py +132 -0
- aria_code/evals/runner.py +425 -0
- aria_code/evals/scorecard.py +322 -0
- aria_code/evals/summary.py +125 -0
- aria_code/external_agent_runner.py +115 -0
- aria_code/figma_client.py +4 -0
- aria_code/file_analysis_tools.py +4 -0
- aria_code/finance_formulas.py +4 -0
- aria_code/football_data_client.py +4 -0
- aria_code/image_gen_tools.py +4 -0
- aria_code/intent_classifier.py +459 -0
- aria_code/kling_video_client.py +4 -0
- aria_code/licensing.py +151 -0
- aria_code/local_finance_tools.py +4 -0
- aria_code/local_image_provider.py +4 -0
- aria_code/local_llm_provider.py +4 -0
- aria_code/macro_tools.py +4 -0
- aria_code/markdown_pdf.py +289 -0
- aria_code/market_data_client.py +4 -0
- aria_code/mcp_client.py +744 -0
- aria_code/memory_manager.py +273 -0
- aria_code/model_capability.py +512 -0
- aria_code/notification_tools.py +4 -0
- aria_code/openai_image_client.py +4 -0
- aria_code/packages/__init__.py +26 -0
- aria_code/packages/adk_bridge/__init__.py +11 -0
- aria_code/packages/adk_bridge/code_review_tools.py +49 -0
- aria_code/packages/adk_bridge/market_tools.py +125 -0
- aria_code/packages/aria_agents/__init__.py +5 -0
- aria_code/packages/aria_agents/manifest.py +78 -0
- aria_code/packages/aria_core/__init__.py +34 -0
- aria_code/packages/aria_core/architecture.py +269 -0
- aria_code/packages/aria_core/export.py +147 -0
- aria_code/packages/aria_core/manifest.py +65 -0
- aria_code/packages/aria_core/paths.py +57 -0
- aria_code/packages/aria_core/secure_file.py +68 -0
- aria_code/packages/aria_infra/__init__.py +15 -0
- aria_code/packages/aria_infra/arthera.py +58 -0
- aria_code/packages/aria_infra/doctor.py +246 -0
- aria_code/packages/aria_infra/product.py +40 -0
- aria_code/packages/aria_mcp/__init__.py +25 -0
- aria_code/packages/aria_mcp/bridge.py +232 -0
- aria_code/packages/aria_mcp/circuit.py +82 -0
- aria_code/packages/aria_mcp/config.py +162 -0
- aria_code/packages/aria_mcp/installer.py +95 -0
- aria_code/packages/aria_mcp/server.py +1705 -0
- aria_code/packages/aria_mcp/tools.py +81 -0
- aria_code/packages/aria_sdk/__init__.py +19 -0
- aria_code/packages/aria_sdk/client.py +393 -0
- aria_code/packages/aria_sdk/providers.py +70 -0
- aria_code/packages/aria_sdk/streaming.py +75 -0
- aria_code/packages/aria_sdk/types.py +86 -0
- aria_code/packages/aria_services/__init__.py +92 -0
- aria_code/packages/aria_services/cache.py +60 -0
- aria_code/packages/aria_services/context.py +356 -0
- aria_code/packages/aria_services/context_checkpoints.py +157 -0
- aria_code/packages/aria_services/data.py +11 -0
- aria_code/packages/aria_services/provider_health.py +304 -0
- aria_code/packages/aria_services/references.py +247 -0
- aria_code/packages/aria_services/registry.py +249 -0
- aria_code/packages/aria_services/research_protocol.py +137 -0
- aria_code/packages/aria_services/research_quality.py +186 -0
- aria_code/packages/aria_services/settings.py +160 -0
- aria_code/packages/aria_services/usage.py +151 -0
- aria_code/packages/aria_skills/__init__.py +46 -0
- aria_code/packages/aria_skills/catalog.py +149 -0
- aria_code/packages/aria_skills/loader.py +455 -0
- aria_code/packages/aria_skills/registry.py +84 -0
- aria_code/packages/aria_tools/__init__.py +5 -0
- aria_code/packages/aria_tools/financial/__init__.py +5 -0
- aria_code/packages/aria_tools/financial/_paths.py +92 -0
- aria_code/packages/aria_tools/registry.py +129 -0
- aria_code/packages/quant_engine/__init__.py +33 -0
- aria_code/packages/quant_engine/_namespace.py +24 -0
- aria_code/packages/quant_engine/backtest/__init__.py +10 -0
- aria_code/packages/quant_engine/backtest/core.py +53 -0
- aria_code/packages/quant_engine/backtest/engine.py +102 -0
- aria_code/packages/quant_engine/portfolio/__init__.py +9 -0
- aria_code/packages/quant_engine/portfolio/optimizer.py +85 -0
- aria_code/packages/quant_engine/sports/__init__.py +76 -0
- aria_code/packages/quant_engine/sports/calibrator.py +354 -0
- aria_code/packages/quant_engine/sports/dixon_coles.py +234 -0
- aria_code/packages/quant_engine/sports/elo.py +300 -0
- aria_code/packages/quant_engine/sports/form.py +188 -0
- aria_code/packages/quant_engine/sports/h2h.py +195 -0
- aria_code/packages/quant_engine/sports/predictor.py +312 -0
- aria_code/packages/quant_engine/sports/tracker.py +665 -0
- aria_code/packages/quant_engine/stochastic/__init__.py +31 -0
- aria_code/packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
- aria_code/packages/quant_engine/stochastic/ito_calculus.py +477 -0
- aria_code/packages/quant_engine/stochastic/kelly_criterion.py +181 -0
- aria_code/packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
- aria_code/packages/quant_engine/stochastic/options_pricing.py +576 -0
- aria_code/packages/quant_engine/stochastic/stochastic_processes.py +90 -0
- aria_code/packs/__init__.py +85 -0
- aria_code/packs/base.py +168 -0
- aria_code/packs/finance/__init__.py +250 -0
- aria_code/packs/healthcare/__init__.py +40 -0
- aria_code/packs/logistics/__init__.py +171 -0
- aria_code/packs/payments/__init__.py +139 -0
- aria_code/packs/realty/__init__.py +190 -0
- aria_code/packs/registry.py +202 -0
- aria_code/packs/rules.py +90 -0
- aria_code/plan_utils.py +194 -0
- aria_code/plugin_loader.py +328 -0
- aria_code/portfolio_ledger.py +4 -0
- aria_code/preview_server.py +449 -0
- aria_code/privacy/__init__.py +5 -0
- aria_code/privacy/feedback.py +123 -0
- aria_code/project_review.py +278 -0
- aria_code/project_review_api.py +235 -0
- aria_code/project_review_server.py +18 -0
- aria_code/project_tools.py +4 -0
- aria_code/providers/__init__.py +30 -0
- aria_code/providers/google_video_provider.py +256 -0
- aria_code/providers/llm/__init__.py +19 -0
- aria_code/providers/llm/anthropic.py +184 -0
- aria_code/providers/llm/arthera_gateway.py +90 -0
- aria_code/providers/llm/autoconfig.py +213 -0
- aria_code/providers/llm/base.py +139 -0
- aria_code/providers/llm/custom.py +158 -0
- aria_code/providers/llm/ollama.py +137 -0
- aria_code/providers/llm/openai_compat.py +350 -0
- aria_code/providers/llm/registry.py +457 -0
- aria_code/providers/local_image_provider.py +202 -0
- aria_code/providers/local_llm_provider.py +549 -0
- aria_code/providers/ollama_models.py +79 -0
- aria_code/providers/openai_params.py +32 -0
- aria_code/providers/tool_messages.py +100 -0
- aria_code/realty_data_tools.py +4 -0
- aria_code/report_exporters.py +150 -0
- aria_code/report_generator.py +1764 -0
- aria_code/review_service.py +497 -0
- aria_code/runtime/__init__.py +176 -0
- aria_code/runtime/acceptance.py +632 -0
- aria_code/runtime/agent_loop.py +1851 -0
- aria_code/runtime/agent_profiles.py +75 -0
- aria_code/runtime/approval.py +71 -0
- aria_code/runtime/baseline.py +219 -0
- aria_code/runtime/budget.py +232 -0
- aria_code/runtime/checkpoints.py +481 -0
- aria_code/runtime/consult.py +65 -0
- aria_code/runtime/contract.py +282 -0
- aria_code/runtime/delivery.py +432 -0
- aria_code/runtime/events.py +113 -0
- aria_code/runtime/gateway.py +167 -0
- aria_code/runtime/lsp.py +484 -0
- aria_code/runtime/native_index.py +153 -0
- aria_code/runtime/orchestrator.py +55 -0
- aria_code/runtime/processes.py +343 -0
- aria_code/runtime/project_graph.py +614 -0
- aria_code/runtime/repo_map.py +712 -0
- aria_code/runtime/research_bundle.py +105 -0
- aria_code/runtime/review.py +269 -0
- aria_code/runtime/run_state.py +127 -0
- aria_code/runtime/run_store.py +522 -0
- aria_code/runtime/scoped_runs.py +113 -0
- aria_code/runtime/self_healing.py +422 -0
- aria_code/runtime/semantic_diff.py +171 -0
- aria_code/runtime/subagent.py +721 -0
- aria_code/runtime/symbol_edit.py +117 -0
- aria_code/runtime/task_brief.py +46 -0
- aria_code/runtime/task_context.py +145 -0
- aria_code/runtime/task_ledger.py +108 -0
- aria_code/runtime/task_orchestrator.py +90 -0
- aria_code/runtime/task_outcome.py +33 -0
- aria_code/runtime/task_verification.py +266 -0
- aria_code/runtime/task_worktree.py +448 -0
- aria_code/runtime/tool_executor.py +329 -0
- aria_code/runtime/tool_policy.py +107 -0
- aria_code/runtime/transactions.py +216 -0
- aria_code/runtime/windows_job.py +115 -0
- aria_code/runtime/workflows.py +222 -0
- aria_code/runtime/worktrees.py +215 -0
- aria_code/runway_video_client.py +4 -0
- aria_code/safety/__init__.py +27 -0
- aria_code/safety/client_isolation.py +178 -0
- aria_code/safety/permissions.py +339 -0
- aria_code/safety/risk.py +699 -0
- aria_code/safety/sandbox.py +148 -0
- aria_code/safety/service.py +102 -0
- aria_code/service_endpoints.py +14 -0
- aria_code/setup_wizard.py +684 -0
- aria_code/spreadsheet_tools.py +4 -0
- aria_code/strategy_vault.py +4 -0
- aria_code/tools/__init__.py +0 -0
- aria_code/tools/artifact_tools.py +252 -0
- aria_code/tools/broker_tools.py +92 -0
- aria_code/tools/build_quant_engine.py +97 -0
- aria_code/tools/code_audit_tools.py +192 -0
- aria_code/tools/computer_use_tools.py +509 -0
- aria_code/tools/data_analysis_tools.py +831 -0
- aria_code/tools/enterprise_finance_tools.py +202 -0
- aria_code/tools/extended_tools.py +240 -0
- aria_code/tools/file_analysis_tools.py +880 -0
- aria_code/tools/image_gen_tools.py +114 -0
- aria_code/tools/local_finance_tools.py +3488 -0
- aria_code/tools/logistics_carriers.py +11 -0
- aria_code/tools/logistics_inventory.py +11 -0
- aria_code/tools/logistics_io.py +11 -0
- aria_code/tools/logistics_tenancy.py +11 -0
- aria_code/tools/logistics_tools.py +11 -0
- aria_code/tools/macro_tools.py +412 -0
- aria_code/tools/notification_tools.py +249 -0
- aria_code/tools/project_tools.py +525 -0
- aria_code/tools/realty_data_tools.py +662 -0
- aria_code/tools/spreadsheet_tools.py +399 -0
- aria_code/tools/stripe_tools.py +115 -0
- aria_code/tools/video_tools.py +500 -0
- aria_code/training/__init__.py +1 -0
- aria_code/training/compare.py +117 -0
- aria_code/training/prelude_commands.py +111 -0
- aria_code/training/prelude_eval.py +390 -0
- aria_code/training/prelude_real.py +183 -0
- aria_code/training/prelude_train.py +248 -0
- aria_code/training/sft_dataset.py +230 -0
- aria_code/training/trajectory_dataset.py +263 -0
- aria_code/ui/__init__.py +105 -0
- aria_code/ui/banner.py +404 -0
- aria_code/ui/completer.py +571 -0
- aria_code/ui/console.py +285 -0
- aria_code/ui/input_box.py +505 -0
- aria_code/ui/mentions.py +206 -0
- aria_code/ui/picker.py +248 -0
- aria_code/ui/render/__init__.py +11 -0
- aria_code/ui/render/actions.py +297 -0
- aria_code/ui/render/finance.py +1732 -0
- aria_code/ui/render/market.py +253 -0
- aria_code/ui/render/output.py +954 -0
- aria_code/ui/render/responsive.py +75 -0
- aria_code/ui/render/team.py +367 -0
- aria_code/ui/robot.py +239 -0
- aria_code/ui/startup_dashboard.py +110 -0
- aria_code/video_analysis.py +147 -0
- aria_code/video_editor.py +518 -0
- aria_code/video_workflow/__init__.py +8 -0
- aria_code/video_workflow/__main__.py +61 -0
- aria_code/video_workflow/engines/__init__.py +18 -0
- aria_code/video_workflow/engines/base.py +69 -0
- aria_code/video_workflow/engines/custom_model.py +204 -0
- aria_code/video_workflow/engines/google_veo.py +94 -0
- aria_code/video_workflow/engines/local_engine.py +71 -0
- aria_code/video_workflow/engines/registry.py +98 -0
- aria_code/video_workflow/google_models.py +278 -0
- aria_code/video_workflow/music/__init__.py +5 -0
- aria_code/video_workflow/music/foley_service.py +132 -0
- aria_code/video_workflow/music/service.py +212 -0
- aria_code/video_workflow/pipeline.py +620 -0
- aria_code/video_workflow/plan.py +202 -0
- aria_code/video_workflow/session.py +297 -0
- aria_code/workspace/__init__.py +6 -0
- aria_code/workspace/files.py +219 -0
- aria_code/workspace/verify.py +167 -0
- aria_code/workspace_changes.py +199 -0
- aria_code/workspace_service.py +196 -0
- aria_code/workspace_tools.py +150 -0
- aria_code-0.167.0.dist-info/METADATA +330 -0
- aria_code-0.167.0.dist-info/RECORD +601 -0
- aria_code-0.167.0.dist-info/WHEEL +5 -0
- aria_code-0.167.0.dist-info/entry_points.txt +5 -0
- aria_code-0.167.0.dist-info/licenses/LICENSE +201 -0
- aria_code-0.167.0.dist-info/licenses/NOTICE +49 -0
- aria_code-0.167.0.dist-info/top_level.txt +2 -0
aria_bots/feishu_bot.py
ADDED
|
@@ -0,0 +1,1385 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aria_bots.feishu_bot — Feishu (Lark) 多模态 AI 机器人
|
|
3
|
+
======================================================
|
|
4
|
+
OpenClaw 同款设计:任意输入(文字/语音/图片/文件)→ Aria AI → 卡片回复
|
|
5
|
+
|
|
6
|
+
两种运行模式:
|
|
7
|
+
1. 嵌入 FastAPI(由 feishu_routes.py 调用)
|
|
8
|
+
2. 独立运行 — python3 -m aria_bots.feishu_bot [port]
|
|
9
|
+
|
|
10
|
+
飞书端配置:
|
|
11
|
+
1. 飞书开发者后台 → 创建自建应用
|
|
12
|
+
2. 事件订阅 → Request URL: http://<host>/api/v1/feishu/event
|
|
13
|
+
3. 权限:im:message / im:message:send_as_bot
|
|
14
|
+
4. ~/.aria/.env 填写:
|
|
15
|
+
FEISHU_APP_ID=cli_xxx FEISHU_APP_SECRET=xxx
|
|
16
|
+
ANTHROPIC_API_KEY=xxx # 图片理解 / LLM
|
|
17
|
+
OPENAI_API_KEY=xxx # Whisper 语音转文字(可选)
|
|
18
|
+
FEISHU_ALLOWED_USER_IDS=ou_xxx,ou_yyy # 必填:留空 = 谁都不能用
|
|
19
|
+
FEISHU_ENCRYPT_KEY=xxx 或 FEISHU_VERIFICATION_TOKEN=xxx # 独立运行时必填其一
|
|
20
|
+
ARIA_BOT_ALLOW_TOOLS=write_file # 可选:机器人可免审批使用的工具
|
|
21
|
+
|
|
22
|
+
安全边界(2026-10-03):
|
|
23
|
+
- 白名单为空时拒绝所有人。之前留空 = 不限制,而机器人调用 aria 时自动批准
|
|
24
|
+
全部工具,于是任何能给它发消息的人都能在这台机器上跑 shell 命令。
|
|
25
|
+
- 机器人不再自动批准需要确认的工具(run_command / write_file / edit_file /
|
|
26
|
+
multi_edit);没有人能在飞书里点「同意」,这些调用会被拒绝。确实需要的,
|
|
27
|
+
由管理员用 ARIA_BOT_ALLOW_TOOLS 逐个列出。
|
|
28
|
+
- 独立运行时校验飞书事件签名;未配置 Encrypt Key 或 Verification Token 时
|
|
29
|
+
拒绝一切事件。之前任何能访问端口的人都能伪造发件人。
|
|
30
|
+
|
|
31
|
+
支持的消息类型:
|
|
32
|
+
📝 文字(非命令)→ Aria LLM 自然语言回答
|
|
33
|
+
🎤 语音 → Whisper 转文字 → Aria LLM
|
|
34
|
+
🖼️ 图片 → 视觉 LLM 分析(Claude / GPT-4V)
|
|
35
|
+
📄 文件 → 自动解析 PDF/Excel/代码 → Aria LLM 总结
|
|
36
|
+
|
|
37
|
+
结构化命令(/command):
|
|
38
|
+
/price AAPL /brief
|
|
39
|
+
/report NVDA /run /price TSLA (调用 aria CLI -p 模式)
|
|
40
|
+
/alert add SYM cond v /alerts /status /help
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import asyncio
|
|
46
|
+
import base64
|
|
47
|
+
import hashlib
|
|
48
|
+
import hmac
|
|
49
|
+
import json
|
|
50
|
+
import logging
|
|
51
|
+
import os
|
|
52
|
+
import re
|
|
53
|
+
import sys
|
|
54
|
+
import tempfile
|
|
55
|
+
import time
|
|
56
|
+
from contextvars import ContextVar
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
from typing import Any, Dict, Optional
|
|
59
|
+
|
|
60
|
+
from aria_bots.channels.conversation import (
|
|
61
|
+
ChannelTurn,
|
|
62
|
+
ConversationStore,
|
|
63
|
+
InboundMessage,
|
|
64
|
+
handle_owner_command,
|
|
65
|
+
prepare_turn,
|
|
66
|
+
should_respond,
|
|
67
|
+
)
|
|
68
|
+
from aria_bots.logistics.tenancy import OWNER_SCOPE_ENV
|
|
69
|
+
from aria_bots import engine as _engine
|
|
70
|
+
from aria_bots import market as _market
|
|
71
|
+
|
|
72
|
+
logger = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
# Strip ANSI escape codes from aria CLI output
|
|
75
|
+
_ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
76
|
+
|
|
77
|
+
# Lines that are tool/UI artifacts and must be stripped from bot replies
|
|
78
|
+
_BOT_NOISE_RE = re.compile(
|
|
79
|
+
r"^\s*(?:"
|
|
80
|
+
# ── diff / table lines (ASCII pipe AND Unicode box-drawing │ U+2502) ────
|
|
81
|
+
r"[│|][+\- \d]" # │298 │+ code |- old | context
|
|
82
|
+
r"|[│|]\s*$" # │ │ (empty cell borders)
|
|
83
|
+
r"|[┌┐└┘├┤┬┴┼─╌╍╴╶╷╸╹]" # box corners / connectors
|
|
84
|
+
# ── timing artifacts ────────────────────────────────────────────────────
|
|
85
|
+
r"|└\s*\d+[\.,]\d+s?\b"
|
|
86
|
+
r"|[└─]{1,3}\s*\d+[\.,]\d+\s*s"
|
|
87
|
+
# ── tool call / result bullets ──────────────────────────────────────────
|
|
88
|
+
r"| [●└■▸]"
|
|
89
|
+
r"| L \d"
|
|
90
|
+
# ── permission / confirmation dialog ────────────────────────────────────
|
|
91
|
+
r"|[›❯>]\s*\d+\." # › 1. Yes ❯ 1. Yes > 1. Yes
|
|
92
|
+
r"|\d+\.\s+Yes" # 1. Yes / 2. Yes, allow all
|
|
93
|
+
r"|\d+\.\s+No" # 3. No
|
|
94
|
+
r"|Enter number" # "Enter number (or Enter to keep current):"
|
|
95
|
+
r"|Cancelled"
|
|
96
|
+
# ── leftover Rich markup tags ────────────────────────────────────────────
|
|
97
|
+
r"|\[/?(?:cyan|dim|bold|red|green|yellow|blue|magenta|white|grey|reset)\]"
|
|
98
|
+
# ── horizontal rules ────────────────────────────────────────────────────
|
|
99
|
+
r"|\s*[━─═]{4,}\s*$"
|
|
100
|
+
r")"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Inline timing / markup to strip from within a line
|
|
104
|
+
_INLINE_TIMING_RE = re.compile(r"\s*[└─]{1,2}\s*\d+[\.,]\d+\s*s\b")
|
|
105
|
+
_INLINE_RICH_TAG_RE = re.compile(r"\[/?(?:cyan|dim|bold|red|green|yellow|blue|magenta|white|grey|reset)\]")
|
|
106
|
+
|
|
107
|
+
def _find_aria_code_dir() -> Path:
|
|
108
|
+
"""Where aria_cli.py lives: the installed aria_code package, else beside this file."""
|
|
109
|
+
import importlib.util
|
|
110
|
+
|
|
111
|
+
spec = importlib.util.find_spec("aria_code")
|
|
112
|
+
if spec and spec.submodule_search_locations:
|
|
113
|
+
return Path(list(spec.submodule_search_locations)[0])
|
|
114
|
+
return Path(__file__).parent
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_ARIA_CODE_DIR = _find_aria_code_dir()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── Feishu API endpoints ───────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
_FEISHU_API = "https://open.feishu.cn/open-apis"
|
|
123
|
+
|
|
124
|
+
# ── Token cache (tenant_access_token, expires ~2h) ────────────────────────────
|
|
125
|
+
_token_cache: dict[str, Any] = {"token": None, "expires_at": 0}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _get_access_token() -> Optional[str]:
|
|
129
|
+
"""Fetch/cache tenant_access_token via app credentials."""
|
|
130
|
+
app_id = os.environ.get("FEISHU_APP_ID", "")
|
|
131
|
+
app_secret = os.environ.get("FEISHU_APP_SECRET", "")
|
|
132
|
+
if not app_id or not app_secret:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
now = time.time()
|
|
136
|
+
if _token_cache["token"] and _token_cache["expires_at"] > now + 60:
|
|
137
|
+
return _token_cache["token"]
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
import httpx
|
|
141
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
142
|
+
resp = await client.post(
|
|
143
|
+
f"{_FEISHU_API}/auth/v3/tenant_access_token/internal",
|
|
144
|
+
json={"app_id": app_id, "app_secret": app_secret},
|
|
145
|
+
)
|
|
146
|
+
data = resp.json()
|
|
147
|
+
token = data.get("tenant_access_token")
|
|
148
|
+
expire = int(data.get("expire", 7200))
|
|
149
|
+
_token_cache["token"] = token
|
|
150
|
+
_token_cache["expires_at"] = now + expire
|
|
151
|
+
return token
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
logger.warning("Feishu token fetch failed: %s", exc)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── Send message helpers ───────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
# Set by the relay client while it is connected. In relay mode this machine has
|
|
160
|
+
# no Feishu app credentials — by design, the app secret stays on the relay — so
|
|
161
|
+
# every message goes back over the WebSocket and the relay sends it. Before
|
|
162
|
+
# 2026-10-03 nothing did this, and relay mode silently dropped every answer.
|
|
163
|
+
_RELAY_SEND = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def set_relay_sender(sender) -> None:
|
|
167
|
+
"""Route sends through the relay: sender(request) -> Feishu-style result dict."""
|
|
168
|
+
global _RELAY_SEND
|
|
169
|
+
_RELAY_SEND = sender
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def can_send() -> bool:
|
|
173
|
+
"""Whether this process can deliver a message at all."""
|
|
174
|
+
has_app = bool(os.environ.get("FEISHU_APP_ID") and os.environ.get("FEISHU_APP_SECRET"))
|
|
175
|
+
return has_app or _RELAY_SEND is not None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _send_message(op: str, target: str, msg_type: str, content: str,
|
|
179
|
+
receive_id_type: str = "chat_id") -> Optional[dict]:
|
|
180
|
+
"""The one place a message leaves: directly with app credentials, else via the relay.
|
|
181
|
+
|
|
182
|
+
op "reply" answers message `target`; op "send" posts to chat `target`.
|
|
183
|
+
"""
|
|
184
|
+
token = await _get_access_token()
|
|
185
|
+
if token:
|
|
186
|
+
if op == "reply":
|
|
187
|
+
return await _feishu_post(f"{_FEISHU_API}/im/v1/messages/{target}/reply", token,
|
|
188
|
+
{"msg_type": msg_type, "content": content})
|
|
189
|
+
return await _feishu_post(f"{_FEISHU_API}/im/v1/messages?receive_id_type={receive_id_type}",
|
|
190
|
+
token, {"receive_id": target, "msg_type": msg_type, "content": content})
|
|
191
|
+
if _RELAY_SEND is not None:
|
|
192
|
+
try:
|
|
193
|
+
return await _RELAY_SEND({"op": op, "target": target, "msg_type": msg_type,
|
|
194
|
+
"content": content, "receive_id_type": receive_id_type})
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
logger.warning("relay send failed: %s", exc)
|
|
197
|
+
return None
|
|
198
|
+
logger.error("cannot send: no Feishu app credentials and no relay connection")
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
async def _feishu_post(url: str, token: str, payload: dict) -> Optional[dict]:
|
|
202
|
+
"""POST to Feishu API; log the response code on error."""
|
|
203
|
+
try:
|
|
204
|
+
import httpx
|
|
205
|
+
async with httpx.AsyncClient(timeout=15) as client:
|
|
206
|
+
resp = await client.post(
|
|
207
|
+
url,
|
|
208
|
+
headers={"Authorization": f"Bearer {token}",
|
|
209
|
+
"Content-Type": "application/json; charset=utf-8"},
|
|
210
|
+
json=payload,
|
|
211
|
+
)
|
|
212
|
+
data = resp.json()
|
|
213
|
+
code = data.get("code", 0)
|
|
214
|
+
if code != 0:
|
|
215
|
+
logger.error("Feishu API error %s: %s url=%s msg_id in payload=%s",
|
|
216
|
+
code, data.get("msg", ""), url.split("/")[-3:],
|
|
217
|
+
payload.get("receive_id", "—"))
|
|
218
|
+
else:
|
|
219
|
+
logger.debug("Feishu API ok: %s", url.split("/")[-2:])
|
|
220
|
+
return data
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
logger.warning("Feishu POST failed: %s", exc)
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def reply_text(message_id: str, text: str) -> None:
|
|
227
|
+
"""Reply to a Feishu message with plain text (auto-truncated at 3000 chars)."""
|
|
228
|
+
if not message_id:
|
|
229
|
+
logger.error("reply_text: empty message_id — cannot reply")
|
|
230
|
+
return
|
|
231
|
+
logger.info("reply_text → message_id=%s len=%d", message_id, len(text))
|
|
232
|
+
await _send_message("reply", message_id, "text", json.dumps({"text": text[:3000]}))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def reply_card(message_id: str, title: str, body: str,
|
|
236
|
+
color: str = "blue", footer: str = "") -> None:
|
|
237
|
+
"""Reply with an interactive card (title + Markdown body)."""
|
|
238
|
+
if not message_id:
|
|
239
|
+
logger.error("reply_card: empty message_id — cannot reply")
|
|
240
|
+
return
|
|
241
|
+
elements = _build_card_elements(body, footer)
|
|
242
|
+
card = {
|
|
243
|
+
"config": {"wide_screen_mode": True},
|
|
244
|
+
"header": {"title": {"tag": "plain_text", "content": title}, "template": color},
|
|
245
|
+
"elements": elements,
|
|
246
|
+
}
|
|
247
|
+
logger.info("reply_card → message_id=%s title=%s", message_id, title[:40])
|
|
248
|
+
result = await _send_message("reply", message_id, "interactive", json.dumps(card))
|
|
249
|
+
# If card failed, fall back to plain text
|
|
250
|
+
if result and result.get("code") != 0:
|
|
251
|
+
logger.info("reply_card: card failed (code %s), falling back to text", result.get("code"))
|
|
252
|
+
await reply_text(message_id, f"【{title}】\n{body}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def reply_or_send(message_id: str, chat_id: str,
|
|
256
|
+
title: str, body: str,
|
|
257
|
+
color: str = "blue", footer: str = "") -> None:
|
|
258
|
+
"""Try reply by message_id first; if that fails, send a new message to chat_id."""
|
|
259
|
+
if message_id:
|
|
260
|
+
result = await _reply_card_raw(message_id, title, body, color, footer)
|
|
261
|
+
if result is not None and result.get("code", 0) == 0:
|
|
262
|
+
return
|
|
263
|
+
logger.warning("reply failed (code=%s), falling back to send_card_to_chat",
|
|
264
|
+
result.get("code") if result else "no response")
|
|
265
|
+
if chat_id:
|
|
266
|
+
await send_card_to_chat(chat_id, title, body, color)
|
|
267
|
+
else:
|
|
268
|
+
logger.error("reply_or_send: both message_id and chat_id empty, cannot send")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _build_card_elements(body: str, footer: str = "") -> list:
|
|
272
|
+
"""Split body into visual sections for richer card layout."""
|
|
273
|
+
import re as _re
|
|
274
|
+
# Split on markdown `---` dividers or `##`/`###` section headers
|
|
275
|
+
_section_re = _re.compile(r'(?m)^[-─]{3,}\s*$')
|
|
276
|
+
raw_sections = _section_re.split(body.strip())
|
|
277
|
+
elements: list = []
|
|
278
|
+
for i, sec in enumerate(raw_sections):
|
|
279
|
+
sec = sec.strip()
|
|
280
|
+
if not sec:
|
|
281
|
+
continue
|
|
282
|
+
# Detect if section starts with a ## header and peel it off
|
|
283
|
+
_hdr_m = _re.match(r'^#{1,3}\s+(.+)\n', sec)
|
|
284
|
+
if _hdr_m:
|
|
285
|
+
hdr_text = _hdr_m.group(1).strip()
|
|
286
|
+
sec_body = sec[_hdr_m.end():].strip()
|
|
287
|
+
elements.append({"tag": "markdown", "content": f"**{hdr_text}**"})
|
|
288
|
+
if sec_body:
|
|
289
|
+
elements.append({"tag": "div", "text": {"tag": "lark_md", "content": sec_body[:900]}})
|
|
290
|
+
else:
|
|
291
|
+
elements.append({"tag": "div", "text": {"tag": "lark_md", "content": sec[:900]}})
|
|
292
|
+
if i < len(raw_sections) - 1:
|
|
293
|
+
elements.append({"tag": "hr"})
|
|
294
|
+
|
|
295
|
+
if not elements:
|
|
296
|
+
elements = [{"tag": "div", "text": {"tag": "lark_md", "content": body[:2000]}}]
|
|
297
|
+
|
|
298
|
+
if footer:
|
|
299
|
+
elements += [{"tag": "hr"}, {"tag": "note", "elements": [
|
|
300
|
+
{"tag": "plain_text", "content": footer}
|
|
301
|
+
]}]
|
|
302
|
+
return elements
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
async def _reply_card_raw(message_id: str, title: str, body: str,
|
|
306
|
+
color: str = "blue", footer: str = "") -> Optional[dict]:
|
|
307
|
+
"""Reply with a card; return raw API response dict."""
|
|
308
|
+
elements = _build_card_elements(body, footer)
|
|
309
|
+
card = {
|
|
310
|
+
"config": {"wide_screen_mode": True},
|
|
311
|
+
"header": {"title": {"tag": "plain_text", "content": title}, "template": color},
|
|
312
|
+
"elements": elements,
|
|
313
|
+
}
|
|
314
|
+
return await _send_message("reply", message_id, "interactive", json.dumps(card))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def send_card_to_chat(chat_id: str, title: str, body: str,
|
|
318
|
+
color: str = "blue", receive_id_type: str = "chat_id") -> bool:
|
|
319
|
+
"""Send a new card message to a chat (group or user). True if Feishu accepted it.
|
|
320
|
+
|
|
321
|
+
The content is the card itself. It was wrapped as {"card": ...} — the
|
|
322
|
+
custom-webhook format, which /im/v1/messages rejects — and the response
|
|
323
|
+
was never read, so every proactive send failed without a trace. Nothing
|
|
324
|
+
scheduled depended on it until the shipper digest.
|
|
325
|
+
"""
|
|
326
|
+
elements = _build_card_elements(body)
|
|
327
|
+
card = {
|
|
328
|
+
"config": {"wide_screen_mode": True},
|
|
329
|
+
"header": {"title": {"tag": "plain_text", "content": title}, "template": color},
|
|
330
|
+
"elements": elements,
|
|
331
|
+
}
|
|
332
|
+
result = await _send_message("send", chat_id, "interactive", json.dumps(card), receive_id_type) or {}
|
|
333
|
+
if result.get("code") != 0:
|
|
334
|
+
logger.warning("send_card_to_chat rejected: code=%s msg=%s", result.get("code"), result.get("msg"))
|
|
335
|
+
return False
|
|
336
|
+
return True
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ── Command router ─────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
async def _handle_command(cmd: str, message_id: str, sender_id: str, chat_id: str = "") -> None:
|
|
342
|
+
"""Parse a command string and reply with structured card."""
|
|
343
|
+
parts = cmd.strip().split()
|
|
344
|
+
if not parts:
|
|
345
|
+
return
|
|
346
|
+
verb = parts[0].lstrip("/").lower()
|
|
347
|
+
|
|
348
|
+
if verb == "help":
|
|
349
|
+
body = (
|
|
350
|
+
"**💬 直接发消息** — 自然语言提问,Aria AI 直接回答\n"
|
|
351
|
+
"**🎤 语音消息** — 自动转文字后 AI 分析\n"
|
|
352
|
+
"**🖼️ 图片** — 自动识别图表/截图内容\n"
|
|
353
|
+
"**📄 文件** — PDF/Excel/Word/代码 自动解析后 AI 总结\n\n"
|
|
354
|
+
"**结构化命令:**\n"
|
|
355
|
+
"`/price <symbol>` — 实时价格(支持 A 股 6 位代码)\n"
|
|
356
|
+
"`/brief` — 今日晨报摘要\n"
|
|
357
|
+
"`/report <symbol>` — 个股研报(异步推送)\n"
|
|
358
|
+
"`/team <symbol>` — 🤖 多Agent研究(宏观+基本面+技术+风控)\n"
|
|
359
|
+
"`/run <aria命令>` — 执行任意 Aria 命令,如 `/run /corr AAPL TSLA`\n"
|
|
360
|
+
"`/alert add <symbol> <cond> <value>` — 添加价格预警\n"
|
|
361
|
+
" 条件: `price_above` `price_below` `pct_change_above` `pct_change_below`\n"
|
|
362
|
+
"`/alerts` — 查看所有预警\n"
|
|
363
|
+
"`/status` — Daemon 运行状态\n"
|
|
364
|
+
"`/help` — 显示此帮助"
|
|
365
|
+
)
|
|
366
|
+
await reply_card(message_id, "📖 Aria 帮助", body, "blue")
|
|
367
|
+
|
|
368
|
+
elif verb == "price":
|
|
369
|
+
symbol = parts[1].upper() if len(parts) > 1 else ""
|
|
370
|
+
if not symbol:
|
|
371
|
+
await reply_text(message_id, "用法: /price <symbol>,例如 /price AAPL 或 /price 600036")
|
|
372
|
+
return
|
|
373
|
+
await reply_card(message_id, f"🔄 查询 {symbol}…", "正在获取实时行情,请稍候…", "blue")
|
|
374
|
+
try:
|
|
375
|
+
title, body, color = _market.price_card(await _market.quote(symbol))
|
|
376
|
+
await reply_card(message_id, title, body, color)
|
|
377
|
+
except Exception as exc:
|
|
378
|
+
await reply_card(message_id, f"❌ {symbol}", f"查询失败: {str(exc)[:300]}", "red")
|
|
379
|
+
|
|
380
|
+
elif verb == "brief":
|
|
381
|
+
await reply_card(message_id, "⏳ 生成晨报…", "正在获取市场数据,请稍候…", "blue")
|
|
382
|
+
try:
|
|
383
|
+
title, brief = await _market.brief("morning-brief")
|
|
384
|
+
await reply_card(message_id, title, brief[:2000], "green",
|
|
385
|
+
footer="Aria Code · 实时市场分析")
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
await reply_card(message_id, "❌ 晨报生成失败", str(exc)[:300], "red")
|
|
388
|
+
|
|
389
|
+
elif verb == "report":
|
|
390
|
+
symbol = parts[1].upper() if len(parts) > 1 else ""
|
|
391
|
+
if not symbol:
|
|
392
|
+
await reply_text(message_id, "用法: /report <symbol>,例如 /report NVDA")
|
|
393
|
+
return
|
|
394
|
+
await reply_card(message_id, f"⏳ 生成 {symbol} 研报…",
|
|
395
|
+
"正在进行多维度分析,通常需要 30-60 秒,完成后推送结果。", "blue")
|
|
396
|
+
asyncio.create_task(_async_report(symbol, message_id))
|
|
397
|
+
|
|
398
|
+
elif verb == "alert":
|
|
399
|
+
if len(parts) >= 5 and parts[1].lower() == "add":
|
|
400
|
+
sym, cond, val = parts[2].upper(), parts[3], parts[4]
|
|
401
|
+
if cond not in _market.CONDITIONS:
|
|
402
|
+
await reply_card(message_id, "❌ 无效条件",
|
|
403
|
+
"支持的条件:\n" + "\n".join(f"• `{c}`" for c in _market.CONDITIONS), "red")
|
|
404
|
+
return
|
|
405
|
+
try:
|
|
406
|
+
await _market.add_alert(sym, cond, val)
|
|
407
|
+
except Exception as exc:
|
|
408
|
+
await reply_card(message_id, "❌ 预警添加失败", str(exc)[:300], "red")
|
|
409
|
+
return
|
|
410
|
+
await reply_card(message_id, "✅ 预警已添加",
|
|
411
|
+
f"**{sym}** 当 {cond.replace('_',' ')} `{val}` 时触发通知", "green")
|
|
412
|
+
else:
|
|
413
|
+
await reply_text(message_id, "用法: /alert add <symbol> <cond> <value>")
|
|
414
|
+
|
|
415
|
+
elif verb == "alerts":
|
|
416
|
+
try:
|
|
417
|
+
table = await _market.list_alerts()
|
|
418
|
+
except Exception as exc:
|
|
419
|
+
await reply_card(message_id, "❌ 预警读取失败", str(exc)[:300], "red")
|
|
420
|
+
return
|
|
421
|
+
rows = table.splitlines()[1:]
|
|
422
|
+
body = "暂无预警" if not rows else "```\n" + table[:1900] + "\n```"
|
|
423
|
+
await reply_card(message_id, f"📋 预警({len(rows)} 条)", body, "blue")
|
|
424
|
+
|
|
425
|
+
elif verb == "status":
|
|
426
|
+
try:
|
|
427
|
+
text = await _market.status()
|
|
428
|
+
except Exception as exc:
|
|
429
|
+
await reply_card(message_id, "📡 Aria Daemon 状态", str(exc)[:300], "red")
|
|
430
|
+
return
|
|
431
|
+
running = "not running" not in text.splitlines()[0] if text else False
|
|
432
|
+
await reply_card(message_id, "📡 Aria Daemon 状态", text[:2000],
|
|
433
|
+
"green" if running else "red")
|
|
434
|
+
elif verb == "run":
|
|
435
|
+
# /run <aria-command> e.g. /run /price AAPL /run /corr AAPL TSLA NVDA
|
|
436
|
+
sub_cmd = " ".join(parts[1:]).strip() if len(parts) > 1 else ""
|
|
437
|
+
if not sub_cmd:
|
|
438
|
+
await reply_text(message_id, "用法: /run <aria命令>,例如 `/run /price AAPL`")
|
|
439
|
+
return
|
|
440
|
+
await reply_card(message_id, f"⚙️ 执行: {sub_cmd[:60]}", "正在运行,请稍候…", "blue")
|
|
441
|
+
asyncio.create_task(_async_run_aria(sub_cmd, message_id))
|
|
442
|
+
|
|
443
|
+
else:
|
|
444
|
+
await reply_card(message_id, "❓ 未知命令",
|
|
445
|
+
f"不认识 `/{verb}`,发送 `/help` 查看全部命令", "yellow")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
async def _async_report(symbol: str, message_id: str) -> None:
|
|
449
|
+
"""Background task: a research note from the engine (financial-service tools)."""
|
|
450
|
+
runtime = _engine.find_engine()
|
|
451
|
+
if not runtime:
|
|
452
|
+
await reply_card(message_id, f"❌ {symbol} 研报失败", "未找到 aria-runtime", "red")
|
|
453
|
+
return
|
|
454
|
+
prompt = (f"为 {symbol} 写一份简明研报:最新行情、技术面(趋势、RSI、MACD)、"
|
|
455
|
+
"基本面要点、主要风险,以及结论。只用工具取到的数据,注明来源与日期。")
|
|
456
|
+
result = await _engine.run_turn(prompt, engine=runtime, owner=_TURN_OWNER.get(), timeout=240)
|
|
457
|
+
failed = result.startswith(("❌", "⏱️"))
|
|
458
|
+
await reply_card(message_id, f"{'❌' if failed else '📄'} {symbol} 研报{'失败' if failed else '完成'}",
|
|
459
|
+
result[:2000], "red" if failed else "turquoise", footer="Aria Code · 研报")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def _async_run_aria(cmd: str, message_id: str) -> None:
|
|
463
|
+
"""Background task: run aria CLI command and reply with result."""
|
|
464
|
+
result = await _query_aria_llm(cmd, timeout=120)
|
|
465
|
+
color = "red" if result.startswith("❌") or result.startswith("⏱️") else "turquoise"
|
|
466
|
+
await reply_card(message_id, "✅ Aria 执行完成", result[:2000], color,
|
|
467
|
+
footer=f"命令: {cmd[:80]}")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ── Multimodal helpers ────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
def _is_allowed_user(*sender_ids: str) -> bool:
|
|
473
|
+
"""Whether any of the sender's ids (user_id, open_id) is on the allowlist.
|
|
474
|
+
|
|
475
|
+
Fails closed: an empty FEISHU_ALLOWED_USER_IDS allows nobody. It used to
|
|
476
|
+
allow everybody, and the bot runs aria with tools — so an unconfigured
|
|
477
|
+
deployment answered anyone who could message it.
|
|
478
|
+
"""
|
|
479
|
+
raw = os.environ.get("FEISHU_ALLOWED_USER_IDS", "")
|
|
480
|
+
allowed = {u.strip() for u in raw.split(",") if u.strip()}
|
|
481
|
+
return any(i in allowed for i in sender_ids if i)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _bot_cli_flags() -> list[str]:
|
|
485
|
+
"""Extra aria CLI flags for bot-run commands: only an explicit tool allowlist.
|
|
486
|
+
|
|
487
|
+
Bot mode no longer approves every tool. A tool that needs confirmation is
|
|
488
|
+
refused, because nobody in the chat can answer the prompt; an operator who
|
|
489
|
+
needs one names it in ARIA_BOT_ALLOW_TOOLS.
|
|
490
|
+
"""
|
|
491
|
+
tools = [t.strip() for t in os.environ.get("ARIA_BOT_ALLOW_TOOLS", "").split(",") if t.strip()]
|
|
492
|
+
return ["--allow-tools", ",".join(tools)] if tools else []
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
async def _query_aria_direct(text: str, timeout: int = 90, history: Optional[list] = None) -> str:
|
|
496
|
+
"""
|
|
497
|
+
Query the LLM directly via providers/llm/registry.py — no subprocess, no tool use.
|
|
498
|
+
Used for conversational NL queries where we want a clean text answer.
|
|
499
|
+
Falls back to _query_aria_llm on ImportError.
|
|
500
|
+
|
|
501
|
+
With the Go engine installed, the turn runs there instead.
|
|
502
|
+
"""
|
|
503
|
+
runtime = _engine.find_engine()
|
|
504
|
+
if runtime:
|
|
505
|
+
return await _engine.run_turn(text, engine=runtime, owner=_TURN_OWNER.get(),
|
|
506
|
+
history=history, timeout=timeout)
|
|
507
|
+
try:
|
|
508
|
+
import sys as _sys
|
|
509
|
+
_sys.path.insert(0, str(_ARIA_CODE_DIR))
|
|
510
|
+
from providers.llm.registry import stream_cloud_fallback
|
|
511
|
+
import asyncio as _aio
|
|
512
|
+
|
|
513
|
+
collected: list[str] = []
|
|
514
|
+
result = await _aio.wait_for(
|
|
515
|
+
stream_cloud_fallback(text, history=list(history or []), on_token=collected.append),
|
|
516
|
+
timeout=timeout,
|
|
517
|
+
)
|
|
518
|
+
if result.get("success") and collected:
|
|
519
|
+
return "".join(collected).strip()
|
|
520
|
+
if result.get("response"):
|
|
521
|
+
return result["response"].strip()
|
|
522
|
+
return "❌ LLM 返回空响应,请检查 API Key 配置。"
|
|
523
|
+
except ImportError:
|
|
524
|
+
pass # fall through to subprocess
|
|
525
|
+
except Exception as _exc:
|
|
526
|
+
logger.warning("_query_aria_direct failed: %s", _exc)
|
|
527
|
+
return await _query_aria_llm(text, timeout=timeout)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
async def _query_aria_llm(text: str, timeout: int = 120) -> str:
|
|
531
|
+
"""
|
|
532
|
+
Run a natural language query through aria-code's LLM via CLI -p mode.
|
|
533
|
+
Returns plain text output (ANSI stripped, tool noise filtered).
|
|
534
|
+
Use _query_aria_direct() for conversational queries — this is for
|
|
535
|
+
slash commands (/brief, /team, etc.) that need the full CLI context.
|
|
536
|
+
"""
|
|
537
|
+
runtime = _engine.find_engine()
|
|
538
|
+
if runtime:
|
|
539
|
+
return await _engine.run_turn(text, engine=runtime, owner=_TURN_OWNER.get(), timeout=timeout)
|
|
540
|
+
# No Go engine: the Python CLI, while aria-code still ships one.
|
|
541
|
+
aria_cli = _ARIA_CODE_DIR / "aria_cli.py"
|
|
542
|
+
if not aria_cli.exists():
|
|
543
|
+
return "❌ 未找到 Aria 引擎:请安装 aria-runtime,或用 ARIA_RUNTIME 指向它。"
|
|
544
|
+
try:
|
|
545
|
+
# ARIA_BOT_MODE=1: plain output for a chat card. It no longer approves
|
|
546
|
+
# tools; see _bot_cli_flags.
|
|
547
|
+
bot_env = {**os.environ, "ARIA_BOT_MODE": "1"}
|
|
548
|
+
# A conversation bound to a shipper confines every tool in this run to
|
|
549
|
+
# that shipper; the logistics tools enforce it (logistics_tenancy).
|
|
550
|
+
bot_env.pop(OWNER_SCOPE_ENV, None)
|
|
551
|
+
if _TURN_OWNER.get():
|
|
552
|
+
bot_env[OWNER_SCOPE_ENV] = _TURN_OWNER.get()
|
|
553
|
+
proc = await asyncio.create_subprocess_exec(
|
|
554
|
+
sys.executable, str(aria_cli), "-p", text, *_bot_cli_flags(),
|
|
555
|
+
stdin=asyncio.subprocess.DEVNULL, # no interactive prompts
|
|
556
|
+
stdout=asyncio.subprocess.PIPE,
|
|
557
|
+
stderr=asyncio.subprocess.PIPE,
|
|
558
|
+
cwd=str(_ARIA_CODE_DIR),
|
|
559
|
+
env=bot_env,
|
|
560
|
+
)
|
|
561
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
562
|
+
raw = stdout.decode("utf-8", errors="replace")
|
|
563
|
+
# Strip ANSI codes, filter noise lines, collapse blank runs
|
|
564
|
+
clean_lines = []
|
|
565
|
+
blank_run = 0
|
|
566
|
+
for line in _ANSI_RE.sub("", raw).splitlines():
|
|
567
|
+
if _BOT_NOISE_RE.match(line):
|
|
568
|
+
continue
|
|
569
|
+
line = _INLINE_TIMING_RE.sub("", line)
|
|
570
|
+
line = _INLINE_RICH_TAG_RE.sub("", line)
|
|
571
|
+
if not line.strip():
|
|
572
|
+
blank_run += 1
|
|
573
|
+
if blank_run > 1: # collapse consecutive blank lines to one
|
|
574
|
+
continue
|
|
575
|
+
else:
|
|
576
|
+
blank_run = 0
|
|
577
|
+
clean_lines.append(line)
|
|
578
|
+
clean = "\n".join(clean_lines).strip()
|
|
579
|
+
return clean[:3000] if clean else (stderr.decode()[:500] or "(无输出)")
|
|
580
|
+
except asyncio.TimeoutError:
|
|
581
|
+
try: proc.kill()
|
|
582
|
+
except Exception: pass
|
|
583
|
+
return f"⏱️ 查询超时(>{timeout}s),请简化问题后重试。"
|
|
584
|
+
except Exception as exc:
|
|
585
|
+
return f"❌ aria LLM 调用失败: {exc}"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
async def _download_feishu_resource(message_id: str, resource_key: str,
|
|
589
|
+
rtype: str = "file") -> Optional[bytes]:
|
|
590
|
+
"""Download image / audio / file from Feishu message."""
|
|
591
|
+
token = await _get_access_token()
|
|
592
|
+
if not token:
|
|
593
|
+
return None
|
|
594
|
+
try:
|
|
595
|
+
import httpx
|
|
596
|
+
async with httpx.AsyncClient(timeout=60) as client:
|
|
597
|
+
resp = await client.get(
|
|
598
|
+
f"{_FEISHU_API}/im/v1/messages/{message_id}/resources/{resource_key}",
|
|
599
|
+
params={"type": rtype},
|
|
600
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
601
|
+
)
|
|
602
|
+
if resp.status_code == 200:
|
|
603
|
+
return resp.content
|
|
604
|
+
except Exception as exc:
|
|
605
|
+
logger.warning("_download_feishu_resource failed: %s", exc)
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
async def _transcribe_voice(audio_bytes: bytes) -> str:
|
|
610
|
+
"""Speech-to-text: tries OpenAI Whisper API, then local faster-whisper."""
|
|
611
|
+
api_key = os.environ.get("OPENAI_API_KEY", "")
|
|
612
|
+
if api_key:
|
|
613
|
+
try:
|
|
614
|
+
import httpx
|
|
615
|
+
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
|
|
616
|
+
f.write(audio_bytes)
|
|
617
|
+
tmp = f.name
|
|
618
|
+
with open(tmp, "rb") as audio_file:
|
|
619
|
+
async with httpx.AsyncClient(timeout=60) as client:
|
|
620
|
+
resp = await client.post(
|
|
621
|
+
"https://api.openai.com/v1/audio/transcriptions",
|
|
622
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
623
|
+
data={"model": "whisper-1"},
|
|
624
|
+
files={"file": ("voice.ogg", audio_file, "audio/ogg")},
|
|
625
|
+
)
|
|
626
|
+
data = resp.json()
|
|
627
|
+
return data.get("text", "(识别结果为空)")
|
|
628
|
+
except Exception as exc:
|
|
629
|
+
logger.warning("OpenAI Whisper failed: %s", exc)
|
|
630
|
+
|
|
631
|
+
# Fallback: local faster-whisper / openai-whisper
|
|
632
|
+
try:
|
|
633
|
+
from faster_whisper import WhisperModel
|
|
634
|
+
model = WhisperModel("base", device="cpu", compute_type="int8")
|
|
635
|
+
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
|
|
636
|
+
f.write(audio_bytes)
|
|
637
|
+
tmp = f.name
|
|
638
|
+
segments, _ = model.transcribe(tmp, language="zh")
|
|
639
|
+
return " ".join(seg.text for seg in segments).strip() or "(识别结果为空)"
|
|
640
|
+
except ImportError:
|
|
641
|
+
pass
|
|
642
|
+
|
|
643
|
+
return "❌ 语音转文字需要配置 OPENAI_API_KEY,或安装 faster-whisper (`pip install faster-whisper`)"
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
async def _analyze_image(image_bytes: bytes, caption: str = "") -> str:
|
|
647
|
+
"""Analyze image via Claude vision (ANTHROPIC_API_KEY) or GPT-4V (OPENAI_API_KEY)."""
|
|
648
|
+
b64 = base64.b64encode(image_bytes).decode()
|
|
649
|
+
prompt = caption or "请详细分析这张图片的内容。如果是图表、K线图或截图,请做专业解读并给出结论。"
|
|
650
|
+
|
|
651
|
+
anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
|
652
|
+
if anthropic_key:
|
|
653
|
+
try:
|
|
654
|
+
import httpx
|
|
655
|
+
async with httpx.AsyncClient(timeout=60) as client:
|
|
656
|
+
resp = await client.post(
|
|
657
|
+
"https://api.anthropic.com/v1/messages",
|
|
658
|
+
headers={
|
|
659
|
+
"x-api-key": anthropic_key,
|
|
660
|
+
"anthropic-version": "2023-06-01",
|
|
661
|
+
"content-type": "application/json",
|
|
662
|
+
},
|
|
663
|
+
json={
|
|
664
|
+
"model": "claude-sonnet-4-6",
|
|
665
|
+
"max_tokens": 1500,
|
|
666
|
+
"messages": [{
|
|
667
|
+
"role": "user",
|
|
668
|
+
"content": [
|
|
669
|
+
{"type": "image", "source": {
|
|
670
|
+
"type": "base64", "media_type": "image/jpeg", "data": b64}},
|
|
671
|
+
{"type": "text", "text": prompt},
|
|
672
|
+
],
|
|
673
|
+
}],
|
|
674
|
+
},
|
|
675
|
+
)
|
|
676
|
+
data = resp.json()
|
|
677
|
+
return data["content"][0]["text"]
|
|
678
|
+
except Exception as exc:
|
|
679
|
+
logger.warning("Claude vision failed: %s", exc)
|
|
680
|
+
|
|
681
|
+
openai_key = os.environ.get("OPENAI_API_KEY", "")
|
|
682
|
+
if openai_key:
|
|
683
|
+
try:
|
|
684
|
+
import httpx
|
|
685
|
+
async with httpx.AsyncClient(timeout=60) as client:
|
|
686
|
+
resp = await client.post(
|
|
687
|
+
"https://api.openai.com/v1/chat/completions",
|
|
688
|
+
headers={"Authorization": f"Bearer {openai_key}"},
|
|
689
|
+
json={
|
|
690
|
+
"model": "gpt-4o",
|
|
691
|
+
"max_tokens": 1500,
|
|
692
|
+
"messages": [{
|
|
693
|
+
"role": "user",
|
|
694
|
+
"content": [
|
|
695
|
+
{"type": "image_url", "image_url": {
|
|
696
|
+
"url": f"data:image/jpeg;base64,{b64}"}},
|
|
697
|
+
{"type": "text", "text": prompt},
|
|
698
|
+
],
|
|
699
|
+
}],
|
|
700
|
+
},
|
|
701
|
+
)
|
|
702
|
+
data = resp.json()
|
|
703
|
+
return data["choices"][0]["message"]["content"]
|
|
704
|
+
except Exception as exc:
|
|
705
|
+
logger.warning("GPT-4V failed: %s", exc)
|
|
706
|
+
|
|
707
|
+
return "❌ 图片分析需要配置 ANTHROPIC_API_KEY 或 OPENAI_API_KEY"
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
async def _analyze_file(file_bytes: bytes, filename: str) -> str:
|
|
711
|
+
"""Extract text from a file and query Aria LLM for analysis."""
|
|
712
|
+
ext = Path(filename).suffix.lower()
|
|
713
|
+
text_content = ""
|
|
714
|
+
|
|
715
|
+
if ext == ".pdf":
|
|
716
|
+
try:
|
|
717
|
+
import pdfplumber
|
|
718
|
+
import io
|
|
719
|
+
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
|
720
|
+
pages = [p.extract_text() or "" for p in pdf.pages[:20]]
|
|
721
|
+
text_content = "\n".join(pages)[:8000]
|
|
722
|
+
except ImportError:
|
|
723
|
+
try:
|
|
724
|
+
import pypdf
|
|
725
|
+
import io
|
|
726
|
+
reader = pypdf.PdfReader(io.BytesIO(file_bytes))
|
|
727
|
+
text_content = "\n".join(
|
|
728
|
+
p.extract_text() or "" for p in reader.pages[:20]
|
|
729
|
+
)[:8000]
|
|
730
|
+
except Exception as exc:
|
|
731
|
+
return f"❌ PDF 解析失败: {exc}"
|
|
732
|
+
|
|
733
|
+
elif ext in (".xlsx", ".xls"):
|
|
734
|
+
try:
|
|
735
|
+
import openpyxl
|
|
736
|
+
import io
|
|
737
|
+
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True)
|
|
738
|
+
rows = []
|
|
739
|
+
for sheet in wb.sheetnames[:3]:
|
|
740
|
+
ws = wb[sheet]
|
|
741
|
+
for row in list(ws.iter_rows(values_only=True))[:50]:
|
|
742
|
+
rows.append("\t".join(str(c) for c in row if c is not None))
|
|
743
|
+
text_content = f"[Excel: {filename}]\n" + "\n".join(rows)[:6000]
|
|
744
|
+
except Exception as exc:
|
|
745
|
+
return f"❌ Excel 解析失败: {exc}"
|
|
746
|
+
|
|
747
|
+
elif ext in (".docx",):
|
|
748
|
+
try:
|
|
749
|
+
import docx
|
|
750
|
+
import io
|
|
751
|
+
doc = docx.Document(io.BytesIO(file_bytes))
|
|
752
|
+
text_content = "\n".join(p.text for p in doc.paragraphs)[:8000]
|
|
753
|
+
except Exception as exc:
|
|
754
|
+
return f"❌ Word 解析失败: {exc}"
|
|
755
|
+
|
|
756
|
+
elif ext in (".py", ".js", ".ts", ".go", ".java", ".cpp", ".c", ".rs",
|
|
757
|
+
".txt", ".md", ".json", ".yaml", ".yml", ".csv", ".toml"):
|
|
758
|
+
try:
|
|
759
|
+
text_content = file_bytes.decode("utf-8", errors="replace")[:8000]
|
|
760
|
+
except Exception:
|
|
761
|
+
return "❌ 文件编码无法识别"
|
|
762
|
+
|
|
763
|
+
else:
|
|
764
|
+
try:
|
|
765
|
+
text_content = file_bytes.decode("utf-8", errors="replace")[:4000]
|
|
766
|
+
except Exception:
|
|
767
|
+
return f"❌ 不支持的文件类型: {ext}"
|
|
768
|
+
|
|
769
|
+
if not text_content.strip():
|
|
770
|
+
return "❌ 文件内容为空或无法提取文本"
|
|
771
|
+
|
|
772
|
+
query = (
|
|
773
|
+
f"请分析以下文件({filename})的内容,给出主要信息、关键数据和洞察总结:\n\n"
|
|
774
|
+
f"```\n{text_content[:6000]}\n```"
|
|
775
|
+
)
|
|
776
|
+
return await _query_aria_llm(query, timeout=120)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
# ── Event verifier ────────────────────────────────────────────────────────────
|
|
780
|
+
|
|
781
|
+
def verify_feishu_request(headers: Dict[str, str], body: bytes, payload: Dict[str, Any]) -> tuple[bool, str]:
|
|
782
|
+
"""(trusted, reason) for an event received directly from Feishu.
|
|
783
|
+
|
|
784
|
+
The same two checks the relay applies (go/relay: relay.verify; formerly the Python relay's _verify_feishu_request),
|
|
785
|
+
kept separate because the relay ships without this module's dependencies:
|
|
786
|
+
- Encrypt Key: X-Lark-Signature = sha256(timestamp + nonce + key + body)
|
|
787
|
+
- Verification Token: the token field in the event body
|
|
788
|
+
With neither configured every event is refused, unless
|
|
789
|
+
FEISHU_ALLOW_UNVERIFIED_EVENTS=1 is set for local testing.
|
|
790
|
+
|
|
791
|
+
This replaces verify_feishu_signature, which compared the digest with an
|
|
792
|
+
empty string and was never called: the standalone server dispatched any
|
|
793
|
+
POST, so anyone who could reach the port chose the sender — including one
|
|
794
|
+
on the allowlist.
|
|
795
|
+
"""
|
|
796
|
+
headers = {k.lower(): v for k, v in (headers or {}).items()}
|
|
797
|
+
encrypt_key = os.environ.get("FEISHU_ENCRYPT_KEY", "")
|
|
798
|
+
token = os.environ.get("FEISHU_VERIFICATION_TOKEN", "")
|
|
799
|
+
if encrypt_key:
|
|
800
|
+
signature = headers.get("x-lark-signature", "")
|
|
801
|
+
if not signature:
|
|
802
|
+
return False, "missing X-Lark-Signature"
|
|
803
|
+
digest = hashlib.sha256(
|
|
804
|
+
(headers.get("x-lark-request-timestamp", "") + headers.get("x-lark-request-nonce", "")
|
|
805
|
+
+ encrypt_key).encode("utf-8") + body
|
|
806
|
+
).hexdigest()
|
|
807
|
+
if not hmac.compare_digest(digest, signature):
|
|
808
|
+
return False, "signature mismatch"
|
|
809
|
+
return True, ""
|
|
810
|
+
if token:
|
|
811
|
+
sent = payload.get("token") or (payload.get("header") or {}).get("token", "")
|
|
812
|
+
if not hmac.compare_digest(str(sent), token):
|
|
813
|
+
return False, "verification token mismatch"
|
|
814
|
+
return True, ""
|
|
815
|
+
if os.environ.get("FEISHU_ALLOW_UNVERIFIED_EVENTS", "").strip().lower() in {"1", "true", "yes", "on"}:
|
|
816
|
+
return True, ""
|
|
817
|
+
return False, ("neither FEISHU_ENCRYPT_KEY nor FEISHU_VERIFICATION_TOKEN is set; "
|
|
818
|
+
"unverified events are refused")
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
# ── Conversation layer (apps/channels/conversation.py) ───────────────────────
|
|
822
|
+
|
|
823
|
+
# The shipper the current conversation is bound to, for every task it spawns.
|
|
824
|
+
_TURN_OWNER: ContextVar[Optional[str]] = ContextVar("aria_feishu_turn_owner", default=None)
|
|
825
|
+
_STORE: Optional[ConversationStore] = None
|
|
826
|
+
_MENTION_TOKEN = re.compile(r"@_user_\d+")
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _conversation_store() -> ConversationStore:
|
|
830
|
+
global _STORE
|
|
831
|
+
if _STORE is None or str(_STORE.path) != str(_conversation_db_path()):
|
|
832
|
+
_STORE = ConversationStore(_conversation_db_path())
|
|
833
|
+
return _STORE
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _conversation_db_path() -> Path:
|
|
837
|
+
from aria_bots.channels.conversation import default_db_path
|
|
838
|
+
return default_db_path()
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _strip_mentions(text: str) -> str:
|
|
842
|
+
"""Feishu writes each @ as a placeholder (@_user_1); the model needs the words."""
|
|
843
|
+
return re.sub(r"\s+", " ", _MENTION_TOKEN.sub(" ", text or "")).strip()
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _mentions_bot(message: Dict[str, Any]) -> bool:
|
|
847
|
+
"""Whether a group message addresses this bot.
|
|
848
|
+
|
|
849
|
+
With FEISHU_BOT_OPEN_ID set, the bot's own id must be among the mentions.
|
|
850
|
+
Without it, any mention counts — correct for an app without the
|
|
851
|
+
read-all-group-messages permission, which receives only @-mentions of
|
|
852
|
+
itself; set the id if the app has that permission.
|
|
853
|
+
"""
|
|
854
|
+
mentions = message.get("mentions") or []
|
|
855
|
+
own_id = os.environ.get("FEISHU_BOT_OPEN_ID", "").strip()
|
|
856
|
+
if own_id:
|
|
857
|
+
return any((m.get("id") or {}).get("open_id") == own_id for m in mentions)
|
|
858
|
+
return bool(mentions)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def feishu_inbound(event: Dict[str, Any], text: str) -> InboundMessage:
|
|
862
|
+
"""A Feishu im.message.receive_v1 event as a platform-neutral message."""
|
|
863
|
+
message = event.get("message") or {}
|
|
864
|
+
sender = (event.get("sender") or {}).get("sender_id") or {}
|
|
865
|
+
open_id = sender.get("open_id", "")
|
|
866
|
+
user_id = sender.get("user_id", "")
|
|
867
|
+
direct = message.get("chat_type") == "p2p"
|
|
868
|
+
return InboundMessage(
|
|
869
|
+
channel="feishu",
|
|
870
|
+
conversation_id=message.get("chat_id", ""),
|
|
871
|
+
kind="direct" if direct else "group",
|
|
872
|
+
sender_id=open_id or user_id,
|
|
873
|
+
sender_aliases=(user_id,) if open_id and user_id else (),
|
|
874
|
+
text=text,
|
|
875
|
+
message_id=message.get("message_id", ""),
|
|
876
|
+
mentions_bot=direct or _mentions_bot(message),
|
|
877
|
+
reply_to=message.get("parent_id", ""),
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
async def _send_digest_now(conversation: str, message_id: str) -> None:
|
|
882
|
+
"""/digest: this group's shipper digest, now, as a reply."""
|
|
883
|
+
from aria_bots.channels.digest import build_digest, load_feeds
|
|
884
|
+
|
|
885
|
+
owner = _conversation_store().owner_for(conversation)
|
|
886
|
+
if not owner:
|
|
887
|
+
await reply_text(message_id, "本群未绑定货主,没有可发送的简报。管理员可发送 /owner <货主ID> 绑定。")
|
|
888
|
+
return
|
|
889
|
+
try:
|
|
890
|
+
digest = build_digest(owner, load_feeds())
|
|
891
|
+
except Exception as exc:
|
|
892
|
+
logger.warning("digest for %s failed: %s", owner, exc)
|
|
893
|
+
digest = None
|
|
894
|
+
if digest is None or digest.problems and not digest.worth_sending:
|
|
895
|
+
# The details are for the operator's log, not the client's group.
|
|
896
|
+
await reply_text(message_id, "暂时无法生成简报,已记录到运维日志。")
|
|
897
|
+
return
|
|
898
|
+
if not digest.worth_sending:
|
|
899
|
+
await reply_text(message_id, f"{owner}:今天没有需要处理的补货、呆滞或运费异常。")
|
|
900
|
+
return
|
|
901
|
+
await reply_card(message_id, digest.title, digest.body, "orange")
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
# ── Approvals (apps/channels/approvals.py) ────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
def _approval_card(approval) -> Dict[str, Any]:
|
|
907
|
+
"""The request as a card: buttons while pending, the outcome once decided."""
|
|
908
|
+
from aria_bots.channels.approvals import PENDING, status_label
|
|
909
|
+
|
|
910
|
+
elements = _build_card_elements(approval.summary)
|
|
911
|
+
if approval.status == PENDING:
|
|
912
|
+
elements.append({"tag": "note", "elements": [{"tag": "plain_text", "content":
|
|
913
|
+
f"审批编号 {approval.id[:8]} · 24 小时内有效 · 批准后生成采购单草稿文件,不会自动下单"}]})
|
|
914
|
+
elements.append({"tag": "action", "actions": [
|
|
915
|
+
{"tag": "button", "type": "primary", "text": {"tag": "plain_text", "content": "批准"},
|
|
916
|
+
"value": {"aria_approval": approval.id, "decision": "approve"}},
|
|
917
|
+
{"tag": "button", "type": "danger", "text": {"tag": "plain_text", "content": "驳回"},
|
|
918
|
+
"value": {"aria_approval": approval.id, "decision": "reject"}},
|
|
919
|
+
]})
|
|
920
|
+
template = "orange"
|
|
921
|
+
else:
|
|
922
|
+
outcome = f"{status_label(approval.status)} · {approval.decided_by}"
|
|
923
|
+
# The file name, not the host path; a failure's details stay in the
|
|
924
|
+
# approval record and the log, not in the client's group.
|
|
925
|
+
if approval.status == "approved" and approval.result:
|
|
926
|
+
outcome += f"\n已生成:{Path(approval.result).name}"
|
|
927
|
+
elif approval.status == "failed":
|
|
928
|
+
outcome += "\n详情已记录在审批记录中"
|
|
929
|
+
elements.append({"tag": "note", "elements": [{"tag": "plain_text", "content": outcome}]})
|
|
930
|
+
template = "green" if approval.status == "approved" else "grey"
|
|
931
|
+
return {
|
|
932
|
+
"config": {"wide_screen_mode": True, "update_multi": True},
|
|
933
|
+
"header": {"title": {"tag": "plain_text", "content": f"🧾 {approval.owner_id} 采购单草稿"},
|
|
934
|
+
"template": template},
|
|
935
|
+
"elements": elements,
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
async def _request_reorder(inbound: InboundMessage, message_id: str) -> None:
|
|
940
|
+
"""/补货: propose a purchase-order draft from the reorder list, for approval."""
|
|
941
|
+
from aria_bots.channels.approvals import ApprovalStore, propose_reorder
|
|
942
|
+
from aria_bots.channels.digest import feeds_for, load_feeds
|
|
943
|
+
|
|
944
|
+
owner = _conversation_store().owner_for(inbound.key)
|
|
945
|
+
if not owner:
|
|
946
|
+
await reply_text(message_id, "本群未绑定货主,无法发起补货审批。管理员可发送 /owner <货主ID> 绑定。")
|
|
947
|
+
return
|
|
948
|
+
try:
|
|
949
|
+
inventory = feeds_for(owner, load_feeds()).get("inventory")
|
|
950
|
+
if not inventory:
|
|
951
|
+
raise ValueError(f"no inventory feed configured for {owner}")
|
|
952
|
+
proposal = propose_reorder(owner, inventory)
|
|
953
|
+
except Exception as exc:
|
|
954
|
+
logger.warning("reorder proposal for %s failed: %s", owner, exc)
|
|
955
|
+
await reply_text(message_id, "暂时无法生成补货建议,已记录到运维日志。")
|
|
956
|
+
return
|
|
957
|
+
if proposal is None:
|
|
958
|
+
await reply_text(message_id, f"{owner}:当前没有需要补货的 SKU。")
|
|
959
|
+
return
|
|
960
|
+
summary, payload = proposal
|
|
961
|
+
approval = ApprovalStore(_conversation_db_path()).request(
|
|
962
|
+
conversation=inbound.key, owner_id=owner, kind="purchase_order_draft", payload=payload,
|
|
963
|
+
summary=f"**建议订货**\n{summary}\n\n数量未计入起订量和箱规,请审批人核对。",
|
|
964
|
+
requested_by=f"feishu:{inbound.sender_id}",
|
|
965
|
+
)
|
|
966
|
+
await _send_message("reply", message_id, "interactive", json.dumps(_approval_card(approval)))
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
async def _handle_card_action(event: Dict[str, Any], *, authorized_by_binding: bool = False) -> Dict[str, Any]:
|
|
970
|
+
"""An approve/reject press. The response's toast and card are what the presser sees."""
|
|
971
|
+
from aria_bots.channels.approvals import ApprovalStore, can_decide
|
|
972
|
+
|
|
973
|
+
operator = event.get("operator") or {}
|
|
974
|
+
ids = tuple(i for i in (operator.get("open_id", ""), operator.get("user_id", "")) if i)
|
|
975
|
+
value = (event.get("action") or {}).get("value") or {}
|
|
976
|
+
chat_id = (event.get("context") or {}).get("open_chat_id", "")
|
|
977
|
+
approval_id = str(value.get("aria_approval", ""))
|
|
978
|
+
if not approval_id:
|
|
979
|
+
return {}
|
|
980
|
+
|
|
981
|
+
def toast(kind: str, text: str, card=None) -> Dict[str, Any]:
|
|
982
|
+
out: Dict[str, Any] = {"toast": {"type": kind, "content": text}}
|
|
983
|
+
if card is not None:
|
|
984
|
+
out["card"] = {"type": "raw", "data": card}
|
|
985
|
+
return out
|
|
986
|
+
|
|
987
|
+
if not authorized_by_binding and not _is_allowed_user(*ids):
|
|
988
|
+
return toast("error", "你还没有使用 Aria 的权限。")
|
|
989
|
+
conversation = f"feishu:{chat_id}"
|
|
990
|
+
decision = ApprovalStore(_conversation_db_path()).decide(
|
|
991
|
+
approval_id,
|
|
992
|
+
conversation=conversation,
|
|
993
|
+
current_owner=_conversation_store().owner_for(conversation),
|
|
994
|
+
decided_by=f"feishu:{ids[0] if ids else ''}",
|
|
995
|
+
approve=value.get("decision") == "approve",
|
|
996
|
+
authorised=can_decide("feishu", ids),
|
|
997
|
+
)
|
|
998
|
+
card = _approval_card(decision.approval) if decision.approval is not None else None
|
|
999
|
+
return toast("success" if decision.ok else "error", decision.message, card)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
async def send_digest_card(chat_id: str, title: str, body: str) -> bool:
|
|
1003
|
+
"""The Feishu sender for apps.channels.digest.run_digests."""
|
|
1004
|
+
return await send_card_to_chat(chat_id, title, body, "orange")
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
# ── Main event dispatcher (called by feishu_routes.py or standalone) ──────────
|
|
1008
|
+
|
|
1009
|
+
async def dispatch_event(raw: Dict[str, Any], *, authorized_by_binding: bool = False) -> Dict[str, Any]:
|
|
1010
|
+
"""
|
|
1011
|
+
Handle one Feishu event payload.
|
|
1012
|
+
|
|
1013
|
+
authorized_by_binding: the relay client passes True. The relay forwards an
|
|
1014
|
+
event only to the machine its sender bound with a code shown on that
|
|
1015
|
+
machine, so the binding is the authorization; a local allowlist would add
|
|
1016
|
+
nothing, since the relay knows the bound id. What limits a compromised
|
|
1017
|
+
relay is that bot-run commands cannot use confirmation-required tools.
|
|
1018
|
+
Supports: text / audio / image / file / post (富文本)
|
|
1019
|
+
Returns a dict to be sent as JSON response (HTTP 200 required by Feishu).
|
|
1020
|
+
"""
|
|
1021
|
+
# 1. URL verification challenge
|
|
1022
|
+
if "challenge" in raw:
|
|
1023
|
+
return {"challenge": raw["challenge"]}
|
|
1024
|
+
|
|
1025
|
+
header = raw.get("header", {})
|
|
1026
|
+
event = raw.get("event", {})
|
|
1027
|
+
event_type = header.get("event_type") or raw.get("type", "")
|
|
1028
|
+
|
|
1029
|
+
# A press on an approval card's button (callback schema 2.0).
|
|
1030
|
+
if event_type == "card.action.trigger":
|
|
1031
|
+
return await _handle_card_action(event, authorized_by_binding=authorized_by_binding)
|
|
1032
|
+
|
|
1033
|
+
if event_type not in ("im.message.receive_v1", "message"):
|
|
1034
|
+
return {"code": 0}
|
|
1035
|
+
|
|
1036
|
+
msg = event.get("message") or {}
|
|
1037
|
+
msg_id = msg.get("message_id", "")
|
|
1038
|
+
msg_type = msg.get("message_type", "text")
|
|
1039
|
+
sender = event.get("sender", {}).get("sender_id", {})
|
|
1040
|
+
user_id = sender.get("user_id", "") or sender.get("open_id", "")
|
|
1041
|
+
chat_id = msg.get("chat_id", "")
|
|
1042
|
+
|
|
1043
|
+
logger.info("dispatch_event: type=%s msg_id=%s msg_type=%s user=%s chat=%s",
|
|
1044
|
+
event_type, msg_id, msg_type, user_id, chat_id)
|
|
1045
|
+
|
|
1046
|
+
if not msg_id:
|
|
1047
|
+
logger.error("dispatch_event: msg_id is EMPTY — event structure may differ: %s",
|
|
1048
|
+
json.dumps(raw, ensure_ascii=False)[:600])
|
|
1049
|
+
return {"code": 0}
|
|
1050
|
+
|
|
1051
|
+
open_id = sender.get("open_id", "")
|
|
1052
|
+
if not authorized_by_binding and not _is_allowed_user(sender.get("user_id", ""), open_id):
|
|
1053
|
+
logger.warning("Blocked sender user_id=%s open_id=%s (not in FEISHU_ALLOWED_USER_IDS)",
|
|
1054
|
+
sender.get("user_id", ""), open_id)
|
|
1055
|
+
# Say why only in a direct chat: in a group the bot would answer every
|
|
1056
|
+
# message from everyone not on the list.
|
|
1057
|
+
if msg.get("chat_type") == "p2p":
|
|
1058
|
+
await reply_text(
|
|
1059
|
+
msg_id,
|
|
1060
|
+
"⛔ 你还没有使用 Aria 的权限。请把下面的 ID 发给管理员,"
|
|
1061
|
+
f"加入 FEISHU_ALLOWED_USER_IDS:\n{open_id or sender.get('user_id', '')}",
|
|
1062
|
+
)
|
|
1063
|
+
return {"code": 0}
|
|
1064
|
+
|
|
1065
|
+
content_raw = msg.get("content", "{}")
|
|
1066
|
+
try:
|
|
1067
|
+
content = json.loads(content_raw)
|
|
1068
|
+
except Exception:
|
|
1069
|
+
content = {}
|
|
1070
|
+
|
|
1071
|
+
# Groups: only what is addressed to the bot. Feishu delivers just the
|
|
1072
|
+
# @-mentions unless the app holds the read-all-group-messages permission;
|
|
1073
|
+
# with it, this is what stops the bot answering every message in the room.
|
|
1074
|
+
# Images, audio and files cannot @ anyone, so in a group they are ignored.
|
|
1075
|
+
if msg.get("chat_type") != "p2p" and msg_type not in ("text", "post"):
|
|
1076
|
+
return {"code": 0}
|
|
1077
|
+
|
|
1078
|
+
# Attachments are downloaded with the app's credentials, which relay mode
|
|
1079
|
+
# does not have on this machine; say so instead of failing silently.
|
|
1080
|
+
if msg_type in ("audio", "image", "file") and not (
|
|
1081
|
+
os.environ.get("FEISHU_APP_ID") and os.environ.get("FEISHU_APP_SECRET")):
|
|
1082
|
+
await reply_text(msg_id, "中继模式下暂不支持图片、语音和文件,请直接发送文字。")
|
|
1083
|
+
return {"code": 0}
|
|
1084
|
+
|
|
1085
|
+
# ── Text message ──────────────────────────────────────────────────────────
|
|
1086
|
+
if msg_type == "text":
|
|
1087
|
+
text = _strip_mentions(content.get("text", ""))
|
|
1088
|
+
if not text:
|
|
1089
|
+
return {"code": 0}
|
|
1090
|
+
inbound = feishu_inbound(event, text)
|
|
1091
|
+
if not should_respond(inbound):
|
|
1092
|
+
return {"code": 0}
|
|
1093
|
+
|
|
1094
|
+
store = _conversation_store()
|
|
1095
|
+
owner_reply = handle_owner_command(inbound, store)
|
|
1096
|
+
if owner_reply is not None:
|
|
1097
|
+
await reply_text(msg_id, owner_reply)
|
|
1098
|
+
return {"code": 0}
|
|
1099
|
+
if text.strip().lower() in ("/digest", "/简报"):
|
|
1100
|
+
asyncio.create_task(_send_digest_now(inbound.key, msg_id))
|
|
1101
|
+
return {"code": 0}
|
|
1102
|
+
if text.strip().lower() in ("/reorder", "/补货"):
|
|
1103
|
+
asyncio.create_task(_request_reorder(inbound, msg_id))
|
|
1104
|
+
return {"code": 0}
|
|
1105
|
+
|
|
1106
|
+
# A task copies the context it is created in, so every aria subprocess
|
|
1107
|
+
# the tasks below start runs under this conversation's shipper scope.
|
|
1108
|
+
# Reset afterwards: dispatch_event runs in its caller's context.
|
|
1109
|
+
scope_token = _TURN_OWNER.set(store.owner_for(inbound.key))
|
|
1110
|
+
try:
|
|
1111
|
+
if text.startswith("/"):
|
|
1112
|
+
logger.info("Feishu /cmd from %s: %s", user_id, text[:80])
|
|
1113
|
+
asyncio.create_task(_handle_command(text, msg_id, user_id, chat_id))
|
|
1114
|
+
else:
|
|
1115
|
+
# Free-form natural language → Aria LLM, with this conversation's context
|
|
1116
|
+
logger.info("Feishu NL query from %s: %s", user_id, text[:80])
|
|
1117
|
+
turn = prepare_turn(inbound, store)
|
|
1118
|
+
asyncio.create_task(_handle_nl_query(text, msg_id, chat_id, turn=turn,
|
|
1119
|
+
conversation=inbound.key))
|
|
1120
|
+
finally:
|
|
1121
|
+
_TURN_OWNER.reset(scope_token)
|
|
1122
|
+
|
|
1123
|
+
# ── Voice / Audio ─────────────────────────────────────────────────────────
|
|
1124
|
+
elif msg_type == "audio":
|
|
1125
|
+
file_key = content.get("file_key", "")
|
|
1126
|
+
logger.info("Feishu audio from %s, key=%s", user_id, file_key)
|
|
1127
|
+
asyncio.create_task(_handle_audio(file_key, msg_id))
|
|
1128
|
+
|
|
1129
|
+
# ── Image ────────────────────────────────────────────────────────────────
|
|
1130
|
+
elif msg_type == "image":
|
|
1131
|
+
image_key = content.get("image_key", "")
|
|
1132
|
+
logger.info("Feishu image from %s, key=%s", user_id, image_key)
|
|
1133
|
+
asyncio.create_task(_handle_image(image_key, msg_id))
|
|
1134
|
+
|
|
1135
|
+
# ── File attachment ───────────────────────────────────────────────────────
|
|
1136
|
+
elif msg_type == "file":
|
|
1137
|
+
file_key = content.get("file_key", "")
|
|
1138
|
+
file_name = content.get("file_name", "attachment")
|
|
1139
|
+
logger.info("Feishu file from %s: %s", user_id, file_name)
|
|
1140
|
+
asyncio.create_task(_handle_file(file_key, file_name, msg_id))
|
|
1141
|
+
|
|
1142
|
+
# ── Rich text (post) — extract plain text ─────────────────────────────────
|
|
1143
|
+
elif msg_type == "post":
|
|
1144
|
+
try:
|
|
1145
|
+
# post content: {"zh_cn": {"title":"...","content":[[{"tag":"text","text":"..."},...]]}}
|
|
1146
|
+
lang_content = content.get("zh_cn") or content.get("en_us") or {}
|
|
1147
|
+
title = lang_content.get("title", "")
|
|
1148
|
+
paras = lang_content.get("content", [])
|
|
1149
|
+
texts = []
|
|
1150
|
+
for para in paras:
|
|
1151
|
+
for seg in para:
|
|
1152
|
+
if seg.get("tag") == "text":
|
|
1153
|
+
texts.append(seg.get("text", ""))
|
|
1154
|
+
plain = title + ("\n" if title else "") + " ".join(texts)
|
|
1155
|
+
if plain.strip():
|
|
1156
|
+
asyncio.create_task(_handle_nl_query(plain.strip(), msg_id))
|
|
1157
|
+
except Exception as exc:
|
|
1158
|
+
logger.warning("post parse error: %s", exc)
|
|
1159
|
+
|
|
1160
|
+
return {"code": 0}
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
# ── Message type handlers (run as background tasks) ───────────────────────────
|
|
1164
|
+
|
|
1165
|
+
_MARKET_BRIEF_TRIGGERS = frozenset({
|
|
1166
|
+
"行情查询", "市场行情", "股市行情", "今日行情", "行情", "大盘", "大盘行情",
|
|
1167
|
+
"市场概况", "今日市场", "A股行情", "港股行情", "美股行情", "晨报",
|
|
1168
|
+
"market", "market overview", "stock market", "markets today",
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
# ── 常用 A股/港股 中文名 → 股票代码(用于 NL 解析)──────────────────────────
|
|
1173
|
+
_CN_COMPANY_TICKER: dict[str, str] = {
|
|
1174
|
+
# 银行
|
|
1175
|
+
"工商银行": "601398", "工行": "601398",
|
|
1176
|
+
"建设银行": "601939", "建行": "601939",
|
|
1177
|
+
"农业银行": "601288", "农行": "601288",
|
|
1178
|
+
"中国银行": "601988", "中行": "601988",
|
|
1179
|
+
"招商银行": "600036", "招行": "600036",
|
|
1180
|
+
"平安银行": "000001",
|
|
1181
|
+
"兴业银行": "601166",
|
|
1182
|
+
"浦发银行": "600000",
|
|
1183
|
+
"光大银行": "601818",
|
|
1184
|
+
# 券商/保险
|
|
1185
|
+
"中信证券": "600030",
|
|
1186
|
+
"海通证券": "600837",
|
|
1187
|
+
"中国平安": "601318", "平安": "601318",
|
|
1188
|
+
"中国人寿": "601628",
|
|
1189
|
+
# 能源/化工
|
|
1190
|
+
"中国石油": "601857", "中石油": "601857",
|
|
1191
|
+
"中国石化": "600028", "中石化": "600028",
|
|
1192
|
+
"中国神华": "601088",
|
|
1193
|
+
# 有色金属
|
|
1194
|
+
"江西铜业": "600362",
|
|
1195
|
+
"紫金矿业": "601899",
|
|
1196
|
+
"中金黄金": "600489",
|
|
1197
|
+
"山东黄金": "600547",
|
|
1198
|
+
"洛阳钼业": "603993",
|
|
1199
|
+
"铜陵有色": "000630",
|
|
1200
|
+
"中国铝业": "601600",
|
|
1201
|
+
"南方铜业": "SCCO",
|
|
1202
|
+
"自由港": "FCX", "自由港麦克莫兰": "FCX",
|
|
1203
|
+
# 消费
|
|
1204
|
+
"贵州茅台": "600519", "茅台": "600519",
|
|
1205
|
+
"五粮液": "000858",
|
|
1206
|
+
"洋河股份": "002304",
|
|
1207
|
+
"海天味业": "603288",
|
|
1208
|
+
"伊利股份": "600887",
|
|
1209
|
+
"格力电器": "000651", "格力": "000651",
|
|
1210
|
+
"美的集团": "000333", "美的": "000333",
|
|
1211
|
+
"海尔智家": "600690",
|
|
1212
|
+
# 科技/互联网
|
|
1213
|
+
"腾讯": "0700", "腾讯控股": "0700",
|
|
1214
|
+
"阿里巴巴": "BABA", "阿里": "BABA",
|
|
1215
|
+
"京东": "JD",
|
|
1216
|
+
"百度": "BIDU",
|
|
1217
|
+
"比亚迪": "002594",
|
|
1218
|
+
"宁德时代": "300750", "宁德": "300750",
|
|
1219
|
+
"中芯国际": "688981",
|
|
1220
|
+
"海康威视": "002415",
|
|
1221
|
+
# 地产
|
|
1222
|
+
"万科": "000002", "万科A": "000002",
|
|
1223
|
+
"碧桂园": "2007",
|
|
1224
|
+
"恒大": "3333",
|
|
1225
|
+
# 医药
|
|
1226
|
+
"恒瑞医药": "600276",
|
|
1227
|
+
"迈瑞医疗": "300760",
|
|
1228
|
+
# 全球大宗商品矿企
|
|
1229
|
+
"必和必拓": "BHP", "必拓": "BHP",
|
|
1230
|
+
"力拓": "RIO", "力拓集团": "RIO",
|
|
1231
|
+
"淡水河谷": "VALE",
|
|
1232
|
+
"嘉能可": "GLEN.L",
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
def _resolve_cn_company(text: str) -> str:
|
|
1236
|
+
"""
|
|
1237
|
+
Replace Chinese company names in text with their ticker symbols.
|
|
1238
|
+
E.g. "江西铜业的走势" → "600362的走势"
|
|
1239
|
+
Returns modified text; if nothing matched, returns original.
|
|
1240
|
+
"""
|
|
1241
|
+
result = text
|
|
1242
|
+
for cn_name, ticker in _CN_COMPANY_TICKER.items():
|
|
1243
|
+
if cn_name in result:
|
|
1244
|
+
result = result.replace(cn_name, ticker)
|
|
1245
|
+
return result
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
async def _handle_nl_query(text: str, message_id: str, chat_id: str = "", *,
|
|
1249
|
+
turn: Optional[ChannelTurn] = None, conversation: str = "") -> None:
|
|
1250
|
+
"""Route free-form natural language to Aria LLM and reply."""
|
|
1251
|
+
_low = text.strip().lower()
|
|
1252
|
+
_orig = text.strip()
|
|
1253
|
+
|
|
1254
|
+
# Fast-path: generic market brief request — the daemon's brief, no model turn
|
|
1255
|
+
if _low in _MARKET_BRIEF_TRIGGERS or (
|
|
1256
|
+
any(k in _low for k in ("行情查询", "市场行情", "大盘今日", "今日大盘")) and
|
|
1257
|
+
not any(c.isalpha() and c.upper() == c for c in text) # no uppercase ticker
|
|
1258
|
+
):
|
|
1259
|
+
await reply_or_send(message_id, chat_id, "📊 获取市场概况…", "正在抓取主要指数数据…", "blue")
|
|
1260
|
+
try:
|
|
1261
|
+
title, snapshot = await _market.brief("morning-brief")
|
|
1262
|
+
await reply_or_send(message_id, chat_id, title, snapshot[:2000], "turquoise",
|
|
1263
|
+
footer="Aria Code · financial-service")
|
|
1264
|
+
except Exception as _e:
|
|
1265
|
+
await reply_or_send(message_id, chat_id, "❌ 行情获取失败", str(_e)[:200], "red")
|
|
1266
|
+
return
|
|
1267
|
+
|
|
1268
|
+
# Chinese company name → ticker resolution before sending to LLM
|
|
1269
|
+
resolved = _resolve_cn_company(_orig)
|
|
1270
|
+
if resolved != _orig:
|
|
1271
|
+
# Found at least one CN name; also annotate so LLM has context
|
|
1272
|
+
text = resolved + f" (原文: {_orig})"
|
|
1273
|
+
|
|
1274
|
+
await reply_or_send(message_id, chat_id, "🤔 思考中…", f"> {_orig[:120]}", "blue")
|
|
1275
|
+
# Use direct LLM call (no subprocess, no tool execution) for conversational queries.
|
|
1276
|
+
# With a turn, the model sees this conversation's recent messages and, when
|
|
1277
|
+
# the conversation is bound to a shipper, which one.
|
|
1278
|
+
if turn is not None:
|
|
1279
|
+
prompt = turn.prompt if resolved == _orig else f"{turn.prompt}\n({text})"
|
|
1280
|
+
result = await _query_aria_direct(prompt, timeout=120, history=turn.history)
|
|
1281
|
+
if conversation and not result.startswith("❌"):
|
|
1282
|
+
_conversation_store().record(conversation, "assistant", result[:2000])
|
|
1283
|
+
else:
|
|
1284
|
+
result = await _query_aria_direct(text, timeout=120)
|
|
1285
|
+
color = "red" if result.startswith("❌") else "green"
|
|
1286
|
+
await reply_or_send(message_id, chat_id, "💡 Aria 回答", result[:2000], color,
|
|
1287
|
+
footer="Aria Code · AI 分析")
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
async def _handle_audio(file_key: str, message_id: str) -> None:
|
|
1291
|
+
"""Download voice → transcribe → query Aria LLM."""
|
|
1292
|
+
await reply_card(message_id, "🎤 正在转写语音…", "下载中,请稍候…", "blue")
|
|
1293
|
+
audio_bytes = await _download_feishu_resource(message_id, file_key, rtype="file")
|
|
1294
|
+
if not audio_bytes:
|
|
1295
|
+
await reply_card(message_id, "❌ 语音下载失败", "无法获取语音文件", "red")
|
|
1296
|
+
return
|
|
1297
|
+
text = await _transcribe_voice(audio_bytes)
|
|
1298
|
+
if text.startswith("❌"):
|
|
1299
|
+
await reply_card(message_id, "❌ 语音转文字失败", text, "red")
|
|
1300
|
+
return
|
|
1301
|
+
await reply_card(message_id, "🎤 识别结果", f"**语音内容:**\n{text}\n\n---\n正在分析…", "blue")
|
|
1302
|
+
result = await _query_aria_llm(text, timeout=120)
|
|
1303
|
+
await reply_card(message_id, "💡 Aria 回答", result[:2000], "green",
|
|
1304
|
+
footer=f"语音转文字: {text[:60]}…")
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
async def _handle_image(image_key: str, message_id: str) -> None:
|
|
1308
|
+
"""Download image → visual LLM analysis."""
|
|
1309
|
+
await reply_card(message_id, "🖼️ 分析图片中…", "正在下载并识别,请稍候…", "blue")
|
|
1310
|
+
img_bytes = await _download_feishu_resource(message_id, image_key, rtype="image")
|
|
1311
|
+
if not img_bytes:
|
|
1312
|
+
await reply_card(message_id, "❌ 图片下载失败", "无法获取图片", "red")
|
|
1313
|
+
return
|
|
1314
|
+
result = await _analyze_image(img_bytes)
|
|
1315
|
+
await reply_card(message_id, "🖼️ 图片分析", result[:2000], "turquoise",
|
|
1316
|
+
footer="Aria Code · 视觉 AI")
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
async def _handle_file(file_key: str, filename: str, message_id: str) -> None:
|
|
1320
|
+
"""Download file → parse → Aria LLM analysis."""
|
|
1321
|
+
await reply_card(message_id, f"📄 解析文件:{filename}",
|
|
1322
|
+
"正在下载并解析,请稍候…", "blue")
|
|
1323
|
+
file_bytes = await _download_feishu_resource(message_id, file_key, rtype="file")
|
|
1324
|
+
if not file_bytes:
|
|
1325
|
+
await reply_card(message_id, "❌ 文件下载失败", "无法获取文件", "red")
|
|
1326
|
+
return
|
|
1327
|
+
result = await _analyze_file(file_bytes, filename)
|
|
1328
|
+
await reply_card(message_id, f"📄 {filename} 分析完成", result[:2000], "turquoise",
|
|
1329
|
+
footer="Aria Code · 文件智能解析")
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
# ── Standalone HTTP server (for testing without FastAPI) ──────────────────────
|
|
1333
|
+
|
|
1334
|
+
async def _standalone_handle(request):
|
|
1335
|
+
"""POST /feishu/event for the standalone server: verify, then dispatch."""
|
|
1336
|
+
from aiohttp import web
|
|
1337
|
+
|
|
1338
|
+
raw = await request.read()
|
|
1339
|
+
try:
|
|
1340
|
+
body = json.loads(raw)
|
|
1341
|
+
except Exception:
|
|
1342
|
+
return web.json_response({"code": 1, "msg": "bad json"}, status=400)
|
|
1343
|
+
# Before anything else, the URL-verification challenge included: it
|
|
1344
|
+
# carries the token too, and answering it unverified is a free probe.
|
|
1345
|
+
trusted, reason = verify_feishu_request(dict(request.headers), raw, body)
|
|
1346
|
+
if not trusted:
|
|
1347
|
+
logger.warning("refused an unverified Feishu event: %s", reason)
|
|
1348
|
+
return web.json_response({"code": 1, "msg": "unverified event"}, status=401)
|
|
1349
|
+
return web.json_response(await dispatch_event(body))
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _standalone_app():
|
|
1353
|
+
from aiohttp import web
|
|
1354
|
+
|
|
1355
|
+
app = web.Application()
|
|
1356
|
+
app.router.add_post("/feishu/event", _standalone_handle)
|
|
1357
|
+
app.router.add_post("/api/v1/feishu/event", _standalone_handle)
|
|
1358
|
+
return app
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
async def _standalone_server(host: str = "0.0.0.0", port: int = 8888) -> None:
|
|
1362
|
+
"""Minimal aiohttp-based server for standalone Feishu event reception."""
|
|
1363
|
+
try:
|
|
1364
|
+
from aiohttp import web
|
|
1365
|
+
except ImportError:
|
|
1366
|
+
logger.error("aiohttp not installed. pip install aiohttp")
|
|
1367
|
+
return
|
|
1368
|
+
|
|
1369
|
+
app = _standalone_app()
|
|
1370
|
+
|
|
1371
|
+
runner = web.AppRunner(app)
|
|
1372
|
+
await runner.setup()
|
|
1373
|
+
site = web.TCPSite(runner, host, port)
|
|
1374
|
+
await site.start()
|
|
1375
|
+
logger.info("Feishu standalone server listening on http://%s:%d/feishu/event", host, port)
|
|
1376
|
+
logger.info("Configure this URL in Feishu Developer Console → Event Subscription")
|
|
1377
|
+
await asyncio.Event().wait()
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
if __name__ == "__main__":
|
|
1381
|
+
import sys
|
|
1382
|
+
logging.basicConfig(level=logging.INFO,
|
|
1383
|
+
format="%(asctime)s [feishu] %(levelname)s %(message)s")
|
|
1384
|
+
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8888
|
|
1385
|
+
asyncio.run(_standalone_server(port=port))
|