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.
- agents/__init__.py +32 -0
- agents/base.py +190 -0
- agents/deep/__init__.py +37 -0
- agents/deep/calibration_loop.py +144 -0
- agents/deep/critic.py +125 -0
- agents/deep/deepen.py +193 -0
- agents/deep/models.py +149 -0
- agents/deep/pipeline.py +164 -0
- agents/deep/quant_fusion.py +192 -0
- agents/deep/themes.py +95 -0
- agents/deep/tiers.py +106 -0
- agents/financial/__init__.py +10 -0
- agents/financial/catalyst.py +279 -0
- agents/financial/debate.py +145 -0
- agents/financial/earnings.py +303 -0
- agents/financial/fundamental.py +159 -0
- agents/financial/macro.py +99 -0
- agents/financial/news.py +207 -0
- agents/financial/risk.py +132 -0
- agents/financial/sector.py +279 -0
- agents/financial/synthesis.py +274 -0
- agents/financial/technical.py +258 -0
- agents/portfolio_agent.py +333 -0
- agents/realty/__init__.py +62 -0
- agents/realty/asset_diagnosis.py +150 -0
- agents/realty/business_match.py +165 -0
- agents/realty/cashflow_verify.py +208 -0
- agents/realty/contract_rules.py +209 -0
- agents/realty/energy_anomaly.py +188 -0
- agents/realty/exit_settlement.py +207 -0
- agents/realty/fulfillment_risk.py +205 -0
- agents/realty/ops_optimize.py +159 -0
- agents/realty/revenue_share.py +214 -0
- agents/registry.py +144 -0
- agents/sports/__init__.py +0 -0
- agents/sports/football_agent.py +169 -0
- agents/team.py +289 -0
- aliyun_data_client.py +660 -0
- apps/README.md +12 -0
- apps/__init__.py +2 -0
- apps/channels/README.md +15 -0
- apps/cli/README.md +13 -0
- apps/cli/__init__.py +2 -0
- apps/cli/bootstrap.py +99 -0
- apps/cli/codegen_paths.py +29 -0
- apps/cli/commands/__init__.py +16 -0
- apps/cli/commands/analysis_cmds.py +288 -0
- apps/cli/commands/backtest_cmds.py +1887 -0
- apps/cli/commands/broker_cmds.py +1154 -0
- apps/cli/commands/business_workflow_cmds.py +289 -0
- apps/cli/commands/catalog.py +84 -0
- apps/cli/commands/data_cmds.py +405 -0
- apps/cli/commands/diagnostic_cmds.py +179 -0
- apps/cli/commands/diagnostic_ops_cmds.py +696 -0
- apps/cli/commands/finance_render.py +12 -0
- apps/cli/commands/market.py +399 -0
- apps/cli/commands/market_cmds.py +1276 -0
- apps/cli/commands/market_context.py +425 -0
- apps/cli/commands/market_render.py +7 -0
- apps/cli/commands/model_cmds.py +1579 -0
- apps/cli/commands/ops_cmds.py +668 -0
- apps/cli/commands/portfolio_cmds.py +962 -0
- apps/cli/commands/report.py +377 -0
- apps/cli/commands/scaffold_templates.py +617 -0
- apps/cli/commands/session_cmds.py +179 -0
- apps/cli/commands/session_ux_cmds.py +280 -0
- apps/cli/commands/team.py +588 -0
- apps/cli/commands/team_render.py +8 -0
- apps/cli/commands/ui_cmds.py +358 -0
- apps/cli/commands/workflow_cmds.py +279 -0
- apps/cli/commands/workspace_cmds.py +1414 -0
- apps/cli/config_paths.py +70 -0
- apps/cli/config_store.py +61 -0
- apps/cli/deterministic.py +122 -0
- apps/cli/direct.py +48 -0
- apps/cli/github_app_auth.py +135 -0
- apps/cli/handlers/__init__.py +11 -0
- apps/cli/handlers/broker_handlers.py +122 -0
- apps/cli/handlers/chart_handlers.py +1309 -0
- apps/cli/handlers/market_handlers.py +2509 -0
- apps/cli/handlers/realty_handlers.py +114 -0
- apps/cli/handlers/strategy_advice.py +82 -0
- apps/cli/hooks.py +180 -0
- apps/cli/i18n.py +284 -0
- apps/cli/intent.py +136 -0
- apps/cli/intent_router.py +217 -0
- apps/cli/lifecycle_hooks.py +48 -0
- apps/cli/main.py +29 -0
- apps/cli/market_metadata.py +135 -0
- apps/cli/market_universe.py +265 -0
- apps/cli/message_processing.py +257 -0
- apps/cli/plan_mode.py +139 -0
- apps/cli/plotly_html.py +15 -0
- apps/cli/prediction_feedback.py +202 -0
- apps/cli/preflight.py +497 -0
- apps/cli/project_aria.py +60 -0
- apps/cli/prompts/__init__.py +0 -0
- apps/cli/prompts/coding.py +658 -0
- apps/cli/prompts/system_prompts.py +531 -0
- apps/cli/prompts/ui.py +434 -0
- apps/cli/providers/__init__.py +1 -0
- apps/cli/providers/base.py +271 -0
- apps/cli/providers/chat_routing.py +80 -0
- apps/cli/providers/llm/__init__.py +1 -0
- apps/cli/providers/llm/ollama_stream.py +1170 -0
- apps/cli/providers/llm/sse_stream.py +216 -0
- apps/cli/providers/runtime_bridge.py +185 -0
- apps/cli/runtime_consumer.py +489 -0
- apps/cli/session_export.py +87 -0
- apps/cli/session_jsonl.py +207 -0
- apps/cli/session_store.py +112 -0
- apps/cli/todo_tracker.py +190 -0
- apps/cli/tools/__init__.py +40 -0
- apps/cli/tools/context.py +46 -0
- apps/cli/tools/file_tools.py +112 -0
- apps/cli/tools/market_tools.py +549 -0
- apps/cli/tools/notebook_tools.py +111 -0
- apps/cli/tools/system_tools.py +669 -0
- apps/cli/tools/write_tools.py +715 -0
- apps/cli/tradingview_bridge.py +434 -0
- apps/cli/update_check.py +152 -0
- apps/cli/utils/__init__.py +0 -0
- apps/cli/utils/market_detect.py +1578 -0
- apps/daemon/README.md +14 -0
- apps/vscode/README.md +115 -0
- apps/vscode/package.json +70 -0
- aria_cli.py +11636 -0
- aria_code-4.1.3.dist-info/METADATA +952 -0
- aria_code-4.1.3.dist-info/RECORD +284 -0
- aria_code-4.1.3.dist-info/WHEEL +5 -0
- aria_code-4.1.3.dist-info/entry_points.txt +2 -0
- aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
- aria_code-4.1.3.dist-info/top_level.txt +50 -0
- aria_daemon.py +1295 -0
- aria_feishu_bot.py +1359 -0
- aria_relay_client.py +182 -0
- aria_relay_server.py +405 -0
- aria_telegram_bot.py +202 -0
- ariarc.py +328 -0
- artifacts.py +491 -0
- backtest_report.py +472 -0
- brokers/__init__.py +72 -0
- brokers/base.py +207 -0
- brokers/capabilities.py +264 -0
- brokers/cn/__init__.py +10 -0
- brokers/cn/easytrader_broker.py +193 -0
- brokers/cn/futu_broker.py +194 -0
- brokers/cn/longbridge_broker.py +190 -0
- brokers/cn/tiger_broker.py +196 -0
- brokers/cn/xtquant_broker.py +175 -0
- brokers/config.py +364 -0
- brokers/intl/__init__.py +5 -0
- brokers/intl/alpaca_broker.py +183 -0
- brokers/intl/ibkr_broker.py +215 -0
- brokers/intl/webull_broker.py +156 -0
- brokers/paper_broker.py +259 -0
- brokers/planning.py +296 -0
- brokers/registry.py +181 -0
- brokers/trading.py +237 -0
- change_store.py +127 -0
- command_safety.py +19 -0
- computer_use_tools.py +504 -0
- dashboard_generator.py +578 -0
- data_analysis_tools.py +808 -0
- data_cleaner.py +483 -0
- data_service.py +481 -0
- datasources/__init__.py +23 -0
- datasources/base.py +166 -0
- datasources/router.py +221 -0
- datasources/sources/__init__.py +15 -0
- datasources/sources/akshare_source.py +269 -0
- datasources/sources/alpha_vantage_source.py +202 -0
- datasources/sources/edgar_source.py +218 -0
- datasources/sources/finnhub_source.py +197 -0
- datasources/sources/fred_source.py +219 -0
- datasources/sources/tushare_source.py +141 -0
- datasources/sources/web_scraper_source.py +278 -0
- datasources/sources/world_bank_source.py +205 -0
- datasources/sources/yfinance_source.py +152 -0
- demo_player.py +204 -0
- doctor.py +508 -0
- file_analysis_tools.py +734 -0
- finance_formulas.py +389 -0
- football_data_client.py +1670 -0
- intent_classifier.py +358 -0
- local_finance_tools.py +3221 -0
- local_llm_provider.py +552 -0
- macro_tools.py +368 -0
- market_data_client.py +1899 -0
- mcp_client.py +506 -0
- memory_manager.py +245 -0
- model_capability.py +416 -0
- notification_tools.py +248 -0
- packages/__init__.py +23 -0
- packages/aria_agents/__init__.py +5 -0
- packages/aria_agents/manifest.py +69 -0
- packages/aria_core/__init__.py +34 -0
- packages/aria_core/architecture.py +192 -0
- packages/aria_core/export.py +124 -0
- packages/aria_core/manifest.py +65 -0
- packages/aria_infra/__init__.py +15 -0
- packages/aria_infra/arthera.py +52 -0
- packages/aria_infra/doctor.py +246 -0
- packages/aria_infra/product.py +37 -0
- packages/aria_mcp/__init__.py +25 -0
- packages/aria_mcp/bridge.py +38 -0
- packages/aria_mcp/config.py +97 -0
- packages/aria_mcp/tools.py +61 -0
- packages/aria_sdk/__init__.py +19 -0
- packages/aria_sdk/client.py +396 -0
- packages/aria_sdk/providers.py +70 -0
- packages/aria_sdk/streaming.py +73 -0
- packages/aria_sdk/types.py +86 -0
- packages/aria_services/__init__.py +55 -0
- packages/aria_services/context.py +258 -0
- packages/aria_services/data.py +11 -0
- packages/aria_services/provider_health.py +189 -0
- packages/aria_services/registry.py +213 -0
- packages/aria_services/usage.py +138 -0
- packages/aria_skills/__init__.py +5 -0
- packages/aria_skills/registry.py +59 -0
- packages/aria_tools/__init__.py +5 -0
- packages/aria_tools/registry.py +128 -0
- packages/quant_engine/__init__.py +6 -0
- packages/quant_engine/sports/__init__.py +72 -0
- packages/quant_engine/sports/calibrator.py +353 -0
- packages/quant_engine/sports/dixon_coles.py +234 -0
- packages/quant_engine/sports/elo.py +299 -0
- packages/quant_engine/sports/form.py +188 -0
- packages/quant_engine/sports/h2h.py +195 -0
- packages/quant_engine/sports/ml_model.py +354 -0
- packages/quant_engine/sports/predictor.py +311 -0
- packages/quant_engine/sports/tracker.py +664 -0
- packages/quant_engine/stochastic/__init__.py +27 -0
- packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
- packages/quant_engine/stochastic/ito_calculus.py +477 -0
- packages/quant_engine/stochastic/kelly_criterion.py +181 -0
- packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
- packages/quant_engine/stochastic/options_pricing.py +573 -0
- packages/quant_engine/stochastic/stochastic_processes.py +90 -0
- plan_utils.py +194 -0
- plugin_loader.py +328 -0
- portfolio_ledger.py +262 -0
- privacy/__init__.py +5 -0
- privacy/feedback.py +123 -0
- project_tools.py +525 -0
- providers/__init__.py +30 -0
- providers/llm/__init__.py +19 -0
- providers/llm/anthropic.py +184 -0
- providers/llm/base.py +139 -0
- providers/llm/ollama.py +128 -0
- providers/llm/openai_compat.py +282 -0
- providers/llm/registry.py +358 -0
- realty_data_tools.py +659 -0
- report_generator.py +1314 -0
- runtime/__init__.py +103 -0
- runtime/agent_loop.py +1183 -0
- runtime/approval.py +51 -0
- runtime/events.py +102 -0
- runtime/gateway.py +128 -0
- runtime/lsp.py +346 -0
- runtime/subagent.py +258 -0
- runtime/tool_executor.py +104 -0
- runtime/tool_policy.py +106 -0
- safety/__init__.py +21 -0
- safety/permissions.py +275 -0
- setup_wizard.py +653 -0
- strategy_vault.py +420 -0
- ui/__init__.py +100 -0
- ui/banner.py +310 -0
- ui/completer.py +391 -0
- ui/console.py +271 -0
- ui/image_render.py +243 -0
- ui/input_box.py +376 -0
- ui/picker.py +195 -0
- ui/render/__init__.py +11 -0
- ui/render/finance.py +1480 -0
- ui/render/market.py +225 -0
- ui/render/output.py +681 -0
- ui/render/team.py +346 -0
- ui/robot.py +235 -0
- workspace/__init__.py +6 -0
- workspace/files.py +170 -0
- 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))
|