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_relay_client.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
aria_relay_client.py — 连接 Aria 中继服务器的 WebSocket 客户端
|
|
4
|
+
================================================================
|
|
5
|
+
本机运行,把中继服务器转发来的飞书消息交给 aria_feishu_bot 处理,
|
|
6
|
+
并把 LLM 回复返回给中继服务器,再由服务器推送到飞书。
|
|
7
|
+
|
|
8
|
+
启动方式:
|
|
9
|
+
python3 aria_relay_client.py # 单次连接(断线自动重连)
|
|
10
|
+
python3 aria_relay_client.py --once # 调试:收到第一条消息后退出
|
|
11
|
+
|
|
12
|
+
所需环境变量(读取 ~/.aria/.env):
|
|
13
|
+
ARIA_RELAY_URL wss://relay.aria.ai(或自建服务器地址)
|
|
14
|
+
ARIA_RELAY_CLIENT_ID setup_wizard 生成的 12 位 hex id
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("aria.relay_client")
|
|
28
|
+
|
|
29
|
+
# ── 加载 ~/.aria/.env ──────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
def _load_env() -> None:
|
|
32
|
+
env_file = Path.home() / ".aria" / ".env"
|
|
33
|
+
if env_file.exists():
|
|
34
|
+
for line in env_file.read_text().splitlines():
|
|
35
|
+
line = line.strip()
|
|
36
|
+
if line and not line.startswith("#") and "=" in line:
|
|
37
|
+
k, _, v = line.partition("=")
|
|
38
|
+
k = k.strip()
|
|
39
|
+
if k not in os.environ:
|
|
40
|
+
os.environ[k] = v.strip()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_load_env()
|
|
44
|
+
|
|
45
|
+
_RELAY_URL = os.environ.get("ARIA_RELAY_URL", "wss://relay.aria.ai")
|
|
46
|
+
_CLIENT_ID = os.environ.get("ARIA_RELAY_CLIENT_ID", "")
|
|
47
|
+
_RECONNECT_DELAY_MAX = 60 # seconds
|
|
48
|
+
_RECONNECT_DELAY_BASE = 3
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── 本地 aria_feishu_bot import ───────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def _get_feishu_bot():
|
|
54
|
+
aria_dir = Path(__file__).parent
|
|
55
|
+
if str(aria_dir) not in sys.path:
|
|
56
|
+
sys.path.insert(0, str(aria_dir))
|
|
57
|
+
try:
|
|
58
|
+
import aria_feishu_bot
|
|
59
|
+
return aria_feishu_bot
|
|
60
|
+
except ImportError as e:
|
|
61
|
+
logger.warning("aria_feishu_bot not importable: %s", e)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Message handler ───────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
async def _handle_message(raw_msg: dict, ws) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Server sends:
|
|
70
|
+
{"type": "message", "id": "req_xxx", "payload": <feishu_event_dict>}
|
|
71
|
+
|
|
72
|
+
We reply:
|
|
73
|
+
{"type": "response", "id": "req_xxx", "result": <any>}
|
|
74
|
+
"""
|
|
75
|
+
req_id = raw_msg.get("id", "")
|
|
76
|
+
payload = raw_msg.get("payload", {})
|
|
77
|
+
|
|
78
|
+
bot = _get_feishu_bot()
|
|
79
|
+
if bot is None:
|
|
80
|
+
result = {"error": "aria_feishu_bot unavailable"}
|
|
81
|
+
else:
|
|
82
|
+
try:
|
|
83
|
+
result = await bot.dispatch_event(payload)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.exception("dispatch_event error")
|
|
86
|
+
result = {"error": str(e)[:300]}
|
|
87
|
+
|
|
88
|
+
reply = json.dumps({"type": "response", "id": req_id, "result": result})
|
|
89
|
+
await ws.send(reply)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── Main loop ─────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
async def _connect_and_serve(once: bool = False) -> None:
|
|
95
|
+
try:
|
|
96
|
+
import websockets # type: ignore
|
|
97
|
+
except ImportError:
|
|
98
|
+
logger.error("websockets package not installed — run: pip install websockets")
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
|
|
101
|
+
if not _CLIENT_ID:
|
|
102
|
+
logger.error(
|
|
103
|
+
"ARIA_RELAY_CLIENT_ID is not set. "
|
|
104
|
+
"Run setup_wizard.py to generate your client ID."
|
|
105
|
+
)
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
delay = _RECONNECT_DELAY_BASE
|
|
109
|
+
while True:
|
|
110
|
+
try:
|
|
111
|
+
logger.info("Connecting to %s (client_id=%s)", _RELAY_URL, _CLIENT_ID)
|
|
112
|
+
async with websockets.connect(
|
|
113
|
+
_RELAY_URL,
|
|
114
|
+
ping_interval=30,
|
|
115
|
+
ping_timeout=10,
|
|
116
|
+
open_timeout=15,
|
|
117
|
+
) as ws:
|
|
118
|
+
# Register with server
|
|
119
|
+
await ws.send(json.dumps({
|
|
120
|
+
"type": "register",
|
|
121
|
+
"client_id": _CLIENT_ID,
|
|
122
|
+
}))
|
|
123
|
+
ack_raw = await asyncio.wait_for(ws.recv(), timeout=10)
|
|
124
|
+
ack = json.loads(ack_raw)
|
|
125
|
+
if not ack.get("ok"):
|
|
126
|
+
logger.error("Registration rejected: %s", ack.get("reason", "unknown"))
|
|
127
|
+
await asyncio.sleep(delay)
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
logger.info("Registered. Waiting for messages…")
|
|
131
|
+
delay = _RECONNECT_DELAY_BASE # reset on success
|
|
132
|
+
|
|
133
|
+
async for raw in ws:
|
|
134
|
+
try:
|
|
135
|
+
msg = json.loads(raw)
|
|
136
|
+
except json.JSONDecodeError:
|
|
137
|
+
logger.warning("Invalid JSON from relay: %r", raw[:100])
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if msg.get("type") == "ping":
|
|
141
|
+
await ws.send(json.dumps({"type": "pong"}))
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
if msg.get("type") == "message":
|
|
145
|
+
asyncio.create_task(_handle_message(msg, ws))
|
|
146
|
+
|
|
147
|
+
if once:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
except (OSError, ConnectionRefusedError) as e:
|
|
151
|
+
logger.warning("Connection failed: %s — retry in %ds", e, delay)
|
|
152
|
+
except asyncio.CancelledError:
|
|
153
|
+
logger.info("Relay client cancelled")
|
|
154
|
+
return
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.warning("Relay error: %s — retry in %ds", e, delay)
|
|
157
|
+
|
|
158
|
+
await asyncio.sleep(delay)
|
|
159
|
+
delay = min(delay * 2, _RECONNECT_DELAY_MAX)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main() -> None:
|
|
163
|
+
import argparse
|
|
164
|
+
parser = argparse.ArgumentParser(description="Aria 中继客户端")
|
|
165
|
+
parser.add_argument("--once", action="store_true", help="接收一条消息后退出(调试用)")
|
|
166
|
+
parser.add_argument("--verbose", "-v", action="store_true")
|
|
167
|
+
args = parser.parse_args()
|
|
168
|
+
|
|
169
|
+
logging.basicConfig(
|
|
170
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
171
|
+
format="%(asctime)s [aria-relay] %(message)s",
|
|
172
|
+
datefmt="%H:%M:%S",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
asyncio.run(_connect_and_serve(once=args.once))
|
|
177
|
+
except KeyboardInterrupt:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
main()
|
aria_relay_server.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
aria_relay_server.py — Aria 中继服务器
|
|
4
|
+
=======================================
|
|
5
|
+
你(产品方)部署一次,所有用户共用。
|
|
6
|
+
|
|
7
|
+
架构:
|
|
8
|
+
用户飞书消息
|
|
9
|
+
→ 飞书云 → POST /feishu/event
|
|
10
|
+
→ relay_server 查 feishu_user_id → 对应 WebSocket
|
|
11
|
+
→ relay_server 转发 payload 到用户本机
|
|
12
|
+
→ aria_relay_client.dispatch_event()
|
|
13
|
+
→ Aria LLM 回复
|
|
14
|
+
→ relay_server 收到 response
|
|
15
|
+
→ relay_server 调飞书 reply_message API
|
|
16
|
+
→ 飞书消息卡片展示给用户
|
|
17
|
+
|
|
18
|
+
WebSocket 注册流程:
|
|
19
|
+
1. 用户本机 aria_relay_client 连接 wss://relay.yourdomain.com
|
|
20
|
+
2. 发送: {"type": "register", "client_id": "aria-xxxxxxxxxxxx"}
|
|
21
|
+
3. 用户在飞书向 Aria Bot 发: /bind ARIA-BIND-ARIA-XXXXXXXXXXXX
|
|
22
|
+
4. 服务器记录: feishu_user_id → client_id
|
|
23
|
+
5. 后续消息经 WebSocket 透传
|
|
24
|
+
|
|
25
|
+
依赖:
|
|
26
|
+
pip install fastapi uvicorn websockets httpx
|
|
27
|
+
|
|
28
|
+
启动:
|
|
29
|
+
python3 aria_relay_server.py
|
|
30
|
+
# 或
|
|
31
|
+
uvicorn aria_relay_server:app --host 0.0.0.0 --port 8765
|
|
32
|
+
|
|
33
|
+
所需环境变量:
|
|
34
|
+
FEISHU_APP_ID 飞书应用 App ID
|
|
35
|
+
FEISHU_APP_SECRET 飞书应用 App Secret
|
|
36
|
+
RELAY_SECRET WebSocket 注册鉴权(可选,留空则不鉴权)
|
|
37
|
+
DB_PATH SQLite 路径(默认 ./relay.db)
|
|
38
|
+
MESSAGE_TIMEOUT 等待用户本机回复的超时秒数(默认 90)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import asyncio
|
|
44
|
+
import hashlib
|
|
45
|
+
import json
|
|
46
|
+
import logging
|
|
47
|
+
import os
|
|
48
|
+
import sqlite3
|
|
49
|
+
import time
|
|
50
|
+
import uuid
|
|
51
|
+
from contextlib import asynccontextmanager
|
|
52
|
+
from typing import Any, Optional
|
|
53
|
+
|
|
54
|
+
import httpx
|
|
55
|
+
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
56
|
+
from fastapi.responses import JSONResponse
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger("aria.relay_server")
|
|
59
|
+
|
|
60
|
+
# ── Config ────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
_FEISHU_APP_ID = os.environ.get("FEISHU_APP_ID", "")
|
|
63
|
+
_FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
|
|
64
|
+
_RELAY_SECRET = os.environ.get("RELAY_SECRET", "")
|
|
65
|
+
_DB_PATH = os.environ.get("DB_PATH", "./relay.db")
|
|
66
|
+
_MSG_TIMEOUT = int(os.environ.get("MESSAGE_TIMEOUT", "90"))
|
|
67
|
+
_FEISHU_API = "https://open.feishu.cn/open-apis"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── SQLite store ──────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def _db() -> sqlite3.Connection:
|
|
73
|
+
conn = sqlite3.connect(_DB_PATH, check_same_thread=False)
|
|
74
|
+
conn.row_factory = sqlite3.Row
|
|
75
|
+
conn.execute("""
|
|
76
|
+
CREATE TABLE IF NOT EXISTS bindings (
|
|
77
|
+
feishu_user_id TEXT PRIMARY KEY,
|
|
78
|
+
client_id TEXT NOT NULL,
|
|
79
|
+
bound_at REAL NOT NULL
|
|
80
|
+
)
|
|
81
|
+
""")
|
|
82
|
+
conn.execute("""
|
|
83
|
+
CREATE TABLE IF NOT EXISTS pending_binds (
|
|
84
|
+
client_id TEXT PRIMARY KEY,
|
|
85
|
+
created_at REAL NOT NULL
|
|
86
|
+
)
|
|
87
|
+
""")
|
|
88
|
+
conn.commit()
|
|
89
|
+
return conn
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_db_conn: Optional[sqlite3.Connection] = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_db() -> sqlite3.Connection:
|
|
96
|
+
global _db_conn
|
|
97
|
+
if _db_conn is None:
|
|
98
|
+
_db_conn = _db()
|
|
99
|
+
return _db_conn
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _lookup_client(feishu_user_id: str) -> Optional[str]:
|
|
103
|
+
row = get_db().execute(
|
|
104
|
+
"SELECT client_id FROM bindings WHERE feishu_user_id = ?",
|
|
105
|
+
(feishu_user_id,),
|
|
106
|
+
).fetchone()
|
|
107
|
+
return row["client_id"] if row else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _bind(feishu_user_id: str, client_id: str) -> None:
|
|
111
|
+
get_db().execute(
|
|
112
|
+
"INSERT OR REPLACE INTO bindings VALUES (?, ?, ?)",
|
|
113
|
+
(feishu_user_id, client_id, time.time()),
|
|
114
|
+
)
|
|
115
|
+
get_db().execute(
|
|
116
|
+
"DELETE FROM pending_binds WHERE client_id = ?", (client_id,)
|
|
117
|
+
)
|
|
118
|
+
get_db().commit()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _register_pending(client_id: str) -> None:
|
|
122
|
+
get_db().execute(
|
|
123
|
+
"INSERT OR REPLACE INTO pending_binds VALUES (?, ?)",
|
|
124
|
+
(client_id, time.time()),
|
|
125
|
+
)
|
|
126
|
+
get_db().commit()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _is_valid_client_id(client_id: str) -> bool:
|
|
130
|
+
row = get_db().execute(
|
|
131
|
+
"SELECT client_id FROM pending_binds WHERE client_id = ?", (client_id,)
|
|
132
|
+
).fetchone()
|
|
133
|
+
return row is not None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── WebSocket connection registry ─────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
_connections: dict[str, WebSocket] = {} # client_id → WebSocket
|
|
139
|
+
_pending_responses: dict[str, asyncio.Future] = {} # request_id → Future
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── Feishu API helpers ────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
_feishu_token_cache: dict[str, Any] = {}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _get_tenant_token() -> str:
|
|
148
|
+
now = time.time()
|
|
149
|
+
if _feishu_token_cache.get("expires_at", 0) > now + 60:
|
|
150
|
+
return _feishu_token_cache["token"]
|
|
151
|
+
|
|
152
|
+
async with httpx.AsyncClient() as client:
|
|
153
|
+
resp = await client.post(
|
|
154
|
+
f"{_FEISHU_API}/auth/v3/tenant_access_token/internal",
|
|
155
|
+
json={"app_id": _FEISHU_APP_ID, "app_secret": _FEISHU_APP_SECRET},
|
|
156
|
+
timeout=10,
|
|
157
|
+
)
|
|
158
|
+
data = resp.json()
|
|
159
|
+
|
|
160
|
+
token = data.get("tenant_access_token", "")
|
|
161
|
+
expire = int(data.get("expire", 7200))
|
|
162
|
+
_feishu_token_cache.update({"token": token, "expires_at": now + expire})
|
|
163
|
+
return token
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def _reply_feishu(message_id: str, content: str, color: str = "blue") -> None:
|
|
167
|
+
token = await _get_tenant_token()
|
|
168
|
+
card = {
|
|
169
|
+
"msg_type": "interactive",
|
|
170
|
+
"card": {
|
|
171
|
+
"header": {
|
|
172
|
+
"title": {"tag": "plain_text", "content": "Aria"},
|
|
173
|
+
"template": color,
|
|
174
|
+
},
|
|
175
|
+
"elements": [{"tag": "div", "text": {"tag": "lark_md", "content": content}}],
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
async with httpx.AsyncClient() as client:
|
|
179
|
+
await client.post(
|
|
180
|
+
f"{_FEISHU_API}/im/v1/messages/{message_id}/reply",
|
|
181
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
182
|
+
json=card,
|
|
183
|
+
timeout=15,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _send_feishu_text(open_id: str, text: str) -> None:
|
|
188
|
+
token = await _get_tenant_token()
|
|
189
|
+
async with httpx.AsyncClient() as client:
|
|
190
|
+
await client.post(
|
|
191
|
+
f"{_FEISHU_API}/im/v1/messages",
|
|
192
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
193
|
+
params={"receive_id_type": "open_id"},
|
|
194
|
+
json={
|
|
195
|
+
"receive_id": open_id,
|
|
196
|
+
"msg_type": "text",
|
|
197
|
+
"content": json.dumps({"text": text}),
|
|
198
|
+
},
|
|
199
|
+
timeout=15,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── Route message to local aria instance ─────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
async def _route_to_local(feishu_user_id: str, payload: dict) -> Optional[Any]:
|
|
206
|
+
"""Forward Feishu event to the user's connected local aria instance."""
|
|
207
|
+
client_id = _lookup_client(feishu_user_id)
|
|
208
|
+
if not client_id:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
ws = _connections.get(client_id)
|
|
212
|
+
if not ws:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
req_id = f"req_{uuid.uuid4().hex[:10]}"
|
|
216
|
+
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
217
|
+
_pending_responses[req_id] = future
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
await ws.send_text(json.dumps({
|
|
221
|
+
"type": "message",
|
|
222
|
+
"id": req_id,
|
|
223
|
+
"payload": payload,
|
|
224
|
+
}))
|
|
225
|
+
result = await asyncio.wait_for(future, timeout=_MSG_TIMEOUT)
|
|
226
|
+
return result
|
|
227
|
+
except asyncio.TimeoutError:
|
|
228
|
+
logger.warning("Timeout waiting for response from client_id=%s", client_id)
|
|
229
|
+
return {"error": "Aria 本机响应超时,请检查 aria_relay_client 是否在线"}
|
|
230
|
+
finally:
|
|
231
|
+
_pending_responses.pop(req_id, None)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── FastAPI app ───────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
@asynccontextmanager
|
|
237
|
+
async def lifespan(app: FastAPI):
|
|
238
|
+
logging.basicConfig(
|
|
239
|
+
level=logging.INFO,
|
|
240
|
+
format="%(asctime)s [relay] %(message)s",
|
|
241
|
+
datefmt="%H:%M:%S",
|
|
242
|
+
)
|
|
243
|
+
logger.info("Aria Relay Server started db=%s", _DB_PATH)
|
|
244
|
+
get_db() # init tables
|
|
245
|
+
yield
|
|
246
|
+
logger.info("Relay Server shutting down")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
app = FastAPI(title="Aria Relay Server", lifespan=lifespan)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── WebSocket endpoint (users' local machines) ────────────────────────────────
|
|
253
|
+
|
|
254
|
+
@app.websocket("/ws")
|
|
255
|
+
async def ws_endpoint(websocket: WebSocket):
|
|
256
|
+
await websocket.accept()
|
|
257
|
+
client_id: Optional[str] = None
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
# First message must be register
|
|
261
|
+
raw = await asyncio.wait_for(websocket.receive_text(), timeout=15)
|
|
262
|
+
msg = json.loads(raw)
|
|
263
|
+
|
|
264
|
+
if msg.get("type") != "register":
|
|
265
|
+
await websocket.send_text(json.dumps({"ok": False, "reason": "first message must be register"}))
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
client_id = msg.get("client_id", "")
|
|
269
|
+
if not client_id:
|
|
270
|
+
await websocket.send_text(json.dumps({"ok": False, "reason": "client_id required"}))
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
# Validate RELAY_SECRET if configured
|
|
274
|
+
if _RELAY_SECRET and msg.get("secret") != _RELAY_SECRET:
|
|
275
|
+
await websocket.send_text(json.dumps({"ok": False, "reason": "invalid secret"}))
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# Register in-memory + mark as pending bind (if first time)
|
|
279
|
+
_connections[client_id] = websocket
|
|
280
|
+
if _lookup_client.__module__: # always true; used as noop to be explicit
|
|
281
|
+
_register_pending(client_id)
|
|
282
|
+
|
|
283
|
+
await websocket.send_text(json.dumps({"ok": True, "client_id": client_id}))
|
|
284
|
+
logger.info("Client connected: %s", client_id)
|
|
285
|
+
|
|
286
|
+
# Message loop
|
|
287
|
+
async for raw in websocket.iter_text():
|
|
288
|
+
try:
|
|
289
|
+
response_msg = json.loads(raw)
|
|
290
|
+
except json.JSONDecodeError:
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
if response_msg.get("type") == "pong":
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
if response_msg.get("type") == "response":
|
|
297
|
+
req_id = response_msg.get("id", "")
|
|
298
|
+
future = _pending_responses.get(req_id)
|
|
299
|
+
if future and not future.done():
|
|
300
|
+
future.set_result(response_msg.get("result"))
|
|
301
|
+
|
|
302
|
+
except WebSocketDisconnect:
|
|
303
|
+
pass
|
|
304
|
+
except asyncio.TimeoutError:
|
|
305
|
+
logger.warning("Register timeout for new connection")
|
|
306
|
+
except Exception as e:
|
|
307
|
+
logger.exception("WebSocket error: %s", e)
|
|
308
|
+
finally:
|
|
309
|
+
if client_id:
|
|
310
|
+
_connections.pop(client_id, None)
|
|
311
|
+
logger.info("Client disconnected: %s", client_id)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ── Feishu event endpoint ─────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
@app.post("/feishu/event")
|
|
317
|
+
async def feishu_event(request: Request):
|
|
318
|
+
"""Feishu Developer Console → Event Subscription → Request URL: /feishu/event"""
|
|
319
|
+
body = await request.body()
|
|
320
|
+
try:
|
|
321
|
+
payload = json.loads(body)
|
|
322
|
+
except Exception:
|
|
323
|
+
raise HTTPException(400, "Invalid JSON")
|
|
324
|
+
|
|
325
|
+
# URL verification challenge
|
|
326
|
+
if "challenge" in payload:
|
|
327
|
+
return {"challenge": payload["challenge"]}
|
|
328
|
+
|
|
329
|
+
# Extract sender + message_id
|
|
330
|
+
event = payload.get("event", {})
|
|
331
|
+
message = event.get("message", {})
|
|
332
|
+
sender = event.get("sender", {})
|
|
333
|
+
feishu_user_id = sender.get("sender_id", {}).get("open_id", "")
|
|
334
|
+
message_id = message.get("message_id", "")
|
|
335
|
+
|
|
336
|
+
if not feishu_user_id:
|
|
337
|
+
return {"code": 0}
|
|
338
|
+
|
|
339
|
+
# Handle /bind command
|
|
340
|
+
msg_type = message.get("message_type", "")
|
|
341
|
+
if msg_type == "text":
|
|
342
|
+
try:
|
|
343
|
+
text_content = json.loads(message.get("content", "{}")).get("text", "").strip()
|
|
344
|
+
except Exception:
|
|
345
|
+
text_content = ""
|
|
346
|
+
|
|
347
|
+
if text_content.upper().startswith("/BIND ") or text_content.upper().startswith("ARIA-BIND-"):
|
|
348
|
+
raw_code = text_content.upper().replace("/BIND ", "").strip()
|
|
349
|
+
# Normalize: "ARIA-BIND-ARIA-XXXX" → "aria-xxxx"
|
|
350
|
+
client_id_upper = raw_code.replace("ARIA-BIND-", "").replace("ARIA-", "aria-").lower()
|
|
351
|
+
_bind(feishu_user_id, client_id_upper)
|
|
352
|
+
await _send_feishu_text(
|
|
353
|
+
feishu_user_id,
|
|
354
|
+
f"✅ 绑定成功!你的 Aria 实例已连接。\n"
|
|
355
|
+
f"Client ID: {client_id_upper}\n"
|
|
356
|
+
f"现在可以直接发消息与你的 Aria 交互了。"
|
|
357
|
+
)
|
|
358
|
+
return {"code": 0}
|
|
359
|
+
|
|
360
|
+
# Route to local aria instance
|
|
361
|
+
result = await _route_to_local(feishu_user_id, payload)
|
|
362
|
+
|
|
363
|
+
if result is None:
|
|
364
|
+
# No binding found or client offline
|
|
365
|
+
if not _lookup_client(feishu_user_id):
|
|
366
|
+
await _send_feishu_text(
|
|
367
|
+
feishu_user_id,
|
|
368
|
+
"👋 你好!要开始使用 Aria,请:\n"
|
|
369
|
+
"1. 在你的电脑上安装 Aria Code\n"
|
|
370
|
+
"2. 运行 `python3 setup_wizard.py` 完成配置\n"
|
|
371
|
+
"3. 发送绑定码绑定你的账户"
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
await _reply_feishu(
|
|
375
|
+
message_id,
|
|
376
|
+
"⚠️ Aria 本机未连接。请确保你的电脑上 `aria_relay_client.py` 正在运行。",
|
|
377
|
+
color="yellow",
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
return {"code": 0}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ── Status endpoint ───────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
@app.get("/status")
|
|
386
|
+
async def status():
|
|
387
|
+
return {
|
|
388
|
+
"connected_clients": len(_connections),
|
|
389
|
+
"client_ids": list(_connections.keys()),
|
|
390
|
+
"total_bindings": get_db().execute("SELECT COUNT(*) FROM bindings").fetchone()[0],
|
|
391
|
+
"feishu_app_configured": bool(_FEISHU_APP_ID),
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
if __name__ == "__main__":
|
|
398
|
+
import uvicorn
|
|
399
|
+
uvicorn.run(
|
|
400
|
+
"aria_relay_server:app",
|
|
401
|
+
host="0.0.0.0",
|
|
402
|
+
port=int(os.environ.get("PORT", "8765")),
|
|
403
|
+
reload=False,
|
|
404
|
+
log_level="info",
|
|
405
|
+
)
|