diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5fc0d9e --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +APP_HOST=0.0.0.0 +APP_PORT=8030 +DATABASE_PATH=data/dispatcher.db + +ADMIN_USERNAME=admin +ADMIN_PASSWORD=12345678 +SESSION_SECRET=DBUwycvdxjSUZX4LMvUKa0xMzWKzFJmg + +# Optional but recommended. When set, TradingView must call: +# /webhook/tradingview?token=your-shared-secret +# or send X-Webhook-Token: your-shared-secret +WEBHOOK_TOKEN=vvyVmc33aC0I85LkH4yrd6ojvkqmyrb1 + +RETENTION_DAYS=30 +MAX_DELIVERY_ATTEMPTS=3 +RETRY_BACKOFF_SECONDS=60 +FEISHU_TIMEOUT_SECONDS=10 +WORKER_INTERVAL_SECONDS=15 diff --git a/README.md b/README.md index 6957eee..84924bf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TradingView Alert Dispatcher -接收 TradingView webhook alert,按 `timeframe + symbol + strategy` 路由到飞书 webhook,并提供管理控制台。 +接收 TradingView webhook alert,按 `timeframe / symbol / strategy` 等条件路由到飞书 webhook,并提供管理控制台。 ## Run Locally @@ -44,16 +44,22 @@ POST /webhook/tradingview Content-Type: application/json ``` +如果设置了 `WEBHOOK_TOKEN`,TradingView 需要使用以下任一方式携带 token: + +```text +POST /webhook/tradingview?token=your-shared-secret +X-Webhook-Token: your-shared-secret +``` + +`docker-compose.yml` 默认已经设置了占位 token,生产使用前请替换;本地临时调试如果不想校验 token,可以把 `WEBHOOK_TOKEN` 置为空。 + ## Feishu Message Templates -路由规则支持两种消息类型: - -- `Card`:默认,发送飞书 interactive card。 -- `Text`:发送普通文本消息。 +路由规则统一发送飞书 interactive card。 标题和正文模板支持 `{{field}}` 占位符,字段来自 TradingView alert JSON。嵌套字段可以写成 `{{order.id}}`。 -每条路由规则通过「发送到」下拉框选择一个飞书 Webhook。需要同一个信号发到多个群时,可以建多条匹配条件相同、目标不同的规则,并用优先级控制命中顺序;当前默认路由逻辑只发送最高优先级命中的规则。 +每条路由规则通过「发送到」下拉框选择一个飞书 Webhook。`timeframe`、`symbol`、`strategy` 至少填写一个,空字段表示不限。例如只填 `symbol=BTCUSDT` 会匹配所有 BTCUSDT 信号。需要同一个信号发到多个群时,可以建多条匹配条件相同、目标不同的规则,并用优先级控制命中顺序;当前默认路由逻辑只发送最高优先级命中的规则。 示例正文模板: @@ -70,6 +76,7 @@ Content-Type: application/json - `ADMIN_USERNAME` - `ADMIN_PASSWORD` - `SESSION_SECRET` +- `WEBHOOK_TOKEN` - `DATABASE_PATH` - `RETENTION_DAYS` - `MAX_DELIVERY_ATTEMPTS` diff --git a/app/config.py b/app/config.py index 6f9681a..ac3b2af 100644 --- a/app/config.py +++ b/app/config.py @@ -13,6 +13,7 @@ class Settings: admin_username: str = "admin" admin_password: str = "change-me-now" session_secret: str = "change-this-session-secret" + webhook_token: str = "" retention_days: int = 30 max_delivery_attempts: int = 3 retry_backoff_seconds: int = 60 @@ -27,6 +28,7 @@ def get_settings() -> Settings: admin_username=os.getenv("ADMIN_USERNAME", "admin"), admin_password=os.getenv("ADMIN_PASSWORD", "change-me-now"), session_secret=os.getenv("SESSION_SECRET", "change-this-session-secret"), + webhook_token=os.getenv("WEBHOOK_TOKEN", ""), retention_days=int(os.getenv("RETENTION_DAYS", "30")), max_delivery_attempts=int(os.getenv("MAX_DELIVERY_ATTEMPTS", "3")), retry_backoff_seconds=int(os.getenv("RETRY_BACKOFF_SECONDS", "60")), diff --git a/app/dispatcher.py b/app/dispatcher.py index 8c9a048..ecd4814 100644 --- a/app/dispatcher.py +++ b/app/dispatcher.py @@ -14,7 +14,6 @@ from app.db import Database, from_json, now_iso, to_json UTC = timezone.utc -REQUIRED_ALERT_FIELDS = ("timeframe", "symbol", "strategy") TEMPLATE_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_.-]+)\s*}}|(? dict[str, Any]: - missing = [field for field in REQUIRED_ALERT_FIELDS if not str(payload.get(field, "")).strip()] - if missing: - raise ValidationError(f"Missing required fields: {', '.join(missing)}") normalized = dict(payload) - normalized["timeframe"] = str(payload["timeframe"]).strip() - normalized["symbol"] = str(payload["symbol"]).strip().upper() - normalized["strategy"] = str(payload["strategy"]).strip() + normalized["timeframe"] = str(payload.get("timeframe", "")).strip() + normalized["symbol"] = str(payload.get("symbol", "")).strip().upper() + normalized["strategy"] = str(payload.get("strategy", "")).strip() if "price" in normalized and normalized["price"] not in (None, ""): try: normalized["price"] = float(normalized["price"]) @@ -59,9 +55,9 @@ def render_template(template: str, alert: dict[str, Any]) -> str: def default_body(alert: dict[str, Any]) -> str: action = alert.get("action") or alert.get("signal") or "alert" lines = [ - f"TradingView 信号: {alert['symbol']}", - f"周期: {alert['timeframe']}", - f"策略: {alert['strategy']}", + f"TradingView 信号: {alert.get('symbol') or '-'}", + f"周期: {alert.get('timeframe') or '-'}", + f"策略: {alert.get('strategy') or '-'}", f"动作: {action}", ] if alert.get("price") is not None: @@ -75,12 +71,9 @@ def build_feishu_message(alert: dict[str, Any], rule: dict[str, Any] | None = No rule = rule or {} title_template = rule.get("card_title_template") or "TradingView {{symbol}} {{action}}" body_template = rule.get("card_body_template") or default_body(alert) - title = render_template(title_template, alert).strip() or f"TradingView {alert['symbol']}" + title = render_template(title_template, alert).strip() or f"TradingView {alert.get('symbol') or 'Alert'}" body = render_template(body_template, alert).strip() or default_body(alert) - if rule.get("message_type") == "text": - return {"msg_type": "text", "content": {"text": f"{title}\n{body}"}} - return { "msg_type": "interactive", "card": { @@ -99,7 +92,7 @@ def build_feishu_message(alert: dict[str, Any], rule: dict[str, Any] | None = No "elements": [ { "tag": "plain_text", - "content": f"{alert['symbol']} · {alert['timeframe']} · {alert['strategy']}", + "content": f"{alert.get('symbol') or '-'} · {alert.get('timeframe') or '-'} · {alert.get('strategy') or '-'}", } ], }, @@ -113,22 +106,35 @@ class Dispatcher: self.db = db self.settings = settings + def find_matching_rule(self, alert: dict[str, Any]) -> dict[str, Any] | None: + normalized = normalize_alert(alert) + with self.db.connect() as conn: + row = conn.execute( + """ + SELECT * FROM routing_rules + WHERE enabled = 1 + AND (timeframe = '' OR timeframe = ?) + AND (symbol = '' OR upper(symbol) = ?) + AND (strategy = '' OR strategy = ?) + AND (timeframe <> '' OR symbol <> '' OR strategy <> '') + ORDER BY priority ASC, + ( + CASE WHEN timeframe <> '' THEN 1 ELSE 0 END + + CASE WHEN symbol <> '' THEN 1 ELSE 0 END + + CASE WHEN strategy <> '' THEN 1 ELSE 0 END + ) DESC, + id ASC + LIMIT 1 + """, + (normalized["timeframe"], normalized["symbol"], normalized["strategy"]), + ).fetchone() + return dict(row) if row else None + def receive_alert(self, payload: dict[str, Any]) -> dict[str, Any]: alert = normalize_alert(payload) created_at = now_iso() with self.db.connect() as conn: - rule = conn.execute( - """ - SELECT * FROM routing_rules - WHERE enabled = 1 - AND timeframe = ? - AND upper(symbol) = ? - AND strategy = ? - ORDER BY priority ASC, id ASC - LIMIT 1 - """, - (alert["timeframe"], alert["symbol"], alert["strategy"]), - ).fetchone() + rule = self.find_matching_rule(alert) status = "matched" if rule else "unmatched" cur = conn.execute( @@ -204,8 +210,7 @@ class Dispatcher: with self.db.connect() as conn: rows = conn.execute( """ - SELECT d.*, a.payload - , r.message_type, r.card_title_template, r.card_body_template + SELECT d.*, a.payload, r.card_title_template, r.card_body_template FROM deliveries d JOIN alerts a ON a.id = d.alert_id LEFT JOIN routing_rules r ON r.id = d.rule_id diff --git a/app/server.py b/app/server.py index 81f9936..d98a2ec 100644 --- a/app/server.py +++ b/app/server.py @@ -4,6 +4,8 @@ import html import json import mimetypes import os +import urllib.error +import urllib.request from http import HTTPStatus from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -13,7 +15,7 @@ from urllib.parse import parse_qs, urlparse from app.auth import COOKIE_NAME, check_credentials, hash_password, is_valid_session, make_session_cookie from app.config import Settings, get_settings from app.db import Database, from_json, now_iso, to_json -from app.dispatcher import Dispatcher, ValidationError +from app.dispatcher import Dispatcher, ValidationError, build_feishu_message, normalize_alert class AppContext: @@ -103,8 +105,16 @@ class Handler(BaseHTTPRequestHandler): self.render_dashboard() elif parsed.path == "/targets": self.render_targets() + elif parsed.path == "/targets/delete": + self.render_target_delete(parsed) elif parsed.path == "/rules": self.render_rules() + elif parsed.path == "/rules/new": + self.render_rule_new() + elif parsed.path == "/rules/edit": + self.render_rule_edit(parsed) + elif parsed.path == "/rules/delete": + self.render_rule_delete(parsed) elif parsed.path == "/logs": self.render_logs() elif parsed.path == "/test": @@ -134,9 +144,11 @@ class Handler(BaseHTTPRequestHandler): "/targets/create": self.create_target, "/targets/update": self.update_target, "/targets/delete": self.delete_target, + "/targets/test": self.test_target, "/rules/create": self.create_rule, "/rules/update": self.update_rule, "/rules/delete": self.delete_rule, + "/rules/preview": self.preview_rule, "/test/send": self.send_test, "/account/password": self.change_password, "/deliveries/retry": self.retry_deliveries, @@ -261,6 +273,12 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(content) def handle_tradingview_webhook(self) -> None: + if self.context.settings.webhook_token: + query = parse_qs(urlparse(self.path).query) + token = self.headers.get("X-Webhook-Token") or query.get("token", [""])[-1] + if token != self.context.settings.webhook_token: + json_response(self, 401, {"error": "Invalid webhook token"}) + return try: payload = parse_json_body(self) result = self.context.dispatcher.receive_alert(payload) @@ -290,6 +308,24 @@ class Handler(BaseHTTPRequestHandler): return {"alerts": [dict(row) for row in alerts], "deliveries": [dict(row) for row in deliveries]} def render_dashboard(self) -> None: + host = self.headers.get("Host", f"localhost:{self.context.settings.port}") + scheme = self.headers.get("X-Forwarded-Proto", "http") + base_url = f"{scheme}://{host}" + webhook_url = f"{base_url}/webhook/tradingview" + token = self.context.settings.webhook_token + webhook_url_with_token = f"{webhook_url}?token={token}" if token else webhook_url + token_block = ( + f"""
{html.escape(token)}X-Webhook-Token: {html.escape(token)}当前未设置 WEBHOOK_TOKEN,任何知道地址的人都可以提交 alert。生产环境建议设置。
""" + ) + webhook_panel = f"""{html.escape(webhook_url_with_token)}{html.escape(webhook_url)}结构化 alert 分发、飞书转发和重试状态。
| ID | 品种 | 周期 | 策略 | 状态 | 时间 |
|---|
结构化 alert 分发、飞书转发和重试状态。
| ID | 品种 | 周期 | 策略 | 状态 | 时间 |
|---|
维护所有可分发的飞书机器人地址。
| ID | 名称 | URL | 状态 | 操作 |
|---|
维护所有可分发的飞书机器人地址。
| ID | 名称 | URL | 状态 | 操作 |
|---|
请确认是否删除这个飞书目标。
{html.escape(target['webhook_url'])}
+ +