aria-code 4.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. agents/__init__.py +32 -0
  2. agents/base.py +190 -0
  3. agents/deep/__init__.py +37 -0
  4. agents/deep/calibration_loop.py +144 -0
  5. agents/deep/critic.py +125 -0
  6. agents/deep/deepen.py +193 -0
  7. agents/deep/models.py +149 -0
  8. agents/deep/pipeline.py +164 -0
  9. agents/deep/quant_fusion.py +192 -0
  10. agents/deep/themes.py +95 -0
  11. agents/deep/tiers.py +106 -0
  12. agents/financial/__init__.py +10 -0
  13. agents/financial/catalyst.py +279 -0
  14. agents/financial/debate.py +145 -0
  15. agents/financial/earnings.py +303 -0
  16. agents/financial/fundamental.py +159 -0
  17. agents/financial/macro.py +99 -0
  18. agents/financial/news.py +207 -0
  19. agents/financial/risk.py +132 -0
  20. agents/financial/sector.py +279 -0
  21. agents/financial/synthesis.py +274 -0
  22. agents/financial/technical.py +258 -0
  23. agents/portfolio_agent.py +333 -0
  24. agents/realty/__init__.py +62 -0
  25. agents/realty/asset_diagnosis.py +150 -0
  26. agents/realty/business_match.py +165 -0
  27. agents/realty/cashflow_verify.py +208 -0
  28. agents/realty/contract_rules.py +209 -0
  29. agents/realty/energy_anomaly.py +188 -0
  30. agents/realty/exit_settlement.py +207 -0
  31. agents/realty/fulfillment_risk.py +205 -0
  32. agents/realty/ops_optimize.py +159 -0
  33. agents/realty/revenue_share.py +214 -0
  34. agents/registry.py +144 -0
  35. agents/sports/__init__.py +0 -0
  36. agents/sports/football_agent.py +169 -0
  37. agents/team.py +289 -0
  38. aliyun_data_client.py +660 -0
  39. apps/README.md +12 -0
  40. apps/__init__.py +2 -0
  41. apps/channels/README.md +15 -0
  42. apps/cli/README.md +13 -0
  43. apps/cli/__init__.py +2 -0
  44. apps/cli/bootstrap.py +99 -0
  45. apps/cli/codegen_paths.py +29 -0
  46. apps/cli/commands/__init__.py +16 -0
  47. apps/cli/commands/analysis_cmds.py +288 -0
  48. apps/cli/commands/backtest_cmds.py +1887 -0
  49. apps/cli/commands/broker_cmds.py +1154 -0
  50. apps/cli/commands/business_workflow_cmds.py +289 -0
  51. apps/cli/commands/catalog.py +84 -0
  52. apps/cli/commands/data_cmds.py +405 -0
  53. apps/cli/commands/diagnostic_cmds.py +179 -0
  54. apps/cli/commands/diagnostic_ops_cmds.py +696 -0
  55. apps/cli/commands/finance_render.py +12 -0
  56. apps/cli/commands/market.py +399 -0
  57. apps/cli/commands/market_cmds.py +1276 -0
  58. apps/cli/commands/market_context.py +425 -0
  59. apps/cli/commands/market_render.py +7 -0
  60. apps/cli/commands/model_cmds.py +1579 -0
  61. apps/cli/commands/ops_cmds.py +668 -0
  62. apps/cli/commands/portfolio_cmds.py +962 -0
  63. apps/cli/commands/report.py +377 -0
  64. apps/cli/commands/scaffold_templates.py +617 -0
  65. apps/cli/commands/session_cmds.py +179 -0
  66. apps/cli/commands/session_ux_cmds.py +280 -0
  67. apps/cli/commands/team.py +588 -0
  68. apps/cli/commands/team_render.py +8 -0
  69. apps/cli/commands/ui_cmds.py +358 -0
  70. apps/cli/commands/workflow_cmds.py +279 -0
  71. apps/cli/commands/workspace_cmds.py +1414 -0
  72. apps/cli/config_paths.py +70 -0
  73. apps/cli/config_store.py +61 -0
  74. apps/cli/deterministic.py +122 -0
  75. apps/cli/direct.py +48 -0
  76. apps/cli/github_app_auth.py +135 -0
  77. apps/cli/handlers/__init__.py +11 -0
  78. apps/cli/handlers/broker_handlers.py +122 -0
  79. apps/cli/handlers/chart_handlers.py +1309 -0
  80. apps/cli/handlers/market_handlers.py +2509 -0
  81. apps/cli/handlers/realty_handlers.py +114 -0
  82. apps/cli/handlers/strategy_advice.py +82 -0
  83. apps/cli/hooks.py +180 -0
  84. apps/cli/i18n.py +284 -0
  85. apps/cli/intent.py +136 -0
  86. apps/cli/intent_router.py +217 -0
  87. apps/cli/lifecycle_hooks.py +48 -0
  88. apps/cli/main.py +29 -0
  89. apps/cli/market_metadata.py +135 -0
  90. apps/cli/market_universe.py +265 -0
  91. apps/cli/message_processing.py +257 -0
  92. apps/cli/plan_mode.py +139 -0
  93. apps/cli/plotly_html.py +15 -0
  94. apps/cli/prediction_feedback.py +202 -0
  95. apps/cli/preflight.py +497 -0
  96. apps/cli/project_aria.py +60 -0
  97. apps/cli/prompts/__init__.py +0 -0
  98. apps/cli/prompts/coding.py +658 -0
  99. apps/cli/prompts/system_prompts.py +531 -0
  100. apps/cli/prompts/ui.py +434 -0
  101. apps/cli/providers/__init__.py +1 -0
  102. apps/cli/providers/base.py +271 -0
  103. apps/cli/providers/chat_routing.py +80 -0
  104. apps/cli/providers/llm/__init__.py +1 -0
  105. apps/cli/providers/llm/ollama_stream.py +1170 -0
  106. apps/cli/providers/llm/sse_stream.py +216 -0
  107. apps/cli/providers/runtime_bridge.py +185 -0
  108. apps/cli/runtime_consumer.py +489 -0
  109. apps/cli/session_export.py +87 -0
  110. apps/cli/session_jsonl.py +207 -0
  111. apps/cli/session_store.py +112 -0
  112. apps/cli/todo_tracker.py +190 -0
  113. apps/cli/tools/__init__.py +40 -0
  114. apps/cli/tools/context.py +46 -0
  115. apps/cli/tools/file_tools.py +112 -0
  116. apps/cli/tools/market_tools.py +549 -0
  117. apps/cli/tools/notebook_tools.py +111 -0
  118. apps/cli/tools/system_tools.py +669 -0
  119. apps/cli/tools/write_tools.py +715 -0
  120. apps/cli/tradingview_bridge.py +434 -0
  121. apps/cli/update_check.py +152 -0
  122. apps/cli/utils/__init__.py +0 -0
  123. apps/cli/utils/market_detect.py +1578 -0
  124. apps/daemon/README.md +14 -0
  125. apps/vscode/README.md +115 -0
  126. apps/vscode/package.json +70 -0
  127. aria_cli.py +11636 -0
  128. aria_code-4.1.3.dist-info/METADATA +952 -0
  129. aria_code-4.1.3.dist-info/RECORD +284 -0
  130. aria_code-4.1.3.dist-info/WHEEL +5 -0
  131. aria_code-4.1.3.dist-info/entry_points.txt +2 -0
  132. aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
  133. aria_code-4.1.3.dist-info/top_level.txt +50 -0
  134. aria_daemon.py +1295 -0
  135. aria_feishu_bot.py +1359 -0
  136. aria_relay_client.py +182 -0
  137. aria_relay_server.py +405 -0
  138. aria_telegram_bot.py +202 -0
  139. ariarc.py +328 -0
  140. artifacts.py +491 -0
  141. backtest_report.py +472 -0
  142. brokers/__init__.py +72 -0
  143. brokers/base.py +207 -0
  144. brokers/capabilities.py +264 -0
  145. brokers/cn/__init__.py +10 -0
  146. brokers/cn/easytrader_broker.py +193 -0
  147. brokers/cn/futu_broker.py +194 -0
  148. brokers/cn/longbridge_broker.py +190 -0
  149. brokers/cn/tiger_broker.py +196 -0
  150. brokers/cn/xtquant_broker.py +175 -0
  151. brokers/config.py +364 -0
  152. brokers/intl/__init__.py +5 -0
  153. brokers/intl/alpaca_broker.py +183 -0
  154. brokers/intl/ibkr_broker.py +215 -0
  155. brokers/intl/webull_broker.py +156 -0
  156. brokers/paper_broker.py +259 -0
  157. brokers/planning.py +296 -0
  158. brokers/registry.py +181 -0
  159. brokers/trading.py +237 -0
  160. change_store.py +127 -0
  161. command_safety.py +19 -0
  162. computer_use_tools.py +504 -0
  163. dashboard_generator.py +578 -0
  164. data_analysis_tools.py +808 -0
  165. data_cleaner.py +483 -0
  166. data_service.py +481 -0
  167. datasources/__init__.py +23 -0
  168. datasources/base.py +166 -0
  169. datasources/router.py +221 -0
  170. datasources/sources/__init__.py +15 -0
  171. datasources/sources/akshare_source.py +269 -0
  172. datasources/sources/alpha_vantage_source.py +202 -0
  173. datasources/sources/edgar_source.py +218 -0
  174. datasources/sources/finnhub_source.py +197 -0
  175. datasources/sources/fred_source.py +219 -0
  176. datasources/sources/tushare_source.py +141 -0
  177. datasources/sources/web_scraper_source.py +278 -0
  178. datasources/sources/world_bank_source.py +205 -0
  179. datasources/sources/yfinance_source.py +152 -0
  180. demo_player.py +204 -0
  181. doctor.py +508 -0
  182. file_analysis_tools.py +734 -0
  183. finance_formulas.py +389 -0
  184. football_data_client.py +1670 -0
  185. intent_classifier.py +358 -0
  186. local_finance_tools.py +3221 -0
  187. local_llm_provider.py +552 -0
  188. macro_tools.py +368 -0
  189. market_data_client.py +1899 -0
  190. mcp_client.py +506 -0
  191. memory_manager.py +245 -0
  192. model_capability.py +416 -0
  193. notification_tools.py +248 -0
  194. packages/__init__.py +23 -0
  195. packages/aria_agents/__init__.py +5 -0
  196. packages/aria_agents/manifest.py +69 -0
  197. packages/aria_core/__init__.py +34 -0
  198. packages/aria_core/architecture.py +192 -0
  199. packages/aria_core/export.py +124 -0
  200. packages/aria_core/manifest.py +65 -0
  201. packages/aria_infra/__init__.py +15 -0
  202. packages/aria_infra/arthera.py +52 -0
  203. packages/aria_infra/doctor.py +246 -0
  204. packages/aria_infra/product.py +37 -0
  205. packages/aria_mcp/__init__.py +25 -0
  206. packages/aria_mcp/bridge.py +38 -0
  207. packages/aria_mcp/config.py +97 -0
  208. packages/aria_mcp/tools.py +61 -0
  209. packages/aria_sdk/__init__.py +19 -0
  210. packages/aria_sdk/client.py +396 -0
  211. packages/aria_sdk/providers.py +70 -0
  212. packages/aria_sdk/streaming.py +73 -0
  213. packages/aria_sdk/types.py +86 -0
  214. packages/aria_services/__init__.py +55 -0
  215. packages/aria_services/context.py +258 -0
  216. packages/aria_services/data.py +11 -0
  217. packages/aria_services/provider_health.py +189 -0
  218. packages/aria_services/registry.py +213 -0
  219. packages/aria_services/usage.py +138 -0
  220. packages/aria_skills/__init__.py +5 -0
  221. packages/aria_skills/registry.py +59 -0
  222. packages/aria_tools/__init__.py +5 -0
  223. packages/aria_tools/registry.py +128 -0
  224. packages/quant_engine/__init__.py +6 -0
  225. packages/quant_engine/sports/__init__.py +72 -0
  226. packages/quant_engine/sports/calibrator.py +353 -0
  227. packages/quant_engine/sports/dixon_coles.py +234 -0
  228. packages/quant_engine/sports/elo.py +299 -0
  229. packages/quant_engine/sports/form.py +188 -0
  230. packages/quant_engine/sports/h2h.py +195 -0
  231. packages/quant_engine/sports/ml_model.py +354 -0
  232. packages/quant_engine/sports/predictor.py +311 -0
  233. packages/quant_engine/sports/tracker.py +664 -0
  234. packages/quant_engine/stochastic/__init__.py +27 -0
  235. packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  236. packages/quant_engine/stochastic/ito_calculus.py +477 -0
  237. packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  238. packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  239. packages/quant_engine/stochastic/options_pricing.py +573 -0
  240. packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  241. plan_utils.py +194 -0
  242. plugin_loader.py +328 -0
  243. portfolio_ledger.py +262 -0
  244. privacy/__init__.py +5 -0
  245. privacy/feedback.py +123 -0
  246. project_tools.py +525 -0
  247. providers/__init__.py +30 -0
  248. providers/llm/__init__.py +19 -0
  249. providers/llm/anthropic.py +184 -0
  250. providers/llm/base.py +139 -0
  251. providers/llm/ollama.py +128 -0
  252. providers/llm/openai_compat.py +282 -0
  253. providers/llm/registry.py +358 -0
  254. realty_data_tools.py +659 -0
  255. report_generator.py +1314 -0
  256. runtime/__init__.py +103 -0
  257. runtime/agent_loop.py +1183 -0
  258. runtime/approval.py +51 -0
  259. runtime/events.py +102 -0
  260. runtime/gateway.py +128 -0
  261. runtime/lsp.py +346 -0
  262. runtime/subagent.py +258 -0
  263. runtime/tool_executor.py +104 -0
  264. runtime/tool_policy.py +106 -0
  265. safety/__init__.py +21 -0
  266. safety/permissions.py +275 -0
  267. setup_wizard.py +653 -0
  268. strategy_vault.py +420 -0
  269. ui/__init__.py +100 -0
  270. ui/banner.py +310 -0
  271. ui/completer.py +391 -0
  272. ui/console.py +271 -0
  273. ui/image_render.py +243 -0
  274. ui/input_box.py +376 -0
  275. ui/picker.py +195 -0
  276. ui/render/__init__.py +11 -0
  277. ui/render/finance.py +1480 -0
  278. ui/render/market.py +225 -0
  279. ui/render/output.py +681 -0
  280. ui/render/team.py +346 -0
  281. ui/robot.py +235 -0
  282. workspace/__init__.py +6 -0
  283. workspace/files.py +170 -0
  284. workspace/verify.py +113 -0
aria_feishu_bot.py ADDED
@@ -0,0 +1,1359 @@
1
+ """
2
+ aria_feishu_bot.py — Feishu (Lark) 多模态 AI 机器人
3
+ ======================================================
4
+ OpenClaw 同款设计:任意输入(文字/语音/图片/文件)→ Aria AI → 卡片回复
5
+
6
+ 两种运行模式:
7
+ 1. 嵌入 FastAPI(由 feishu_routes.py 调用)
8
+ 2. 独立运行 — python3 aria_feishu_bot.py [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=uid1,uid2 # 留空=不限制
19
+
20
+ 支持的消息类型:
21
+ 📝 文字(非命令)→ Aria LLM 自然语言回答
22
+ 🎤 语音 → Whisper 转文字 → Aria LLM
23
+ 🖼️ 图片 → 视觉 LLM 分析(Claude / GPT-4V)
24
+ 📄 文件 → 自动解析 PDF/Excel/代码 → Aria LLM 总结
25
+
26
+ 结构化命令(/command):
27
+ /price AAPL /brief /screen
28
+ /report NVDA /run /price TSLA (调用 aria CLI -p 模式)
29
+ /alert add SYM cond v /alerts /status /help
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import base64
36
+ import hashlib
37
+ import hmac
38
+ import json
39
+ import logging
40
+ import os
41
+ import re
42
+ import sys
43
+ import tempfile
44
+ import time
45
+ from pathlib import Path
46
+ from typing import Any, Dict, Optional
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # Strip ANSI escape codes from aria CLI output
51
+ _ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
52
+
53
+ # Lines that are tool/UI artifacts and must be stripped from bot replies
54
+ _BOT_NOISE_RE = re.compile(
55
+ r"^\s*(?:"
56
+ # ── diff / table lines (ASCII pipe AND Unicode box-drawing │ U+2502) ────
57
+ r"[│|][+\- \d]" # │298 │+ code |- old | context
58
+ r"|[│|]\s*$" # │ │ (empty cell borders)
59
+ r"|[┌┐└┘├┤┬┴┼─╌╍╴╶╷╸╹]" # box corners / connectors
60
+ # ── timing artifacts ────────────────────────────────────────────────────
61
+ r"|└\s*\d+[\.,]\d+s?\b"
62
+ r"|[└─]{1,3}\s*\d+[\.,]\d+\s*s"
63
+ # ── tool call / result bullets ──────────────────────────────────────────
64
+ r"| [●└■▸]"
65
+ r"| L \d"
66
+ # ── permission / confirmation dialog ────────────────────────────────────
67
+ r"|[›❯>]\s*\d+\." # › 1. Yes ❯ 1. Yes > 1. Yes
68
+ r"|\d+\.\s+Yes" # 1. Yes / 2. Yes, allow all
69
+ r"|\d+\.\s+No" # 3. No
70
+ r"|Enter number" # "Enter number (or Enter to keep current):"
71
+ r"|Cancelled"
72
+ # ── leftover Rich markup tags ────────────────────────────────────────────
73
+ r"|\[/?(?:cyan|dim|bold|red|green|yellow|blue|magenta|white|grey|reset)\]"
74
+ # ── horizontal rules ────────────────────────────────────────────────────
75
+ r"|\s*[━─═]{4,}\s*$"
76
+ r")"
77
+ )
78
+
79
+ # Inline timing / markup to strip from within a line
80
+ _INLINE_TIMING_RE = re.compile(r"\s*[└─]{1,2}\s*\d+[\.,]\d+\s*s\b")
81
+ _INLINE_RICH_TAG_RE = re.compile(r"\[/?(?:cyan|dim|bold|red|green|yellow|blue|magenta|white|grey|reset)\]")
82
+
83
+ _ARIA_CODE_DIR = Path(__file__).parent
84
+
85
+ # ── Feishu API endpoints ───────────────────────────────────────────────────────
86
+
87
+ _FEISHU_API = "https://open.feishu.cn/open-apis"
88
+
89
+ # ── Token cache (tenant_access_token, expires ~2h) ────────────────────────────
90
+ _token_cache: dict[str, Any] = {"token": None, "expires_at": 0}
91
+
92
+
93
+ async def _get_access_token() -> Optional[str]:
94
+ """Fetch/cache tenant_access_token via app credentials."""
95
+ app_id = os.environ.get("FEISHU_APP_ID", "")
96
+ app_secret = os.environ.get("FEISHU_APP_SECRET", "")
97
+ if not app_id or not app_secret:
98
+ return None
99
+
100
+ now = time.time()
101
+ if _token_cache["token"] and _token_cache["expires_at"] > now + 60:
102
+ return _token_cache["token"]
103
+
104
+ try:
105
+ import httpx
106
+ async with httpx.AsyncClient(timeout=10) as client:
107
+ resp = await client.post(
108
+ f"{_FEISHU_API}/auth/v3/tenant_access_token/internal",
109
+ json={"app_id": app_id, "app_secret": app_secret},
110
+ )
111
+ data = resp.json()
112
+ token = data.get("tenant_access_token")
113
+ expire = int(data.get("expire", 7200))
114
+ _token_cache["token"] = token
115
+ _token_cache["expires_at"] = now + expire
116
+ return token
117
+ except Exception as exc:
118
+ logger.warning("Feishu token fetch failed: %s", exc)
119
+ return None
120
+
121
+
122
+ # ── Send message helpers ───────────────────────────────────────────────────────
123
+
124
+ async def _feishu_post(url: str, token: str, payload: dict) -> Optional[dict]:
125
+ """POST to Feishu API; log the response code on error."""
126
+ try:
127
+ import httpx
128
+ async with httpx.AsyncClient(timeout=15) as client:
129
+ resp = await client.post(
130
+ url,
131
+ headers={"Authorization": f"Bearer {token}",
132
+ "Content-Type": "application/json; charset=utf-8"},
133
+ json=payload,
134
+ )
135
+ data = resp.json()
136
+ code = data.get("code", 0)
137
+ if code != 0:
138
+ logger.error("Feishu API error %s: %s url=%s msg_id in payload=%s",
139
+ code, data.get("msg", ""), url.split("/")[-3:],
140
+ payload.get("receive_id", "—"))
141
+ else:
142
+ logger.debug("Feishu API ok: %s", url.split("/")[-2:])
143
+ return data
144
+ except Exception as exc:
145
+ logger.warning("Feishu POST failed: %s", exc)
146
+ return None
147
+
148
+
149
+ async def reply_text(message_id: str, text: str) -> None:
150
+ """Reply to a Feishu message with plain text (auto-truncated at 3000 chars)."""
151
+ if not message_id:
152
+ logger.error("reply_text: empty message_id — cannot reply")
153
+ return
154
+ token = await _get_access_token()
155
+ if not token:
156
+ logger.error("reply_text: no access token")
157
+ return
158
+ logger.info("reply_text → message_id=%s len=%d", message_id, len(text))
159
+ await _feishu_post(
160
+ f"{_FEISHU_API}/im/v1/messages/{message_id}/reply",
161
+ token,
162
+ {"msg_type": "text", "content": json.dumps({"text": text[:3000]})},
163
+ )
164
+
165
+
166
+ async def reply_card(message_id: str, title: str, body: str,
167
+ color: str = "blue", footer: str = "") -> None:
168
+ """Reply with an interactive card (title + Markdown body)."""
169
+ if not message_id:
170
+ logger.error("reply_card: empty message_id — cannot reply")
171
+ return
172
+ token = await _get_access_token()
173
+ if not token:
174
+ await reply_text(message_id, f"【{title}】\n{body}")
175
+ return
176
+ elements = _build_card_elements(body, footer)
177
+ card = {
178
+ "config": {"wide_screen_mode": True},
179
+ "header": {"title": {"tag": "plain_text", "content": title}, "template": color},
180
+ "elements": elements,
181
+ }
182
+ logger.info("reply_card → message_id=%s title=%s", message_id, title[:40])
183
+ result = await _feishu_post(
184
+ f"{_FEISHU_API}/im/v1/messages/{message_id}/reply",
185
+ token,
186
+ {"msg_type": "interactive", "content": json.dumps(card)},
187
+ )
188
+ # If card failed, fall back to plain text
189
+ if result and result.get("code") != 0:
190
+ logger.info("reply_card: card failed (code %s), falling back to text", result.get("code"))
191
+ await reply_text(message_id, f"【{title}】\n{body}")
192
+
193
+
194
+ async def reply_or_send(message_id: str, chat_id: str,
195
+ title: str, body: str,
196
+ color: str = "blue", footer: str = "") -> None:
197
+ """Try reply by message_id first; if that fails, send a new message to chat_id."""
198
+ if message_id:
199
+ result = await _reply_card_raw(message_id, title, body, color, footer)
200
+ if result is not None and result.get("code", 0) == 0:
201
+ return
202
+ logger.warning("reply failed (code=%s), falling back to send_card_to_chat",
203
+ result.get("code") if result else "no response")
204
+ if chat_id:
205
+ await send_card_to_chat(chat_id, title, body, color)
206
+ else:
207
+ logger.error("reply_or_send: both message_id and chat_id empty, cannot send")
208
+
209
+
210
+ def _build_card_elements(body: str, footer: str = "") -> list:
211
+ """Split body into visual sections for richer card layout."""
212
+ import re as _re
213
+ # Split on markdown `---` dividers or `##`/`###` section headers
214
+ _section_re = _re.compile(r'(?m)^[-─]{3,}\s*$')
215
+ raw_sections = _section_re.split(body.strip())
216
+ elements: list = []
217
+ for i, sec in enumerate(raw_sections):
218
+ sec = sec.strip()
219
+ if not sec:
220
+ continue
221
+ # Detect if section starts with a ## header and peel it off
222
+ _hdr_m = _re.match(r'^#{1,3}\s+(.+)\n', sec)
223
+ if _hdr_m:
224
+ hdr_text = _hdr_m.group(1).strip()
225
+ sec_body = sec[_hdr_m.end():].strip()
226
+ elements.append({"tag": "markdown", "content": f"**{hdr_text}**"})
227
+ if sec_body:
228
+ elements.append({"tag": "div", "text": {"tag": "lark_md", "content": sec_body[:900]}})
229
+ else:
230
+ elements.append({"tag": "div", "text": {"tag": "lark_md", "content": sec[:900]}})
231
+ if i < len(raw_sections) - 1:
232
+ elements.append({"tag": "hr"})
233
+
234
+ if not elements:
235
+ elements = [{"tag": "div", "text": {"tag": "lark_md", "content": body[:2000]}}]
236
+
237
+ if footer:
238
+ elements += [{"tag": "hr"}, {"tag": "note", "elements": [
239
+ {"tag": "plain_text", "content": footer}
240
+ ]}]
241
+ return elements
242
+
243
+
244
+ async def _reply_card_raw(message_id: str, title: str, body: str,
245
+ color: str = "blue", footer: str = "") -> Optional[dict]:
246
+ """Reply with a card; return raw API response dict."""
247
+ token = await _get_access_token()
248
+ if not token:
249
+ return None
250
+ elements = _build_card_elements(body, footer)
251
+ card = {
252
+ "config": {"wide_screen_mode": True},
253
+ "header": {"title": {"tag": "plain_text", "content": title}, "template": color},
254
+ "elements": elements,
255
+ }
256
+ return await _feishu_post(
257
+ f"{_FEISHU_API}/im/v1/messages/{message_id}/reply",
258
+ token,
259
+ {"msg_type": "interactive", "content": json.dumps(card)},
260
+ )
261
+
262
+
263
+ async def send_card_to_chat(chat_id: str, title: str, body: str,
264
+ color: str = "blue", receive_id_type: str = "chat_id") -> None:
265
+ """Send a new card message to a chat (group or user)."""
266
+ token = await _get_access_token()
267
+ if not token:
268
+ return
269
+ elements = _build_card_elements(body)
270
+ card = {
271
+ "config": {"wide_screen_mode": True},
272
+ "header": {"title": {"tag": "plain_text", "content": title}, "template": color},
273
+ "elements": elements,
274
+ }
275
+ try:
276
+ import httpx
277
+ async with httpx.AsyncClient(timeout=15) as client:
278
+ await client.post(
279
+ f"{_FEISHU_API}/im/v1/messages?receive_id_type={receive_id_type}",
280
+ headers={"Authorization": f"Bearer {token}"},
281
+ json={
282
+ "receive_id": chat_id,
283
+ "msg_type": "interactive",
284
+ "content": json.dumps({"card": card}),
285
+ },
286
+ )
287
+ except Exception as exc:
288
+ logger.warning("send_card_to_chat failed: %s", exc)
289
+
290
+
291
+ # ── Command router ─────────────────────────────────────────────────────────────
292
+
293
+ async def _handle_command(cmd: str, message_id: str, sender_id: str, chat_id: str = "") -> None:
294
+ """Parse a command string and reply with structured card."""
295
+ parts = cmd.strip().split()
296
+ if not parts:
297
+ return
298
+ verb = parts[0].lstrip("/").lower()
299
+
300
+ if verb == "help":
301
+ body = (
302
+ "**💬 直接发消息** — 自然语言提问,Aria AI 直接回答\n"
303
+ "**🎤 语音消息** — 自动转文字后 AI 分析\n"
304
+ "**🖼️ 图片** — 自动识别图表/截图内容\n"
305
+ "**📄 文件** — PDF/Excel/Word/代码 自动解析后 AI 总结\n\n"
306
+ "**结构化命令:**\n"
307
+ "`/price <symbol>` — 实时价格(支持 A 股 6 位代码)\n"
308
+ "`/brief` — 今日晨报摘要\n"
309
+ "`/screen` — 涨停预测 Top10\n"
310
+ "`/report <symbol>` — 个股研报(异步推送)\n"
311
+ "`/team <symbol>` — 🤖 多Agent研究(宏观+基本面+技术+风控)\n"
312
+ "`/football predict Arsenal vs Chelsea pl` — ⚽ 足球比赛预测\n"
313
+ "`/football standings pl` — 联赛积分榜(pl/bl/ll/sa/cl)\n"
314
+ "`/run <aria命令>` — 执行任意 Aria 命令,如 `/run /corr AAPL TSLA`\n"
315
+ "`/alert add <symbol> <cond> <value>` — 添加价格预警\n"
316
+ "  条件: `price_above` `price_below` `pct_change_above` `pct_change_below`\n"
317
+ "`/alerts` — 查看所有预警\n"
318
+ "`/status` — Daemon 运行状态\n"
319
+ "`/help` — 显示此帮助"
320
+ )
321
+ await reply_card(message_id, "📖 Aria 帮助", body, "blue")
322
+
323
+ elif verb == "price":
324
+ symbol = parts[1].upper() if len(parts) > 1 else ""
325
+ if not symbol:
326
+ await reply_text(message_id, "用法: /price <symbol>,例如 /price AAPL 或 /price 600036")
327
+ return
328
+ await reply_card(message_id, f"🔄 查询 {symbol}…", "正在获取实时行情,请稍候…", "blue")
329
+ try:
330
+ price, prev = await _fetch_price_feishu(symbol)
331
+ if price is None:
332
+ await reply_card(message_id, f"❌ {symbol}", "获取行情失败,请检查代码是否正确", "red")
333
+ return
334
+ pct = f"{(price - prev) / prev * 100:+.2f}%" if prev else "N/A"
335
+ color = "red" if prev and price > prev else "green" if prev and price < prev else "blue"
336
+ body = (
337
+ f"**当前价** ¥{price:.3f}\n"
338
+ f"**涨跌幅** {pct}\n"
339
+ f"**昨收** ¥{prev:.3f}" if prev else f"**当前价** {price:.4f}"
340
+ )
341
+ await reply_card(message_id, f"{'📈' if prev and price >= prev else '📉'} {symbol}", body, color)
342
+ except Exception as exc:
343
+ await reply_card(message_id, f"❌ {symbol}", f"查询失败: {exc}", "red")
344
+
345
+ elif verb == "brief":
346
+ await reply_card(message_id, "⏳ 生成晨报…", "正在获取市场数据,请稍候…", "blue")
347
+ try:
348
+ from aria_daemon import _run_morning_brief
349
+ brief = await _run_morning_brief()
350
+ await reply_card(message_id, "📊 Aria 晨报", brief[:2000], "green",
351
+ footer="Aria Code · 实时市场分析")
352
+ except Exception as exc:
353
+ await reply_card(message_id, "❌ 晨报生成失败", str(exc)[:300], "red")
354
+
355
+ elif verb == "screen":
356
+ await reply_card(message_id, "⏳ 筛选中…", "正在扫描 A 股涨停预测,请稍候…", "blue")
357
+ try:
358
+ from aria_daemon import _run_screener
359
+ result = await _run_screener()
360
+ await reply_card(message_id, "🔍 涨停预测 Top10", result[:2000], "turquoise")
361
+ except Exception as exc:
362
+ await reply_card(message_id, "❌ 筛选失败", str(exc)[:300], "red")
363
+
364
+ elif verb == "report":
365
+ symbol = parts[1].upper() if len(parts) > 1 else ""
366
+ if not symbol:
367
+ await reply_text(message_id, "用法: /report <symbol>,例如 /report NVDA")
368
+ return
369
+ await reply_card(message_id, f"⏳ 生成 {symbol} 研报…",
370
+ "正在进行多维度分析,通常需要 30-60 秒,完成后推送结果。", "blue")
371
+ asyncio.create_task(_async_report(symbol, message_id))
372
+
373
+ elif verb == "alert":
374
+ if len(parts) < 2:
375
+ await reply_text(message_id, "用法: /alert add <symbol> <cond> <value>")
376
+ return
377
+ sub = parts[1].lower()
378
+ if sub == "add" and len(parts) >= 5:
379
+ sym, cond, val = parts[2].upper(), parts[3], parts[4]
380
+ valid_conds = {"price_above", "price_below", "pct_change_above", "pct_change_below"}
381
+ if cond not in valid_conds:
382
+ await reply_card(message_id, "❌ 无效条件",
383
+ f"支持的条件:\n" + "\n".join(f"• `{c}`" for c in sorted(valid_conds)), "red")
384
+ return
385
+ import sqlite3
386
+ from aria_daemon import _DB_PATH
387
+ with sqlite3.connect(_DB_PATH) as conn:
388
+ conn.execute(
389
+ "INSERT INTO alerts(id,symbol,condition,value,message,active) VALUES(?,?,?,?,?,1)",
390
+ (f"fs_{int(time.time())}_{sym}", sym, cond, float(val),
391
+ f"{sym} {cond.replace('_',' ')} {val}", )
392
+ )
393
+ conn.commit()
394
+ await reply_card(message_id, "✅ 预警已添加",
395
+ f"**{sym}** 当 {cond.replace('_',' ')} `{val}` 时触发通知", "green")
396
+ else:
397
+ await reply_text(message_id, "用法: /alert add <symbol> <cond> <value>")
398
+
399
+ elif verb == "alerts":
400
+ import sqlite3
401
+ from aria_daemon import _DB_PATH
402
+ rows = sqlite3.connect(_DB_PATH).execute(
403
+ "SELECT symbol,condition,value FROM alerts WHERE active=1 ORDER BY created_at DESC LIMIT 20"
404
+ ).fetchall()
405
+ if not rows:
406
+ await reply_card(message_id, "📋 当前预警", "暂无活跃预警", "blue")
407
+ else:
408
+ lines = "\n".join(f"• **{r[0]}** {r[1].replace('_',' ')} `{r[2]}`" for r in rows)
409
+ await reply_card(message_id, f"📋 活跃预警({len(rows)} 条)", lines, "blue")
410
+
411
+ elif verb == "status":
412
+ import sqlite3
413
+ from aria_daemon import _DB_PATH, _PID_FILE
414
+ pid_alive = _PID_FILE.exists()
415
+ conn = sqlite3.connect(_DB_PATH)
416
+ alert_count = conn.execute("SELECT COUNT(*) FROM alerts WHERE active=1").fetchone()[0]
417
+ sched_count = conn.execute("SELECT COUNT(*) FROM schedules WHERE active=1").fetchone()[0]
418
+ job_pending = conn.execute("SELECT COUNT(*) FROM webhook_jobs WHERE status='pending'").fetchone()[0]
419
+ body = (
420
+ f"**Daemon** {'🟢 运行中' if pid_alive else '🔴 未运行'}\n"
421
+ f"**活跃预警** {alert_count} 条\n"
422
+ f"**定时任务** {sched_count} 条\n"
423
+ f"**待处理 Jobs** {job_pending} 个"
424
+ )
425
+ await reply_card(message_id, "📡 Aria Daemon 状态", body,
426
+ "green" if pid_alive else "red")
427
+ elif verb == "football":
428
+ sub = parts[1].lower() if len(parts) > 1 else ""
429
+ rest = " ".join(parts[2:])
430
+ if sub == "predict" and " vs " in rest.lower():
431
+ await reply_card(message_id, f"⚽ 预测中…", f"> {rest}", "blue")
432
+ asyncio.create_task(_handle_football_predict(rest, message_id))
433
+ elif sub == "standings":
434
+ league = rest.strip() or "pl"
435
+ await reply_card(message_id, f"⚽ 获取积分榜…", f"联赛: {league.upper()}", "blue")
436
+ asyncio.create_task(_handle_football_standings(league, message_id))
437
+ else:
438
+ # Natural language after /football (e.g. "/football 预测加拿大跟波黑")
439
+ # → treat as NL query with football intent, route to LLM
440
+ nl_text = f"{sub} {rest}".strip() if rest else sub
441
+ _is_chinese = any('一' <= c <= '鿿' for c in nl_text)
442
+ _is_predict_kw = any(k in nl_text.lower() for k in (
443
+ "predict", "preview", "who wins", "who will", "预测", "谁赢", "比分", "胜率"
444
+ ))
445
+ if _is_chinese or _is_predict_kw:
446
+ asyncio.create_task(_handle_nl_query(nl_text, message_id, chat_id))
447
+ else:
448
+ await reply_card(
449
+ message_id, "⚽ /football 命令",
450
+ f"未识别子命令: `{sub}`\n\n**用法:**\n"
451
+ "- `/football predict Arsenal vs Chelsea pl` — 预测比赛\n"
452
+ "- `/football standings pl` — 积分榜\n"
453
+ "- 或直接用自然语言提问,例如:「预测加拿大跟波黑的比分」",
454
+ "yellow"
455
+ )
456
+
457
+ elif verb in ("team", "analyze"):
458
+ # /team <symbol> [--full] — multi-agent research team
459
+ sym_parts = [p for p in parts[1:] if not p.startswith("-")]
460
+ flags = [p for p in parts[1:] if p.startswith("-")]
461
+ symbol = sym_parts[0].upper() if sym_parts else ""
462
+ if not symbol:
463
+ await reply_card(message_id, "❓ 用法",
464
+ "`/team <symbol>` — 多Agent研究\n"
465
+ "`/team AAPL --full` — 完整7-agent模式\n"
466
+ "例: `/team NVDA` `/team 600519`", "blue")
467
+ return
468
+ flag_str = " " + " ".join(flags) if flags else ""
469
+ cmd = f"/team {symbol}{flag_str}"
470
+ await reply_card(message_id, f"🤖 多Agent分析 {symbol}…",
471
+ f"正在启动4-agent并行分析,请稍候(约15-30s)…", "blue")
472
+ asyncio.create_task(_async_run_aria(cmd, message_id))
473
+
474
+ elif verb == "run":
475
+ # /run <aria-command> e.g. /run /price AAPL /run /corr AAPL TSLA NVDA
476
+ sub_cmd = " ".join(parts[1:]).strip() if len(parts) > 1 else ""
477
+ if not sub_cmd:
478
+ await reply_text(message_id, "用法: /run <aria命令>,例如 `/run /price AAPL`")
479
+ return
480
+ await reply_card(message_id, f"⚙️ 执行: {sub_cmd[:60]}", "正在运行,请稍候…", "blue")
481
+ asyncio.create_task(_async_run_aria(sub_cmd, message_id))
482
+
483
+ else:
484
+ await reply_card(message_id, "❓ 未知命令",
485
+ f"不认识 `/{verb}`,发送 `/help` 查看全部命令", "yellow")
486
+
487
+
488
+ async def _handle_football_predict(match_str: str, message_id: str) -> None:
489
+ """Handle /football predict <home> vs <away> [league] from Feishu."""
490
+ import re
491
+ m = re.match(r"(.+?)\s+vs\s+(.+?)(?:\s+(\w+))?$", match_str, re.IGNORECASE)
492
+ if not m:
493
+ await reply_card(message_id, "❌ 格式错误", "用法: `/football predict Arsenal vs Chelsea pl`", "red")
494
+ return
495
+ home_raw, away_raw, league = m.group(1).strip(), m.group(2).strip(), (m.group(3) or "pl")
496
+ try:
497
+ from football_data_client import _CN_TEAM_MAP, _FIFA_RATINGS, predict_match, predict_wc_match
498
+
499
+ # Translate Chinese team names → English for model lookup
500
+ home_en = _CN_TEAM_MAP.get(home_raw, home_raw)
501
+ away_en = _CN_TEAM_MAP.get(away_raw, away_raw)
502
+ home_low = home_en.lower().strip()
503
+ away_low = away_en.lower().strip()
504
+
505
+ # Use WC/national team model when both teams are in FIFA ratings table
506
+ if home_low in _FIFA_RATINGS and away_low in _FIFA_RATINGS:
507
+ pred = predict_wc_match(home_en, away_en, neutral_venue=True)
508
+ home_label = pred.get("home_name_cn") or home_raw
509
+ away_label = pred.get("away_name_cn") or away_raw
510
+ ranking_note = (
511
+ f"FIFA 排名: #{pred.get('home_ranking','?')} vs #{pred.get('away_ranking','?')}"
512
+ )
513
+ most_likely = pred["top_scorelines"][0]["score"] if pred.get("top_scorelines") else "?"
514
+ else:
515
+ pred = predict_match(home_en, away_en, league)
516
+ home_label, away_label = home_raw, away_raw
517
+ ranking_note = f"联赛: {league.upper()}"
518
+ most_likely = pred.get("most_likely_score") or (
519
+ pred["top_scorelines"][0]["score"] if pred.get("top_scorelines") else "?"
520
+ )
521
+
522
+ top = "\n".join(
523
+ f" {s['score']} — {s['prob']}%" for s in (pred.get("top_scorelines") or [])[:3]
524
+ )
525
+ body = (
526
+ f"**{home_label}** vs **{away_label}**\n"
527
+ f"*{ranking_note}*\n\n"
528
+ f"---\n"
529
+ f"🏆 主队胜: **{pred['home_win']:.0%}** "
530
+ f"平局: {pred['draw']:.0%} "
531
+ f"客队胜: {pred['away_win']:.0%}\n\n"
532
+ f"⚽ 预期进球: {pred['lambda_home']:.1f} – {pred['lambda_away']:.1f}\n"
533
+ f"📊 最可能比分: **{most_likely}**\n"
534
+ f"🎯 高概率比分:\n{top}\n\n"
535
+ f"双方均进球: {pred['btts']:.0%}\n"
536
+ f"*泊松模型量化预测,仅供参考*"
537
+ )
538
+ color = (
539
+ "green" if pred["home_win"] > pred["away_win"] + 0.1
540
+ else "red" if pred["away_win"] > pred["home_win"] + 0.1
541
+ else "yellow"
542
+ )
543
+ await reply_card(message_id, "⚽ 赛事预测", body, color)
544
+ except Exception as exc:
545
+ await reply_card(message_id, "❌ 预测失败", str(exc)[:300], "red")
546
+
547
+
548
+ async def _handle_football_standings(league: str, message_id: str) -> None:
549
+ """Handle /football standings from Feishu."""
550
+ try:
551
+ from football_data_client import get_standings
552
+ data = get_standings(league)
553
+ if not data:
554
+ await reply_card(message_id, "❌ 无法获取数据",
555
+ "请检查联赛代码或设置 FOOTBALL_DATA_API_KEY", "red")
556
+ return
557
+ rows = data["table"][:10]
558
+ lines = [f"**{data['league_name']}**\n"]
559
+ for r in rows:
560
+ form = r.get("form", "") or ""
561
+ lines.append(f"{r['pos']:2}. {r['team'][:18]:18} {r['pts']}分 {r['w']}W{r['d']}D{r['l']}L {form[:5]}")
562
+ await reply_card(message_id, "📊 积分榜 TOP10", "\n".join(lines), "blue")
563
+ except Exception as exc:
564
+ await reply_card(message_id, "❌ 获取失败", str(exc)[:300], "red")
565
+
566
+
567
+ async def _fetch_price_feishu(symbol: str):
568
+ """Thin wrapper around aria_daemon._fetch_price for use inside bot."""
569
+ try:
570
+ from aria_daemon import _fetch_price
571
+ return await _fetch_price(symbol)
572
+ except ImportError:
573
+ # Fallback: inline yfinance
574
+ import yfinance as yf
575
+ yfn = (symbol + (".SS" if symbol.startswith(("6", "5")) else ".SZ")
576
+ if symbol.isdigit() and len(symbol) == 6 else symbol)
577
+ info = yf.Ticker(yfn).fast_info
578
+ p = getattr(info, "last_price", None)
579
+ pc = getattr(info, "previous_close", None)
580
+ return (float(p) if p else None, float(pc) if pc else None)
581
+
582
+
583
+ async def _async_report(symbol: str, message_id: str) -> None:
584
+ """Background task: generate report and reply when done."""
585
+ try:
586
+ from aria_daemon import _run_report
587
+ result = await _run_report(symbol)
588
+ await reply_card(message_id, f"📄 {symbol} 研报完成", result[:2000], "turquoise",
589
+ footer="Aria Code · 多智能体分析")
590
+ except Exception as exc:
591
+ await reply_card(message_id, f"❌ {symbol} 研报失败", str(exc)[:300], "red")
592
+
593
+
594
+ async def _async_run_aria(cmd: str, message_id: str) -> None:
595
+ """Background task: run aria CLI command and reply with result."""
596
+ result = await _query_aria_llm(cmd, timeout=120)
597
+ color = "red" if result.startswith("❌") or result.startswith("⏱️") else "turquoise"
598
+ await reply_card(message_id, f"✅ Aria 执行完成", result[:2000], color,
599
+ footer=f"命令: {cmd[:80]}")
600
+
601
+
602
+ # ── Multimodal helpers ────────────────────────────────────────────────────────
603
+
604
+ def _is_allowed_user(user_id: str) -> bool:
605
+ """Check FEISHU_ALLOWED_USER_IDS allowlist (empty = allow all)."""
606
+ raw = os.environ.get("FEISHU_ALLOWED_USER_IDS", "").strip()
607
+ if not raw:
608
+ return True
609
+ return user_id in {u.strip() for u in raw.split(",") if u.strip()}
610
+
611
+
612
+ async def _query_aria_direct(text: str, timeout: int = 90) -> str:
613
+ """
614
+ Query the LLM directly via providers/llm/registry.py — no subprocess, no tool use.
615
+ Used for conversational NL queries where we want a clean text answer.
616
+ Falls back to _query_aria_llm on ImportError.
617
+ """
618
+ try:
619
+ import sys as _sys
620
+ _sys.path.insert(0, str(_ARIA_CODE_DIR))
621
+ from providers.llm.registry import stream_cloud_fallback
622
+ import asyncio as _aio
623
+
624
+ collected: list[str] = []
625
+ result = await _aio.wait_for(
626
+ stream_cloud_fallback(text, history=[], on_token=collected.append),
627
+ timeout=timeout,
628
+ )
629
+ if result.get("success") and collected:
630
+ return "".join(collected).strip()
631
+ if result.get("response"):
632
+ return result["response"].strip()
633
+ return "❌ LLM 返回空响应,请检查 API Key 配置。"
634
+ except ImportError:
635
+ pass # fall through to subprocess
636
+ except Exception as _exc:
637
+ logger.warning("_query_aria_direct failed: %s", _exc)
638
+ return await _query_aria_llm(text, timeout=timeout)
639
+
640
+
641
+ async def _query_aria_llm(text: str, timeout: int = 120) -> str:
642
+ """
643
+ Run a natural language query through aria-code's LLM via CLI -p mode.
644
+ Returns plain text output (ANSI stripped, tool noise filtered).
645
+ Use _query_aria_direct() for conversational queries — this is for
646
+ slash commands (/brief, /team, etc.) that need the full CLI context.
647
+ """
648
+ aria_cli = _ARIA_CODE_DIR / "aria_cli.py"
649
+ if not aria_cli.exists():
650
+ return "❌ aria_cli.py 未找到,请检查 ARIA_CODE_DIR 配置。"
651
+ try:
652
+ # ARIA_BOT_MODE=1: auto-approves tools + suppresses visual diffs in aria_cli
653
+ bot_env = {**os.environ, "ARIA_BOT_MODE": "1"}
654
+ proc = await asyncio.create_subprocess_exec(
655
+ sys.executable, str(aria_cli), "-p", text,
656
+ stdin=asyncio.subprocess.DEVNULL, # no interactive prompts
657
+ stdout=asyncio.subprocess.PIPE,
658
+ stderr=asyncio.subprocess.PIPE,
659
+ cwd=str(_ARIA_CODE_DIR),
660
+ env=bot_env,
661
+ )
662
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
663
+ raw = stdout.decode("utf-8", errors="replace")
664
+ # Strip ANSI codes, filter noise lines, collapse blank runs
665
+ clean_lines = []
666
+ blank_run = 0
667
+ for line in _ANSI_RE.sub("", raw).splitlines():
668
+ if _BOT_NOISE_RE.match(line):
669
+ continue
670
+ line = _INLINE_TIMING_RE.sub("", line)
671
+ line = _INLINE_RICH_TAG_RE.sub("", line)
672
+ if not line.strip():
673
+ blank_run += 1
674
+ if blank_run > 1: # collapse consecutive blank lines to one
675
+ continue
676
+ else:
677
+ blank_run = 0
678
+ clean_lines.append(line)
679
+ clean = "\n".join(clean_lines).strip()
680
+ return clean[:3000] if clean else (stderr.decode()[:500] or "(无输出)")
681
+ except asyncio.TimeoutError:
682
+ try: proc.kill()
683
+ except Exception: pass
684
+ return f"⏱️ 查询超时(>{timeout}s),请简化问题后重试。"
685
+ except Exception as exc:
686
+ return f"❌ aria LLM 调用失败: {exc}"
687
+
688
+
689
+ async def _download_feishu_resource(message_id: str, resource_key: str,
690
+ rtype: str = "file") -> Optional[bytes]:
691
+ """Download image / audio / file from Feishu message."""
692
+ token = await _get_access_token()
693
+ if not token:
694
+ return None
695
+ try:
696
+ import httpx
697
+ async with httpx.AsyncClient(timeout=60) as client:
698
+ resp = await client.get(
699
+ f"{_FEISHU_API}/im/v1/messages/{message_id}/resources/{resource_key}",
700
+ params={"type": rtype},
701
+ headers={"Authorization": f"Bearer {token}"},
702
+ )
703
+ if resp.status_code == 200:
704
+ return resp.content
705
+ except Exception as exc:
706
+ logger.warning("_download_feishu_resource failed: %s", exc)
707
+ return None
708
+
709
+
710
+ async def _transcribe_voice(audio_bytes: bytes) -> str:
711
+ """Speech-to-text: tries OpenAI Whisper API, then local faster-whisper."""
712
+ api_key = os.environ.get("OPENAI_API_KEY", "")
713
+ if api_key:
714
+ try:
715
+ import httpx
716
+ with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
717
+ f.write(audio_bytes)
718
+ tmp = f.name
719
+ with open(tmp, "rb") as audio_file:
720
+ async with httpx.AsyncClient(timeout=60) as client:
721
+ resp = await client.post(
722
+ "https://api.openai.com/v1/audio/transcriptions",
723
+ headers={"Authorization": f"Bearer {api_key}"},
724
+ data={"model": "whisper-1"},
725
+ files={"file": ("voice.ogg", audio_file, "audio/ogg")},
726
+ )
727
+ data = resp.json()
728
+ return data.get("text", "(识别结果为空)")
729
+ except Exception as exc:
730
+ logger.warning("OpenAI Whisper failed: %s", exc)
731
+
732
+ # Fallback: local faster-whisper / openai-whisper
733
+ try:
734
+ from faster_whisper import WhisperModel
735
+ model = WhisperModel("base", device="cpu", compute_type="int8")
736
+ with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
737
+ f.write(audio_bytes)
738
+ tmp = f.name
739
+ segments, _ = model.transcribe(tmp, language="zh")
740
+ return " ".join(seg.text for seg in segments).strip() or "(识别结果为空)"
741
+ except ImportError:
742
+ pass
743
+
744
+ return "❌ 语音转文字需要配置 OPENAI_API_KEY,或安装 faster-whisper (`pip install faster-whisper`)"
745
+
746
+
747
+ async def _analyze_image(image_bytes: bytes, caption: str = "") -> str:
748
+ """Analyze image via Claude vision (ANTHROPIC_API_KEY) or GPT-4V (OPENAI_API_KEY)."""
749
+ b64 = base64.b64encode(image_bytes).decode()
750
+ prompt = caption or "请详细分析这张图片的内容。如果是图表、K线图或截图,请做专业解读并给出结论。"
751
+
752
+ anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "")
753
+ if anthropic_key:
754
+ try:
755
+ import httpx
756
+ async with httpx.AsyncClient(timeout=60) as client:
757
+ resp = await client.post(
758
+ "https://api.anthropic.com/v1/messages",
759
+ headers={
760
+ "x-api-key": anthropic_key,
761
+ "anthropic-version": "2023-06-01",
762
+ "content-type": "application/json",
763
+ },
764
+ json={
765
+ "model": "claude-sonnet-4-6",
766
+ "max_tokens": 1500,
767
+ "messages": [{
768
+ "role": "user",
769
+ "content": [
770
+ {"type": "image", "source": {
771
+ "type": "base64", "media_type": "image/jpeg", "data": b64}},
772
+ {"type": "text", "text": prompt},
773
+ ],
774
+ }],
775
+ },
776
+ )
777
+ data = resp.json()
778
+ return data["content"][0]["text"]
779
+ except Exception as exc:
780
+ logger.warning("Claude vision failed: %s", exc)
781
+
782
+ openai_key = os.environ.get("OPENAI_API_KEY", "")
783
+ if openai_key:
784
+ try:
785
+ import httpx
786
+ async with httpx.AsyncClient(timeout=60) as client:
787
+ resp = await client.post(
788
+ "https://api.openai.com/v1/chat/completions",
789
+ headers={"Authorization": f"Bearer {openai_key}"},
790
+ json={
791
+ "model": "gpt-4o",
792
+ "max_tokens": 1500,
793
+ "messages": [{
794
+ "role": "user",
795
+ "content": [
796
+ {"type": "image_url", "image_url": {
797
+ "url": f"data:image/jpeg;base64,{b64}"}},
798
+ {"type": "text", "text": prompt},
799
+ ],
800
+ }],
801
+ },
802
+ )
803
+ data = resp.json()
804
+ return data["choices"][0]["message"]["content"]
805
+ except Exception as exc:
806
+ logger.warning("GPT-4V failed: %s", exc)
807
+
808
+ return "❌ 图片分析需要配置 ANTHROPIC_API_KEY 或 OPENAI_API_KEY"
809
+
810
+
811
+ async def _analyze_file(file_bytes: bytes, filename: str) -> str:
812
+ """Extract text from a file and query Aria LLM for analysis."""
813
+ ext = Path(filename).suffix.lower()
814
+ text_content = ""
815
+
816
+ if ext == ".pdf":
817
+ try:
818
+ import pdfplumber, io
819
+ with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
820
+ pages = [p.extract_text() or "" for p in pdf.pages[:20]]
821
+ text_content = "\n".join(pages)[:8000]
822
+ except ImportError:
823
+ try:
824
+ import pypdf, io
825
+ reader = pypdf.PdfReader(io.BytesIO(file_bytes))
826
+ text_content = "\n".join(
827
+ p.extract_text() or "" for p in reader.pages[:20]
828
+ )[:8000]
829
+ except Exception as exc:
830
+ return f"❌ PDF 解析失败: {exc}"
831
+
832
+ elif ext in (".xlsx", ".xls"):
833
+ try:
834
+ import openpyxl, io
835
+ wb = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True)
836
+ rows = []
837
+ for sheet in wb.sheetnames[:3]:
838
+ ws = wb[sheet]
839
+ for row in list(ws.iter_rows(values_only=True))[:50]:
840
+ rows.append("\t".join(str(c) for c in row if c is not None))
841
+ text_content = f"[Excel: {filename}]\n" + "\n".join(rows)[:6000]
842
+ except Exception as exc:
843
+ return f"❌ Excel 解析失败: {exc}"
844
+
845
+ elif ext in (".docx",):
846
+ try:
847
+ import docx, io
848
+ doc = docx.Document(io.BytesIO(file_bytes))
849
+ text_content = "\n".join(p.text for p in doc.paragraphs)[:8000]
850
+ except Exception as exc:
851
+ return f"❌ Word 解析失败: {exc}"
852
+
853
+ elif ext in (".py", ".js", ".ts", ".go", ".java", ".cpp", ".c", ".rs",
854
+ ".txt", ".md", ".json", ".yaml", ".yml", ".csv", ".toml"):
855
+ try:
856
+ text_content = file_bytes.decode("utf-8", errors="replace")[:8000]
857
+ except Exception:
858
+ return "❌ 文件编码无法识别"
859
+
860
+ else:
861
+ try:
862
+ text_content = file_bytes.decode("utf-8", errors="replace")[:4000]
863
+ except Exception:
864
+ return f"❌ 不支持的文件类型: {ext}"
865
+
866
+ if not text_content.strip():
867
+ return "❌ 文件内容为空或无法提取文本"
868
+
869
+ query = (
870
+ f"请分析以下文件({filename})的内容,给出主要信息、关键数据和洞察总结:\n\n"
871
+ f"```\n{text_content[:6000]}\n```"
872
+ )
873
+ return await _query_aria_llm(query, timeout=120)
874
+
875
+
876
+ # ── Event verifier ────────────────────────────────────────────────────────────
877
+
878
+ def verify_feishu_signature(timestamp: str, nonce: str, body_bytes: bytes,
879
+ encrypt_key: str) -> bool:
880
+ """Verify Feishu event signature (optional but recommended in production)."""
881
+ if not encrypt_key:
882
+ return True
883
+ s = (timestamp + nonce + encrypt_key).encode() + body_bytes
884
+ return hmac.compare_digest(
885
+ hashlib.sha256(s).hexdigest(),
886
+ "" # caller should pass the X-Lark-Signature header value
887
+ )
888
+
889
+
890
+ # ── Main event dispatcher (called by feishu_routes.py or standalone) ──────────
891
+
892
+ async def dispatch_event(raw: Dict[str, Any]) -> Dict[str, Any]:
893
+ """
894
+ Handle one Feishu event payload.
895
+ Supports: text / audio / image / file / post (富文本)
896
+ Returns a dict to be sent as JSON response (HTTP 200 required by Feishu).
897
+ """
898
+ # 1. URL verification challenge
899
+ if "challenge" in raw:
900
+ return {"challenge": raw["challenge"]}
901
+
902
+ header = raw.get("header", {})
903
+ event = raw.get("event", {})
904
+ event_type = header.get("event_type") or raw.get("type", "")
905
+
906
+ if event_type not in ("im.message.receive_v1", "message"):
907
+ return {"code": 0}
908
+
909
+ msg = event.get("message") or {}
910
+ msg_id = msg.get("message_id", "")
911
+ msg_type = msg.get("message_type", "text")
912
+ sender = event.get("sender", {}).get("sender_id", {})
913
+ user_id = sender.get("user_id", "") or sender.get("open_id", "")
914
+ chat_id = msg.get("chat_id", "")
915
+
916
+ logger.info("dispatch_event: type=%s msg_id=%s msg_type=%s user=%s chat=%s",
917
+ event_type, msg_id, msg_type, user_id, chat_id)
918
+
919
+ if not msg_id:
920
+ logger.error("dispatch_event: msg_id is EMPTY — event structure may differ: %s",
921
+ json.dumps(raw, ensure_ascii=False)[:600])
922
+ return {"code": 0}
923
+
924
+ if not _is_allowed_user(user_id):
925
+ logger.warning("Blocked user %s (not in FEISHU_ALLOWED_USER_IDS)", user_id)
926
+ return {"code": 0}
927
+
928
+ content_raw = msg.get("content", "{}")
929
+ try:
930
+ content = json.loads(content_raw)
931
+ except Exception:
932
+ content = {}
933
+
934
+ # ── Text message ──────────────────────────────────────────────────────────
935
+ if msg_type == "text":
936
+ text = content.get("text", "").strip()
937
+ # Strip @bot mention (飞书群里 @ 机器人会带前缀)
938
+ if text.startswith("@"):
939
+ text = " ".join(text.split()[1:]).strip()
940
+ if not text:
941
+ return {"code": 0}
942
+
943
+ if text.startswith("/"):
944
+ logger.info("Feishu /cmd from %s: %s", user_id, text[:80])
945
+ asyncio.create_task(_handle_command(text, msg_id, user_id, chat_id))
946
+ else:
947
+ # Free-form natural language → Aria LLM
948
+ logger.info("Feishu NL query from %s: %s", user_id, text[:80])
949
+ asyncio.create_task(_handle_nl_query(text, msg_id, chat_id))
950
+
951
+ # ── Voice / Audio ─────────────────────────────────────────────────────────
952
+ elif msg_type == "audio":
953
+ file_key = content.get("file_key", "")
954
+ logger.info("Feishu audio from %s, key=%s", user_id, file_key)
955
+ asyncio.create_task(_handle_audio(file_key, msg_id))
956
+
957
+ # ── Image ────────────────────────────────────────────────────────────────
958
+ elif msg_type == "image":
959
+ image_key = content.get("image_key", "")
960
+ logger.info("Feishu image from %s, key=%s", user_id, image_key)
961
+ asyncio.create_task(_handle_image(image_key, msg_id))
962
+
963
+ # ── File attachment ───────────────────────────────────────────────────────
964
+ elif msg_type == "file":
965
+ file_key = content.get("file_key", "")
966
+ file_name = content.get("file_name", "attachment")
967
+ logger.info("Feishu file from %s: %s", user_id, file_name)
968
+ asyncio.create_task(_handle_file(file_key, file_name, msg_id))
969
+
970
+ # ── Rich text (post) — extract plain text ─────────────────────────────────
971
+ elif msg_type == "post":
972
+ try:
973
+ # post content: {"zh_cn": {"title":"...","content":[[{"tag":"text","text":"..."},...]]}}
974
+ lang_content = content.get("zh_cn") or content.get("en_us") or {}
975
+ title = lang_content.get("title", "")
976
+ paras = lang_content.get("content", [])
977
+ texts = []
978
+ for para in paras:
979
+ for seg in para:
980
+ if seg.get("tag") == "text":
981
+ texts.append(seg.get("text", ""))
982
+ plain = title + ("\n" if title else "") + " ".join(texts)
983
+ if plain.strip():
984
+ asyncio.create_task(_handle_nl_query(plain.strip(), msg_id))
985
+ except Exception as exc:
986
+ logger.warning("post parse error: %s", exc)
987
+
988
+ return {"code": 0}
989
+
990
+
991
+ # ── Message type handlers (run as background tasks) ───────────────────────────
992
+
993
+ _MARKET_BRIEF_TRIGGERS = frozenset({
994
+ "行情查询", "市场行情", "股市行情", "今日行情", "行情", "大盘", "大盘行情",
995
+ "市场概况", "今日市场", "A股行情", "港股行情", "美股行情", "晨报",
996
+ "market", "market overview", "stock market", "markets today",
997
+ })
998
+
999
+ _FOOTBALL_PREDICT_TRIGGERS = (
1000
+ "预测", "谁赢", "谁会赢", "比分预测", "胜率", "分析比赛",
1001
+ "predict", "who wins", "match preview",
1002
+ )
1003
+
1004
+ # ── 常用 A股/港股 中文名 → 股票代码(用于 NL 解析)──────────────────────────
1005
+ _CN_COMPANY_TICKER: dict[str, str] = {
1006
+ # 银行
1007
+ "工商银行": "601398", "工行": "601398",
1008
+ "建设银行": "601939", "建行": "601939",
1009
+ "农业银行": "601288", "农行": "601288",
1010
+ "中国银行": "601988", "中行": "601988",
1011
+ "招商银行": "600036", "招行": "600036",
1012
+ "平安银行": "000001",
1013
+ "兴业银行": "601166",
1014
+ "浦发银行": "600000",
1015
+ "光大银行": "601818",
1016
+ # 券商/保险
1017
+ "中信证券": "600030",
1018
+ "海通证券": "600837",
1019
+ "中国平安": "601318", "平安": "601318",
1020
+ "中国人寿": "601628",
1021
+ # 能源/化工
1022
+ "中国石油": "601857", "中石油": "601857",
1023
+ "中国石化": "600028", "中石化": "600028",
1024
+ "中国神华": "601088",
1025
+ # 有色金属
1026
+ "江西铜业": "600362",
1027
+ "紫金矿业": "601899",
1028
+ "中金黄金": "600489",
1029
+ "山东黄金": "600547",
1030
+ "洛阳钼业": "603993",
1031
+ "铜陵有色": "000630",
1032
+ "中国铝业": "601600",
1033
+ "南方铜业": "SCCO",
1034
+ "自由港": "FCX", "自由港麦克莫兰": "FCX",
1035
+ # 消费
1036
+ "贵州茅台": "600519", "茅台": "600519",
1037
+ "五粮液": "000858",
1038
+ "洋河股份": "002304",
1039
+ "海天味业": "603288",
1040
+ "伊利股份": "600887",
1041
+ "格力电器": "000651", "格力": "000651",
1042
+ "美的集团": "000333", "美的": "000333",
1043
+ "海尔智家": "600690",
1044
+ # 科技/互联网
1045
+ "腾讯": "0700", "腾讯控股": "0700",
1046
+ "阿里巴巴": "BABA", "阿里": "BABA",
1047
+ "京东": "JD",
1048
+ "百度": "BIDU",
1049
+ "比亚迪": "002594",
1050
+ "宁德时代": "300750", "宁德": "300750",
1051
+ "中芯国际": "688981",
1052
+ "海康威视": "002415",
1053
+ # 地产
1054
+ "万科": "000002", "万科A": "000002",
1055
+ "碧桂园": "2007",
1056
+ "恒大": "3333",
1057
+ # 医药
1058
+ "恒瑞医药": "600276",
1059
+ "迈瑞医疗": "300760",
1060
+ # 全球大宗商品矿企
1061
+ "必和必拓": "BHP", "必拓": "BHP",
1062
+ "力拓": "RIO", "力拓集团": "RIO",
1063
+ "淡水河谷": "VALE",
1064
+ "嘉能可": "GLEN.L",
1065
+ }
1066
+
1067
+ # ── 大宗商品关键词 → (商品期货代码, 相关上市公司) ─────────────────────────────
1068
+ _COMMODITY_MAP: dict[str, tuple[str, list[str], str]] = {
1069
+ # keyword: (yfinance_symbol, [related_tickers], display_name)
1070
+ "铜": ("HG=F", ["FCX", "SCCO", "600362", "601899", "000630"], "铜 COMEX"),
1071
+ "黄金": ("GC=F", ["GLD", "NEM", "GOLD", "600547", "600489"], "黄金 COMEX"),
1072
+ "gold": ("GC=F", ["GLD", "NEM", "GOLD", "600547"], "Gold COMEX"),
1073
+ "白银": ("SI=F", ["SLV", "PAAS", "AG"], "白银 COMEX"),
1074
+ "原油": ("CL=F", ["XOM", "CVX", "601857", "600028"], "原油 WTI"),
1075
+ "oil": ("CL=F", ["XOM", "CVX", "BP", "601857"], "Crude Oil WTI"),
1076
+ "天然气":("NG=F", ["UNG", "LNG", "CQP"], "天然气 NYMEX"),
1077
+ "铁矿石":("TIO=F", ["BHP", "RIO", "VALE", "601088"], "铁矿石"),
1078
+ "铝": ("ALI=F", ["AA", "CENX", "601600"], "铝 LME"),
1079
+ "锂": ("", ["ALB", "SQM", "LTHM", "300750", "002594"], "锂矿/电池"),
1080
+ "锂矿": ("", ["ALB", "SQM", "LTHM", "300750"], "锂矿"),
1081
+ "小麦": ("ZW=F", ["ADM", "BG", "INGR"], "小麦 CBOT"),
1082
+ "大豆": ("ZS=F", ["ADM", "BG", "DE"], "大豆 CBOT"),
1083
+ }
1084
+
1085
+
1086
+ async def _fetch_market_snapshot() -> str:
1087
+ """
1088
+ Build a real-time market snapshot for A股 + 港股 + US indices directly via
1089
+ yfinance — bypasses the LLM subprocess so we always get actual data.
1090
+ """
1091
+ import yfinance as yf
1092
+ import asyncio
1093
+
1094
+ _INDICES = [
1095
+ ("^SSEC", "上证指数"),
1096
+ ("^HSI", "恒生指数"),
1097
+ ("^GSPC", "标普500"),
1098
+ ("^IXIC", "纳斯达克"),
1099
+ ("^N225", "日经225"),
1100
+ ("GC=F", "黄金"),
1101
+ ("CL=F", "原油"),
1102
+ ]
1103
+
1104
+ def _fetch_one(sym: str):
1105
+ try:
1106
+ ti = yf.Ticker(sym)
1107
+ fi = ti.fast_info
1108
+ p = getattr(fi, "last_price", None)
1109
+ pc = getattr(fi, "previous_close", None)
1110
+ if p and pc and pc > 0:
1111
+ pct = (p - pc) / pc * 100
1112
+ arrow = "▲" if pct >= 0 else "▼"
1113
+ return f"{arrow} {p:,.2f} ({pct:+.2f}%)"
1114
+ elif p:
1115
+ return f"¥{p:,.2f}"
1116
+ except Exception:
1117
+ pass
1118
+ return "—"
1119
+
1120
+ loop = asyncio.get_event_loop()
1121
+ lines = ["**主要市场行情**\n"]
1122
+ for sym, label in _INDICES:
1123
+ val = await loop.run_in_executor(None, _fetch_one, sym)
1124
+ lines.append(f"**{label}** {val}")
1125
+
1126
+ from datetime import datetime
1127
+ ts = datetime.now().strftime("%H:%M")
1128
+ lines.append(f"\n_更新时间: {ts}_")
1129
+ return "\n".join(lines)
1130
+
1131
+
1132
+ async def _fetch_commodity_with_stocks(keyword: str) -> str:
1133
+ """
1134
+ Fetch commodity futures price + related stock prices for a given commodity keyword.
1135
+ Returns a formatted multi-line string for the Feishu card body.
1136
+ """
1137
+ import yfinance as yf
1138
+ import asyncio
1139
+
1140
+ entry = _COMMODITY_MAP.get(keyword)
1141
+ if not entry:
1142
+ return ""
1143
+ futures_sym, related_tickers, display_name = entry
1144
+
1145
+ def _price_line(sym: str) -> str:
1146
+ try:
1147
+ ti = yf.Ticker(sym)
1148
+ fi = ti.fast_info
1149
+ p = getattr(fi, "last_price", None)
1150
+ pc = getattr(fi, "previous_close", None)
1151
+ name = sym
1152
+ # Try to get a short name
1153
+ try:
1154
+ info = ti.info
1155
+ name = info.get("shortName", sym)[:12]
1156
+ except Exception:
1157
+ pass
1158
+ if p and pc and pc > 0:
1159
+ pct = (p - pc) / pc * 100
1160
+ arrow = "▲" if pct >= 0 else "▼"
1161
+ currency = "¥" if sym.isdigit() else "$"
1162
+ return f"{arrow} **{name}** ({sym}) {currency}{p:,.2f} ({pct:+.2f}%)"
1163
+ elif p:
1164
+ currency = "¥" if sym.isdigit() else "$"
1165
+ return f"**{name}** ({sym}) {currency}{p:,.2f}"
1166
+ except Exception:
1167
+ pass
1168
+ return f"**{sym}** —"
1169
+
1170
+ loop = asyncio.get_event_loop()
1171
+ sections = []
1172
+
1173
+ # Commodity futures
1174
+ if futures_sym:
1175
+ fut_line = await loop.run_in_executor(None, _price_line, futures_sym)
1176
+ sections.append(f"**{display_name} 期货**\n{fut_line}")
1177
+
1178
+ # Related stocks
1179
+ stock_lines = []
1180
+ for sym in related_tickers[:5]:
1181
+ # A-share: append exchange suffix for yfinance
1182
+ yfn = sym
1183
+ if sym.isdigit() and len(sym) == 6:
1184
+ yfn = sym + (".SS" if sym.startswith(("6", "5")) else ".SZ")
1185
+ line = await loop.run_in_executor(None, _price_line, yfn)
1186
+ stock_lines.append(line)
1187
+
1188
+ if stock_lines:
1189
+ sections.append("**相关上市公司**\n" + "\n".join(stock_lines))
1190
+
1191
+ from datetime import datetime
1192
+ ts = datetime.now().strftime("%H:%M")
1193
+ return "\n\n".join(sections) + f"\n\n_数据时间: {ts}_"
1194
+
1195
+
1196
+ def _resolve_cn_company(text: str) -> str:
1197
+ """
1198
+ Replace Chinese company names in text with their ticker symbols.
1199
+ E.g. "江西铜业的走势" → "600362的走势"
1200
+ Returns modified text; if nothing matched, returns original.
1201
+ """
1202
+ result = text
1203
+ for cn_name, ticker in _CN_COMPANY_TICKER.items():
1204
+ if cn_name in result:
1205
+ result = result.replace(cn_name, ticker)
1206
+ return result
1207
+
1208
+
1209
+ async def _handle_nl_query(text: str, message_id: str, chat_id: str = "") -> None:
1210
+ """Route free-form natural language to Aria LLM and reply."""
1211
+ import re as _re_nl
1212
+ _low = text.strip().lower()
1213
+ _orig = text.strip()
1214
+
1215
+ # Fast-path: generic market brief request — call yfinance directly (bypasses LLM)
1216
+ if _low in _MARKET_BRIEF_TRIGGERS or (
1217
+ any(k in _low for k in ("行情查询", "市场行情", "大盘今日", "今日大盘")) and
1218
+ not any(c.isalpha() and c.upper() == c for c in text) # no uppercase ticker
1219
+ ):
1220
+ await reply_or_send(message_id, chat_id, "📊 获取市场概况…", "正在抓取主要指数数据…", "blue")
1221
+ try:
1222
+ snapshot = await _fetch_market_snapshot()
1223
+ await reply_or_send(message_id, chat_id, "📊 市场行情", snapshot, "turquoise",
1224
+ footer="Aria Code · yfinance 实时数据")
1225
+ except Exception as _e:
1226
+ await reply_or_send(message_id, chat_id, "❌ 行情获取失败", str(_e)[:200], "red")
1227
+ return
1228
+
1229
+ # Fast-path: commodity + related stocks (e.g. "铜的相关公司", "黄金走势")
1230
+ _commodity_kw = None
1231
+ for _ck in _COMMODITY_MAP:
1232
+ if _ck in _low:
1233
+ _commodity_kw = _ck
1234
+ break
1235
+ _needs_stocks = any(k in _low for k in ("相关公司", "相关股票", "产业链", "概念股", "走势", "行情", "估值"))
1236
+ if _commodity_kw and (_needs_stocks or len(_orig) <= 6):
1237
+ await reply_or_send(message_id, chat_id, f"🔍 查询{_commodity_kw}行情…",
1238
+ "正在获取期货及相关股票数据…", "blue")
1239
+ body = await _fetch_commodity_with_stocks(_commodity_kw)
1240
+ if body:
1241
+ await reply_or_send(message_id, chat_id, f"📦 {_commodity_kw} 市场概况",
1242
+ body, "turquoise", footer="Aria Code · 大宗商品数据")
1243
+ return
1244
+
1245
+ # Chinese company name → ticker resolution before sending to LLM
1246
+ resolved = _resolve_cn_company(_orig)
1247
+ if resolved != _orig:
1248
+ # Found at least one CN name; also annotate so LLM has context
1249
+ text = resolved + f" (原文: {_orig})"
1250
+
1251
+ # Fast-path: football match prediction — extract team names and call Poisson model directly
1252
+ _has_predict_kw = any(k in _low for k in _FOOTBALL_PREDICT_TRIGGERS)
1253
+ _has_football_kw = any(k in _low for k in ("足球", "世界杯", "欧冠", "英超", "比赛", "football", "soccer", "match", "world cup"))
1254
+ if _has_predict_kw or _has_football_kw:
1255
+ # Strip one or more leading context words (handles "预测今天加拿大跟波黑")
1256
+ _stripped = _re_nl.sub(
1257
+ r'^(?:预测|分析|谁赢|谁会赢|今天|明天|比赛|足球|世界杯|结果|比分|\s)+',
1258
+ '', _orig, flags=_re_nl.IGNORECASE
1259
+ )
1260
+ _vs_m = _re_nl.search(
1261
+ r'(.{2,20}?)\s*(?:vs\.?\s*|对阵\s*|对\s+|跟\s*|和\s*|pk\s*|——\s*|—\s*)(.{2,20})',
1262
+ _stripped, _re_nl.IGNORECASE
1263
+ )
1264
+ if _vs_m:
1265
+ home_t = _vs_m.group(1).strip().rstrip("的在")
1266
+ away_t = _vs_m.group(2).strip().rstrip("的在")
1267
+ if home_t and away_t and len(home_t) >= 2 and len(away_t) >= 2:
1268
+ await reply_or_send(message_id, chat_id, "⚽ 预测中…",
1269
+ f"> {home_t} vs {away_t}", "blue")
1270
+ await _handle_football_predict(f"{home_t} vs {away_t}", message_id)
1271
+ return
1272
+
1273
+ await reply_or_send(message_id, chat_id, "🤔 思考中…", f"> {_orig[:120]}", "blue")
1274
+ # Use direct LLM call (no subprocess, no tool execution) for conversational queries
1275
+ result = await _query_aria_direct(text, timeout=120)
1276
+ color = "red" if result.startswith("❌") else "green"
1277
+ await reply_or_send(message_id, chat_id, "💡 Aria 回答", result[:2000], color,
1278
+ footer="Aria Code · AI 分析")
1279
+
1280
+
1281
+ async def _handle_audio(file_key: str, message_id: str) -> None:
1282
+ """Download voice → transcribe → query Aria LLM."""
1283
+ await reply_card(message_id, "🎤 正在转写语音…", "下载中,请稍候…", "blue")
1284
+ audio_bytes = await _download_feishu_resource(message_id, file_key, rtype="file")
1285
+ if not audio_bytes:
1286
+ await reply_card(message_id, "❌ 语音下载失败", "无法获取语音文件", "red")
1287
+ return
1288
+ text = await _transcribe_voice(audio_bytes)
1289
+ if text.startswith("❌"):
1290
+ await reply_card(message_id, "❌ 语音转文字失败", text, "red")
1291
+ return
1292
+ await reply_card(message_id, "🎤 识别结果", f"**语音内容:**\n{text}\n\n---\n正在分析…", "blue")
1293
+ result = await _query_aria_llm(text, timeout=120)
1294
+ await reply_card(message_id, "💡 Aria 回答", result[:2000], "green",
1295
+ footer=f"语音转文字: {text[:60]}…")
1296
+
1297
+
1298
+ async def _handle_image(image_key: str, message_id: str) -> None:
1299
+ """Download image → visual LLM analysis."""
1300
+ await reply_card(message_id, "🖼️ 分析图片中…", "正在下载并识别,请稍候…", "blue")
1301
+ img_bytes = await _download_feishu_resource(message_id, image_key, rtype="image")
1302
+ if not img_bytes:
1303
+ await reply_card(message_id, "❌ 图片下载失败", "无法获取图片", "red")
1304
+ return
1305
+ result = await _analyze_image(img_bytes)
1306
+ await reply_card(message_id, "🖼️ 图片分析", result[:2000], "turquoise",
1307
+ footer="Aria Code · 视觉 AI")
1308
+
1309
+
1310
+ async def _handle_file(file_key: str, filename: str, message_id: str) -> None:
1311
+ """Download file → parse → Aria LLM analysis."""
1312
+ await reply_card(message_id, f"📄 解析文件:{filename}",
1313
+ "正在下载并解析,请稍候…", "blue")
1314
+ file_bytes = await _download_feishu_resource(message_id, file_key, rtype="file")
1315
+ if not file_bytes:
1316
+ await reply_card(message_id, "❌ 文件下载失败", "无法获取文件", "red")
1317
+ return
1318
+ result = await _analyze_file(file_bytes, filename)
1319
+ await reply_card(message_id, f"📄 {filename} 分析完成", result[:2000], "turquoise",
1320
+ footer="Aria Code · 文件智能解析")
1321
+
1322
+
1323
+ # ── Standalone HTTP server (for testing without FastAPI) ──────────────────────
1324
+
1325
+ async def _standalone_server(host: str = "0.0.0.0", port: int = 8888) -> None:
1326
+ """Minimal aiohttp-based server for standalone Feishu event reception."""
1327
+ try:
1328
+ from aiohttp import web
1329
+ except ImportError:
1330
+ logger.error("aiohttp not installed. pip install aiohttp")
1331
+ return
1332
+
1333
+ async def handle(request):
1334
+ try:
1335
+ body = await request.json()
1336
+ except Exception:
1337
+ return web.json_response({"code": 1, "msg": "bad json"}, status=400)
1338
+ result = await dispatch_event(body)
1339
+ return web.json_response(result)
1340
+
1341
+ app = web.Application()
1342
+ app.router.add_post("/feishu/event", handle)
1343
+ app.router.add_post("/api/v1/feishu/event", handle)
1344
+
1345
+ runner = web.AppRunner(app)
1346
+ await runner.setup()
1347
+ site = web.TCPSite(runner, host, port)
1348
+ await site.start()
1349
+ logger.info("Feishu standalone server listening on http://%s:%d/feishu/event", host, port)
1350
+ logger.info("Configure this URL in Feishu Developer Console → Event Subscription")
1351
+ await asyncio.Event().wait()
1352
+
1353
+
1354
+ if __name__ == "__main__":
1355
+ import sys
1356
+ logging.basicConfig(level=logging.INFO,
1357
+ format="%(asctime)s [feishu] %(levelname)s %(message)s")
1358
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 8888
1359
+ asyncio.run(_standalone_server(port=port))