1
This commit is contained in:
parent
d01c0d49cd
commit
4f025c3736
18
.env.example
Normal file
18
.env.example
Normal file
@ -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
|
||||||
19
README.md
19
README.md
@ -1,6 +1,6 @@
|
|||||||
# TradingView Alert Dispatcher
|
# TradingView Alert Dispatcher
|
||||||
|
|
||||||
接收 TradingView webhook alert,按 `timeframe + symbol + strategy` 路由到飞书 webhook,并提供管理控制台。
|
接收 TradingView webhook alert,按 `timeframe / symbol / strategy` 等条件路由到飞书 webhook,并提供管理控制台。
|
||||||
|
|
||||||
## Run Locally
|
## Run Locally
|
||||||
|
|
||||||
@ -44,16 +44,22 @@ POST /webhook/tradingview
|
|||||||
Content-Type: application/json
|
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
|
## Feishu Message Templates
|
||||||
|
|
||||||
路由规则支持两种消息类型:
|
路由规则统一发送飞书 interactive card。
|
||||||
|
|
||||||
- `Card`:默认,发送飞书 interactive card。
|
|
||||||
- `Text`:发送普通文本消息。
|
|
||||||
|
|
||||||
标题和正文模板支持 `{{field}}` 占位符,字段来自 TradingView alert JSON。嵌套字段可以写成 `{{order.id}}`。
|
标题和正文模板支持 `{{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_USERNAME`
|
||||||
- `ADMIN_PASSWORD`
|
- `ADMIN_PASSWORD`
|
||||||
- `SESSION_SECRET`
|
- `SESSION_SECRET`
|
||||||
|
- `WEBHOOK_TOKEN`
|
||||||
- `DATABASE_PATH`
|
- `DATABASE_PATH`
|
||||||
- `RETENTION_DAYS`
|
- `RETENTION_DAYS`
|
||||||
- `MAX_DELIVERY_ATTEMPTS`
|
- `MAX_DELIVERY_ATTEMPTS`
|
||||||
|
|||||||
@ -13,6 +13,7 @@ class Settings:
|
|||||||
admin_username: str = "admin"
|
admin_username: str = "admin"
|
||||||
admin_password: str = "change-me-now"
|
admin_password: str = "change-me-now"
|
||||||
session_secret: str = "change-this-session-secret"
|
session_secret: str = "change-this-session-secret"
|
||||||
|
webhook_token: str = ""
|
||||||
retention_days: int = 30
|
retention_days: int = 30
|
||||||
max_delivery_attempts: int = 3
|
max_delivery_attempts: int = 3
|
||||||
retry_backoff_seconds: int = 60
|
retry_backoff_seconds: int = 60
|
||||||
@ -27,6 +28,7 @@ def get_settings() -> Settings:
|
|||||||
admin_username=os.getenv("ADMIN_USERNAME", "admin"),
|
admin_username=os.getenv("ADMIN_USERNAME", "admin"),
|
||||||
admin_password=os.getenv("ADMIN_PASSWORD", "change-me-now"),
|
admin_password=os.getenv("ADMIN_PASSWORD", "change-me-now"),
|
||||||
session_secret=os.getenv("SESSION_SECRET", "change-this-session-secret"),
|
session_secret=os.getenv("SESSION_SECRET", "change-this-session-secret"),
|
||||||
|
webhook_token=os.getenv("WEBHOOK_TOKEN", ""),
|
||||||
retention_days=int(os.getenv("RETENTION_DAYS", "30")),
|
retention_days=int(os.getenv("RETENTION_DAYS", "30")),
|
||||||
max_delivery_attempts=int(os.getenv("MAX_DELIVERY_ATTEMPTS", "3")),
|
max_delivery_attempts=int(os.getenv("MAX_DELIVERY_ATTEMPTS", "3")),
|
||||||
retry_backoff_seconds=int(os.getenv("RETRY_BACKOFF_SECONDS", "60")),
|
retry_backoff_seconds=int(os.getenv("RETRY_BACKOFF_SECONDS", "60")),
|
||||||
|
|||||||
@ -14,7 +14,6 @@ from app.db import Database, from_json, now_iso, to_json
|
|||||||
UTC = timezone.utc
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
REQUIRED_ALERT_FIELDS = ("timeframe", "symbol", "strategy")
|
|
||||||
TEMPLATE_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_.-]+)\s*}}|(?<!{){\s*([a-zA-Z0-9_.-]+)\s*}(?!})")
|
TEMPLATE_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_.-]+)\s*}}|(?<!{){\s*([a-zA-Z0-9_.-]+)\s*}(?!})")
|
||||||
|
|
||||||
|
|
||||||
@ -23,13 +22,10 @@ class ValidationError(ValueError):
|
|||||||
|
|
||||||
|
|
||||||
def normalize_alert(payload: dict[str, Any]) -> dict[str, Any]:
|
def normalize_alert(payload: dict[str, Any]) -> 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 = dict(payload)
|
||||||
normalized["timeframe"] = str(payload["timeframe"]).strip()
|
normalized["timeframe"] = str(payload.get("timeframe", "")).strip()
|
||||||
normalized["symbol"] = str(payload["symbol"]).strip().upper()
|
normalized["symbol"] = str(payload.get("symbol", "")).strip().upper()
|
||||||
normalized["strategy"] = str(payload["strategy"]).strip()
|
normalized["strategy"] = str(payload.get("strategy", "")).strip()
|
||||||
if "price" in normalized and normalized["price"] not in (None, ""):
|
if "price" in normalized and normalized["price"] not in (None, ""):
|
||||||
try:
|
try:
|
||||||
normalized["price"] = float(normalized["price"])
|
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:
|
def default_body(alert: dict[str, Any]) -> str:
|
||||||
action = alert.get("action") or alert.get("signal") or "alert"
|
action = alert.get("action") or alert.get("signal") or "alert"
|
||||||
lines = [
|
lines = [
|
||||||
f"TradingView 信号: {alert['symbol']}",
|
f"TradingView 信号: {alert.get('symbol') or '-'}",
|
||||||
f"周期: {alert['timeframe']}",
|
f"周期: {alert.get('timeframe') or '-'}",
|
||||||
f"策略: {alert['strategy']}",
|
f"策略: {alert.get('strategy') or '-'}",
|
||||||
f"动作: {action}",
|
f"动作: {action}",
|
||||||
]
|
]
|
||||||
if alert.get("price") is not None:
|
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 {}
|
rule = rule or {}
|
||||||
title_template = rule.get("card_title_template") or "TradingView {{symbol}} {{action}}"
|
title_template = rule.get("card_title_template") or "TradingView {{symbol}} {{action}}"
|
||||||
body_template = rule.get("card_body_template") or default_body(alert)
|
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)
|
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 {
|
return {
|
||||||
"msg_type": "interactive",
|
"msg_type": "interactive",
|
||||||
"card": {
|
"card": {
|
||||||
@ -99,7 +92,7 @@ def build_feishu_message(alert: dict[str, Any], rule: dict[str, Any] | None = No
|
|||||||
"elements": [
|
"elements": [
|
||||||
{
|
{
|
||||||
"tag": "plain_text",
|
"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.db = db
|
||||||
self.settings = settings
|
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]:
|
def receive_alert(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
alert = normalize_alert(payload)
|
alert = normalize_alert(payload)
|
||||||
created_at = now_iso()
|
created_at = now_iso()
|
||||||
with self.db.connect() as conn:
|
with self.db.connect() as conn:
|
||||||
rule = conn.execute(
|
rule = self.find_matching_rule(alert)
|
||||||
"""
|
|
||||||
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()
|
|
||||||
|
|
||||||
status = "matched" if rule else "unmatched"
|
status = "matched" if rule else "unmatched"
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
@ -204,8 +210,7 @@ class Dispatcher:
|
|||||||
with self.db.connect() as conn:
|
with self.db.connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT d.*, a.payload
|
SELECT d.*, a.payload, r.card_title_template, r.card_body_template
|
||||||
, r.message_type, r.card_title_template, r.card_body_template
|
|
||||||
FROM deliveries d
|
FROM deliveries d
|
||||||
JOIN alerts a ON a.id = d.alert_id
|
JOIN alerts a ON a.id = d.alert_id
|
||||||
LEFT JOIN routing_rules r ON r.id = d.rule_id
|
LEFT JOIN routing_rules r ON r.id = d.rule_id
|
||||||
|
|||||||
331
app/server.py
331
app/server.py
@ -4,6 +4,8 @@ import html
|
|||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
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.auth import COOKIE_NAME, check_credentials, hash_password, is_valid_session, make_session_cookie
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.db import Database, from_json, now_iso, to_json
|
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:
|
class AppContext:
|
||||||
@ -103,8 +105,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.render_dashboard()
|
self.render_dashboard()
|
||||||
elif parsed.path == "/targets":
|
elif parsed.path == "/targets":
|
||||||
self.render_targets()
|
self.render_targets()
|
||||||
|
elif parsed.path == "/targets/delete":
|
||||||
|
self.render_target_delete(parsed)
|
||||||
elif parsed.path == "/rules":
|
elif parsed.path == "/rules":
|
||||||
self.render_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":
|
elif parsed.path == "/logs":
|
||||||
self.render_logs()
|
self.render_logs()
|
||||||
elif parsed.path == "/test":
|
elif parsed.path == "/test":
|
||||||
@ -134,9 +144,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"/targets/create": self.create_target,
|
"/targets/create": self.create_target,
|
||||||
"/targets/update": self.update_target,
|
"/targets/update": self.update_target,
|
||||||
"/targets/delete": self.delete_target,
|
"/targets/delete": self.delete_target,
|
||||||
|
"/targets/test": self.test_target,
|
||||||
"/rules/create": self.create_rule,
|
"/rules/create": self.create_rule,
|
||||||
"/rules/update": self.update_rule,
|
"/rules/update": self.update_rule,
|
||||||
"/rules/delete": self.delete_rule,
|
"/rules/delete": self.delete_rule,
|
||||||
|
"/rules/preview": self.preview_rule,
|
||||||
"/test/send": self.send_test,
|
"/test/send": self.send_test,
|
||||||
"/account/password": self.change_password,
|
"/account/password": self.change_password,
|
||||||
"/deliveries/retry": self.retry_deliveries,
|
"/deliveries/retry": self.retry_deliveries,
|
||||||
@ -261,6 +273,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(content)
|
self.wfile.write(content)
|
||||||
|
|
||||||
def handle_tradingview_webhook(self) -> None:
|
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:
|
try:
|
||||||
payload = parse_json_body(self)
|
payload = parse_json_body(self)
|
||||||
result = self.context.dispatcher.receive_alert(payload)
|
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]}
|
return {"alerts": [dict(row) for row in alerts], "deliveries": [dict(row) for row in deliveries]}
|
||||||
|
|
||||||
def render_dashboard(self) -> None:
|
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"""<div class="copy-row"><span>Webhook Token</span><code>{html.escape(token)}</code><button type="button" data-copy="{html.escape(token)}">复制</button></div>
|
||||||
|
<div class="copy-row"><span>Header 方式</span><code>X-Webhook-Token: {html.escape(token)}</code><button type="button" data-copy="X-Webhook-Token: {html.escape(token)}">复制</button></div>"""
|
||||||
|
if token
|
||||||
|
else """<p class="warning">当前未设置 WEBHOOK_TOKEN,任何知道地址的人都可以提交 alert。生产环境建议设置。</p>"""
|
||||||
|
)
|
||||||
|
webhook_panel = f"""<section class="panel">
|
||||||
|
<h2>TradingView Webhook 配置</h2>
|
||||||
|
<div class="copy-row"><span>Webhook URL</span><code>{html.escape(webhook_url_with_token)}</code><button type="button" data-copy="{html.escape(webhook_url_with_token)}">复制</button></div>
|
||||||
|
<div class="copy-row"><span>纯 URL</span><code>{html.escape(webhook_url)}</code><button type="button" data-copy="{html.escape(webhook_url)}">复制</button></div>
|
||||||
|
{token_block}
|
||||||
|
</section>"""
|
||||||
with self.context.db.connect() as conn:
|
with self.context.db.connect() as conn:
|
||||||
counts = {
|
counts = {
|
||||||
"alerts": conn.execute("SELECT COUNT(*) AS c FROM alerts").fetchone()["c"],
|
"alerts": conn.execute("SELECT COUNT(*) AS c FROM alerts").fetchone()["c"],
|
||||||
@ -308,7 +344,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
f"<tr><td>{row['id']}</td><td>{html.escape(row['symbol'])}</td><td>{html.escape(row['timeframe'])}</td><td>{html.escape(row['strategy'])}</td><td><span class='status'>{html.escape(row['status'])}</span></td><td>{row['created_at']}</td></tr>"
|
f"<tr><td>{row['id']}</td><td>{html.escape(row['symbol'])}</td><td>{html.escape(row['timeframe'])}</td><td>{html.escape(row['strategy'])}</td><td><span class='status'>{html.escape(row['status'])}</span></td><td>{row['created_at']}</td></tr>"
|
||||||
for row in recent
|
for row in recent
|
||||||
)
|
)
|
||||||
self.send_html("概览", f"<header><h1>概览</h1><p>结构化 alert 分发、飞书转发和重试状态。</p></header><section class='metrics'>{cards}</section><section><h2>最近 Alert</h2><table><thead><tr><th>ID</th><th>品种</th><th>周期</th><th>策略</th><th>状态</th><th>时间</th></tr></thead><tbody>{rows}</tbody></table></section>")
|
self.send_html("概览", f"<header><h1>概览</h1><p>结构化 alert 分发、飞书转发和重试状态。</p></header>{webhook_panel}<section class='metrics'>{cards}</section><section><h2>最近 Alert</h2><table><thead><tr><th>ID</th><th>品种</th><th>周期</th><th>策略</th><th>状态</th><th>时间</th></tr></thead><tbody>{rows}</tbody></table></section>")
|
||||||
|
|
||||||
def render_targets(self) -> None:
|
def render_targets(self) -> None:
|
||||||
targets = self.list_targets()
|
targets = self.list_targets()
|
||||||
@ -319,7 +355,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
<td class="url"><input form="target-update-{target['id']}" name="webhook_url" value="{html.escape(target['webhook_url'])}" type="url" required></td>
|
<td class="url"><input form="target-update-{target['id']}" name="webhook_url" value="{html.escape(target['webhook_url'])}" type="url" required></td>
|
||||||
<td><label class="check"><input form="target-update-{target['id']}" name="enabled" type="checkbox" {'checked' if target['enabled'] else ''}> 启用</label></td>
|
<td><label class="check"><input form="target-update-{target['id']}" name="enabled" type="checkbox" {'checked' if target['enabled'] else ''}> 启用</label></td>
|
||||||
<td><form id="target-update-{target['id']}" class="inline" method="post" action="/targets/update"></form><button form="target-update-{target['id']}" type="submit">更新</button>
|
<td><form id="target-update-{target['id']}" class="inline" method="post" action="/targets/update"></form><button form="target-update-{target['id']}" type="submit">更新</button>
|
||||||
<form class="inline" method="post" action="/targets/delete"><input type="hidden" name="id" value="{target['id']}"><button class="danger" type="submit">删除</button></form>
|
<form class="inline" method="post" action="/targets/test"><input type="hidden" name="id" value="{target['id']}"><button type="submit">测试</button></form>
|
||||||
|
<a class="button-link danger-link" href="/targets/delete?id={target['id']}">删除</a>
|
||||||
</td></tr>"""
|
</td></tr>"""
|
||||||
for target in targets
|
for target in targets
|
||||||
)
|
)
|
||||||
@ -330,55 +367,201 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
<label class="check"><input name="enabled" type="checkbox" checked> 启用</label>
|
<label class="check"><input name="enabled" type="checkbox" checked> 启用</label>
|
||||||
<button type="submit">保存目标</button>
|
<button type="submit">保存目标</button>
|
||||||
</form>"""
|
</form>"""
|
||||||
self.send_html("飞书 Webhook", f"<header><h1>飞书 Webhook</h1><p>维护所有可分发的飞书机器人地址。</p></header>{form}<table><thead><tr><th>ID</th><th>名称</th><th>URL</th><th>状态</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
|
notice = getattr(self, "_target_notice", "")
|
||||||
|
self.send_html("飞书 Webhook", f"<header><h1>飞书 Webhook</h1><p>维护所有可分发的飞书机器人地址。</p></header>{notice}{form}<table><thead><tr><th>ID</th><th>名称</th><th>URL</th><th>状态</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
|
||||||
|
|
||||||
|
def render_target_delete(self, parsed: Any) -> None:
|
||||||
|
target_id = parse_qs(parsed.query).get("id", [""])[-1]
|
||||||
|
with self.context.db.connect() as conn:
|
||||||
|
target = conn.execute("SELECT * FROM webhook_targets WHERE id = ?", (target_id,)).fetchone()
|
||||||
|
if not target:
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
body = f"""<header><h1>删除飞书 Webhook</h1><p>请确认是否删除这个飞书目标。</p></header>
|
||||||
|
<section class="panel narrow">
|
||||||
|
<h2>{html.escape(target['name'])}</h2>
|
||||||
|
<p class="url">{html.escape(target['webhook_url'])}</p>
|
||||||
|
<form method="post" action="/targets/delete" class="actions">
|
||||||
|
<input type="hidden" name="id" value="{target['id']}">
|
||||||
|
<button class="danger" type="submit">确认删除</button>
|
||||||
|
<a class="button-link secondary" href="/targets">取消</a>
|
||||||
|
</form>
|
||||||
|
</section>"""
|
||||||
|
self.send_html("删除飞书 Webhook", body)
|
||||||
|
|
||||||
def render_rules(self) -> None:
|
def render_rules(self) -> None:
|
||||||
targets = self.list_targets()
|
targets = self.list_targets()
|
||||||
rules = self.list_rules()
|
rules = self.list_rules()
|
||||||
|
target_names = {target["id"]: target["name"] for target in targets}
|
||||||
rows = ""
|
rows = ""
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
message_type_options = "".join(
|
conditions = [
|
||||||
f'<option value="{value}" {"selected" if rule["message_type"] == value else ""}>{label}</option>'
|
f"周期={html.escape(rule['timeframe'])}" if rule["timeframe"] else "",
|
||||||
for value, label in [("card", "Card"), ("text", "Text")]
|
f"品种={html.escape(rule['symbol'])}" if rule["symbol"] else "",
|
||||||
)
|
f"策略={html.escape(rule['strategy'])}" if rule["strategy"] else "",
|
||||||
selected_targets = target_select_options(targets, rule["target_ids"], placeholder=True)
|
]
|
||||||
rows += f"""<tr data-message-form>
|
target_name = target_names.get(rule["target_ids"][0], "-") if rule["target_ids"] else "-"
|
||||||
<td>{rule['id']}<input form="rule-update-{rule['id']}" type="hidden" name="id" value="{rule['id']}"></td>
|
rows += f"""<tr>
|
||||||
<td><input form="rule-update-{rule['id']}" name="name" value="{html.escape(rule['name'])}" required></td>
|
<td>{rule['id']}</td>
|
||||||
<td><input form="rule-update-{rule['id']}" name="timeframe" value="{html.escape(rule['timeframe'])}" required></td>
|
<td>{html.escape(rule['name'])}</td>
|
||||||
<td><input form="rule-update-{rule['id']}" name="symbol" value="{html.escape(rule['symbol'])}" required></td>
|
<td>{'<br>'.join(item for item in conditions if item) or '-'}</td>
|
||||||
<td><input form="rule-update-{rule['id']}" name="strategy" value="{html.escape(rule['strategy'])}" required></td>
|
<td>{rule['priority']}</td>
|
||||||
<td><input form="rule-update-{rule['id']}" name="priority" type="number" value="{rule['priority']}" required></td>
|
<td>{html.escape(target_name)}</td>
|
||||||
<td><select class="select-compact" form="rule-update-{rule['id']}" name="message_type" data-message-type>{message_type_options}</select></td>
|
<td><span class="status">{'启用' if rule['enabled'] else '停用'}</span></td>
|
||||||
<td><textarea form="rule-update-{rule['id']}" name="card_title_template" rows="2" data-title-template>{html.escape(rule['card_title_template'])}</textarea></td>
|
<td><a class="button-link" href="/rules/edit?id={rule['id']}">编辑</a><a class="button-link danger-link" href="/rules/delete?id={rule['id']}">删除</a></td>
|
||||||
<td><textarea form="rule-update-{rule['id']}" name="card_body_template" rows="4" data-body-template>{html.escape(rule['card_body_template'])}</textarea></td>
|
</tr>"""
|
||||||
<td><select class="select-target" form="rule-update-{rule['id']}" name="target_ids" required>{selected_targets}</select></td>
|
header = """<header class="page-header"><div><h1>路由规则</h1><p>周期、品种、策略至少填写一个;空字段表示不限。消息统一用飞书卡片发送。</p></div><a class="button-link" href="/rules/new">新增规则</a></header>"""
|
||||||
<td><label class="check"><input form="rule-update-{rule['id']}" name="enabled" type="checkbox" {'checked' if rule['enabled'] else ''}> 启用</label></td>
|
self.send_html("路由规则", f"{header}<table><thead><tr><th>ID</th><th>名称</th><th>匹配条件</th><th>优先级</th><th>发送到</th><th>状态</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
|
||||||
<td><form id="rule-update-{rule['id']}" class="inline" method="post" action="/rules/update"></form><button form="rule-update-{rule['id']}" type="submit">更新</button><form class="inline" method="post" action="/rules/delete"><input type="hidden" name="id" value="{rule['id']}"><button class="danger" type="submit">删除</button></form></td></tr>"""
|
|
||||||
create_target_options = target_select_options(targets, placeholder=True)
|
def render_rule_form(
|
||||||
form = f"""<form class="panel" method="post" action="/rules/create" data-message-form>
|
self,
|
||||||
<h2>新增路由规则</h2>
|
title: str,
|
||||||
|
action: str,
|
||||||
|
rule: dict[str, Any] | None = None,
|
||||||
|
preview_html: str = "",
|
||||||
|
sample_payload: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
targets = self.list_targets()
|
||||||
|
rule = rule or {
|
||||||
|
"id": "",
|
||||||
|
"name": "",
|
||||||
|
"timeframe": "",
|
||||||
|
"symbol": "",
|
||||||
|
"strategy": "",
|
||||||
|
"priority": 100,
|
||||||
|
"card_title_template": "TradingView {{symbol}} {{action}}",
|
||||||
|
"card_body_template": "**品种**: {{symbol}}\n**周期**: {{timeframe}}\n**策略**: {{strategy}}\n**动作**: {{action}}\n**价格**: {{price}}",
|
||||||
|
"target_ids": [],
|
||||||
|
"enabled": 1,
|
||||||
|
}
|
||||||
|
selected_targets = target_select_options(targets, rule.get("target_ids", []), placeholder=True)
|
||||||
|
hidden_id = f'<input type="hidden" name="id" value="{rule["id"]}">' if rule.get("id") else ""
|
||||||
|
button_text = "保存修改" if rule.get("id") else "创建规则"
|
||||||
|
sample_payload = sample_payload or json.dumps(
|
||||||
|
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
body = f"""<header><h1>{html.escape(title)}</h1><p>消息统一使用飞书卡片。周期、品种、策略至少填写一个,空字段表示不限。</p></header>
|
||||||
|
<form class="panel rule-form" method="post" action="{action}">
|
||||||
|
{hidden_id}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label>规则名<input name="name" required></label>
|
<label>规则名<input name="name" value="{html.escape(str(rule['name']))}" required></label>
|
||||||
<label>周期<input name="timeframe" placeholder="5m" required></label>
|
<label>周期<input name="timeframe" value="{html.escape(str(rule['timeframe']))}" placeholder="5m,空=不限"></label>
|
||||||
<label>品种<input name="symbol" placeholder="BTCUSDT" required></label>
|
<label>品种<input name="symbol" value="{html.escape(str(rule['symbol']))}" placeholder="BTCUSDT,空=不限"></label>
|
||||||
<label>策略<input name="strategy" placeholder="breakout" required></label>
|
<label>策略<input name="strategy" value="{html.escape(str(rule['strategy']))}" placeholder="breakout,空=不限"></label>
|
||||||
<label>优先级<input name="priority" type="number" value="100" required></label>
|
<label>优先级<input name="priority" type="number" value="{rule['priority']}" required></label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<label>卡片标题模板<input name="card_title_template" value="{html.escape(str(rule['card_title_template']))}" required></label>
|
||||||
<label class="field-compact">消息类型<select class="select-compact" name="message_type" data-message-type><option value="card" selected>Card</option><option value="text">Text</option></select></label>
|
<label>卡片正文模板<textarea name="card_body_template" rows="6">{html.escape(str(rule['card_body_template']))}</textarea></label>
|
||||||
<label><span data-title-label>卡片标题模板</span><input name="card_title_template" value="TradingView {{{{symbol}}}} {{{{action}}}}" data-title-template required></label>
|
<label class="field-target">发送到<select class="select-target" name="target_ids" required>{selected_targets}</select></label>
|
||||||
<label><span data-body-label>卡片正文模板</span><textarea name="card_body_template" rows="5" data-body-template>**品种**: {{{{symbol}}}}
|
<label class="check"><input name="enabled" type="checkbox" {'checked' if rule.get('enabled') else ''}> 启用</label>
|
||||||
**周期**: {{{{timeframe}}}}
|
<h2>规则命中与卡片预览</h2>
|
||||||
**策略**: {{{{strategy}}}}
|
<input type="hidden" name="source_action" value="{html.escape(action)}">
|
||||||
**动作**: {{{{action}}}}
|
<label>样例 Alert JSON<textarea name="sample_payload" rows="9">{html.escape(sample_payload)}</textarea></label>
|
||||||
**价格**: {{{{price}}}}</textarea></label>
|
<div class="actions"><button type="submit">{button_text}</button><button type="submit" formaction="/rules/preview">预览命中和卡片</button><a class="button-link secondary" href="/rules">返回列表</a></div>
|
||||||
<label class="field-target">发送到<select class="select-target" name="target_ids" required>{create_target_options}</select></label>
|
</form>{preview_html}"""
|
||||||
|
self.send_html(title, body)
|
||||||
|
|
||||||
|
def render_rule_new(self) -> None:
|
||||||
|
self.render_rule_form("新增路由规则", "/rules/create")
|
||||||
|
|
||||||
|
def render_rule_edit(self, parsed: Any) -> None:
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
rule_id = query.get("id", [""])[-1]
|
||||||
|
with self.context.db.connect() as conn:
|
||||||
|
rule = conn.execute("SELECT * FROM routing_rules WHERE id = ?", (rule_id,)).fetchone()
|
||||||
|
if not rule:
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
rule_dict = dict(rule)
|
||||||
|
rule_dict["target_ids"] = from_json(rule_dict["target_ids"], [])
|
||||||
|
self.render_rule_form("编辑路由规则", "/rules/update", rule_dict)
|
||||||
|
|
||||||
|
def render_rule_delete(self, parsed: Any) -> None:
|
||||||
|
rule_id = parse_qs(parsed.query).get("id", [""])[-1]
|
||||||
|
with self.context.db.connect() as conn:
|
||||||
|
rule = conn.execute("SELECT * FROM routing_rules WHERE id = ?", (rule_id,)).fetchone()
|
||||||
|
if not rule:
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
body = f"""<header><h1>删除路由规则</h1><p>请确认是否删除这条规则。</p></header>
|
||||||
|
<section class="panel narrow">
|
||||||
|
<h2>{html.escape(rule['name'])}</h2>
|
||||||
|
<p>删除后不会再匹配对应 alert,已有日志不受影响。</p>
|
||||||
|
<form method="post" action="/rules/delete" class="actions">
|
||||||
|
<input type="hidden" name="id" value="{rule['id']}">
|
||||||
|
<button class="danger" type="submit">确认删除</button>
|
||||||
|
<a class="button-link secondary" href="/rules">取消</a>
|
||||||
|
</form>
|
||||||
|
</section>"""
|
||||||
|
self.send_html("删除路由规则", body)
|
||||||
|
|
||||||
|
def build_rule_from_form(self, form: dict[str, list[str]]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": form.get("id", [""])[-1],
|
||||||
|
"name": form.get("name", [""])[-1].strip(),
|
||||||
|
"timeframe": form.get("timeframe", [""])[-1].strip(),
|
||||||
|
"symbol": form.get("symbol", [""])[-1].strip().upper(),
|
||||||
|
"strategy": form.get("strategy", [""])[-1].strip(),
|
||||||
|
"priority": int(form.get("priority", ["100"])[-1] or 100),
|
||||||
|
"card_title_template": form.get("card_title_template", ["TradingView {{symbol}} {{action}}"])[-1].strip(),
|
||||||
|
"card_body_template": form.get("card_body_template", [""])[-1].strip(),
|
||||||
|
"target_ids": [int(value) for value in form.get("target_ids", []) if value],
|
||||||
|
"enabled": 1 if form.get("enabled", [""])[-1] == "on" else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def preview_rule(self) -> None:
|
||||||
|
form = parse_form_multi(self)
|
||||||
|
rule = self.build_rule_from_form(form)
|
||||||
|
sample_payload = form.get("sample_payload", ["{}"])[-1]
|
||||||
|
source_action = form.get("source_action", ["/rules/create"])[-1]
|
||||||
|
title = "编辑路由规则" if rule.get("id") else "新增路由规则"
|
||||||
|
try:
|
||||||
|
alert = normalize_alert(json.loads(sample_payload))
|
||||||
|
message = build_feishu_message(alert, rule)
|
||||||
|
matched = self.context.dispatcher.find_matching_rule(alert)
|
||||||
|
current_matches = self.rule_matches_alert(rule, alert)
|
||||||
|
preview_html = self.render_preview_result(rule, message, matched, current_matches)
|
||||||
|
except (json.JSONDecodeError, ValidationError, ValueError) as exc:
|
||||||
|
preview_html = f"""<section class="result-panel error"><h2>预览失败</h2><p>{html.escape(str(exc))}</p></section>"""
|
||||||
|
self.render_rule_form(title, source_action, rule, preview_html, sample_payload)
|
||||||
|
|
||||||
|
def rule_matches_alert(self, rule: dict[str, Any], alert: dict[str, Any]) -> bool:
|
||||||
|
if not any((rule.get("timeframe"), rule.get("symbol"), rule.get("strategy"))):
|
||||||
|
return False
|
||||||
|
if rule.get("timeframe") and rule["timeframe"] != alert.get("timeframe"):
|
||||||
|
return False
|
||||||
|
if rule.get("symbol") and rule["symbol"].upper() != alert.get("symbol"):
|
||||||
|
return False
|
||||||
|
if rule.get("strategy") and rule["strategy"] != alert.get("strategy"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def render_preview_result(
|
||||||
|
self,
|
||||||
|
rule: dict[str, Any],
|
||||||
|
message: dict[str, Any],
|
||||||
|
matched: dict[str, Any] | None,
|
||||||
|
current_matches: bool,
|
||||||
|
) -> str:
|
||||||
|
title = message["card"]["header"]["title"]["content"]
|
||||||
|
content = message["card"]["elements"][0]["text"]["content"]
|
||||||
|
matched_text = f"当前已保存规则 #{matched['id']} {matched['name']}" if matched else "没有已保存规则会命中"
|
||||||
|
current_text = "当前表单会匹配样例 Alert" if current_matches else "当前表单不会匹配样例 Alert"
|
||||||
|
return f"""<section class="result-panel success">
|
||||||
|
<h2>预览结果</h2>
|
||||||
|
<div class="result-grid">
|
||||||
|
<div><span>当前表单</span><strong>{html.escape(current_text)}</strong></div>
|
||||||
|
<div><span>系统实际命中</span><strong>{html.escape(matched_text)}</strong></div>
|
||||||
|
<div><span>规则优先级</span><strong>{rule.get('priority')}</strong></div>
|
||||||
|
<div><span>消息类型</span><strong>飞书卡片</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<label class="check"><input name="enabled" type="checkbox" checked> 启用</label>
|
<div class="feishu-preview">
|
||||||
<button type="submit">保存规则</button>
|
<div class="feishu-preview-header">{html.escape(title)}</div>
|
||||||
</form>"""
|
<pre>{html.escape(content)}</pre>
|
||||||
self.send_html("路由规则", f"<header><h1>路由规则</h1><p>每条规则选择一个飞书 Webhook。模板支持 TradingView JSON 字段,例如 {{{{symbol}}}}、{{{{timeframe}}}}、{{{{strategy}}}}、{{{{price}}}},嵌套字段可写 {{{{order.id}}}}。</p></header>{form}<table><thead><tr><th>ID</th><th>名称</th><th>周期</th><th>品种</th><th>策略</th><th>优先级</th><th>消息</th><th>标题模板</th><th>内容模板</th><th>发送到</th><th>状态</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
|
</div>
|
||||||
|
</section>"""
|
||||||
|
|
||||||
def render_logs(self) -> None:
|
def render_logs(self) -> None:
|
||||||
logs = self.list_logs()
|
logs = self.list_logs()
|
||||||
@ -444,9 +627,49 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
conn.execute("DELETE FROM webhook_targets WHERE id = ?", (form["id"],))
|
conn.execute("DELETE FROM webhook_targets WHERE id = ?", (form["id"],))
|
||||||
redirect(self, "/targets")
|
redirect(self, "/targets")
|
||||||
|
|
||||||
|
def test_target(self) -> None:
|
||||||
|
form = parse_form(self)
|
||||||
|
with self.context.db.connect() as conn:
|
||||||
|
target = conn.execute("SELECT * FROM webhook_targets WHERE id = ?", (form.get("id"),)).fetchone()
|
||||||
|
if not target:
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
message = build_feishu_message(
|
||||||
|
{"symbol": "TEST", "timeframe": "5m", "strategy": "webhook-test", "action": "test"},
|
||||||
|
{
|
||||||
|
"card_title_template": "TV Dispatch 测试消息",
|
||||||
|
"card_body_template": "**目标**: {{symbol}}\n**动作**: {{action}}\n这是一条飞书 Webhook 连通性测试。",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = json.dumps(message, ensure_ascii=False).encode()
|
||||||
|
request = urllib.request.Request(
|
||||||
|
target["webhook_url"],
|
||||||
|
data=data,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=self.context.settings.feishu_timeout_seconds) as response:
|
||||||
|
status = response.getcode()
|
||||||
|
self.render_targets_with_notice(f"测试消息已发送到 {html.escape(target['name'])},HTTP {status}", success=True)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
self.render_targets_with_notice(f"测试发送失败:HTTP {exc.code}", success=False)
|
||||||
|
except Exception as exc:
|
||||||
|
self.render_targets_with_notice(f"测试发送失败:{html.escape(str(exc))}", success=False)
|
||||||
|
|
||||||
|
def render_targets_with_notice(self, message: str, success: bool) -> None:
|
||||||
|
self._target_notice = f"""<section class="result-panel {'success' if success else 'error'}"><h2>Webhook 测试</h2><p>{message}</p></section>"""
|
||||||
|
self.render_targets()
|
||||||
|
|
||||||
def create_rule(self) -> None:
|
def create_rule(self) -> None:
|
||||||
form = parse_form_multi(self)
|
form = parse_form_multi(self)
|
||||||
target_ids = [int(value) for value in form.get("target_ids", [])]
|
target_ids = [int(value) for value in form.get("target_ids", [])]
|
||||||
|
timeframe = form.get("timeframe", [""])[-1].strip()
|
||||||
|
symbol = form.get("symbol", [""])[-1].strip().upper()
|
||||||
|
strategy = form.get("strategy", [""])[-1].strip()
|
||||||
|
if not any((timeframe, symbol, strategy)):
|
||||||
|
self.send_error(400, "周期、品种、策略至少填写一个")
|
||||||
|
return
|
||||||
now = now_iso()
|
now = now_iso()
|
||||||
with self.context.db.connect() as conn:
|
with self.context.db.connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -460,11 +683,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
form.get("name", [""])[-1].strip(),
|
form.get("name", [""])[-1].strip(),
|
||||||
form.get("timeframe", [""])[-1].strip(),
|
timeframe,
|
||||||
form.get("symbol", [""])[-1].strip().upper(),
|
symbol,
|
||||||
form.get("strategy", [""])[-1].strip(),
|
strategy,
|
||||||
int(form.get("priority", ["100"])[-1]),
|
int(form.get("priority", ["100"])[-1]),
|
||||||
form.get("message_type", ["card"])[-1],
|
"card",
|
||||||
form.get("card_title_template", ["TradingView {{symbol}} {{action}}"])[-1].strip(),
|
form.get("card_title_template", ["TradingView {{symbol}} {{action}}"])[-1].strip(),
|
||||||
form.get("card_body_template", [""])[-1].strip(),
|
form.get("card_body_template", [""])[-1].strip(),
|
||||||
1 if form.get("enabled", [""])[-1] == "on" else 0,
|
1 if form.get("enabled", [""])[-1] == "on" else 0,
|
||||||
@ -484,6 +707,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def update_rule(self) -> None:
|
def update_rule(self) -> None:
|
||||||
form = parse_form_multi(self)
|
form = parse_form_multi(self)
|
||||||
target_ids = [int(value) for value in form.get("target_ids", [])]
|
target_ids = [int(value) for value in form.get("target_ids", [])]
|
||||||
|
timeframe = form.get("timeframe", [""])[-1].strip()
|
||||||
|
symbol = form.get("symbol", [""])[-1].strip().upper()
|
||||||
|
strategy = form.get("strategy", [""])[-1].strip()
|
||||||
|
if not any((timeframe, symbol, strategy)):
|
||||||
|
self.send_error(400, "周期、品种、策略至少填写一个")
|
||||||
|
return
|
||||||
with self.context.db.connect() as conn:
|
with self.context.db.connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@ -495,11 +724,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
form.get("name", [""])[-1].strip(),
|
form.get("name", [""])[-1].strip(),
|
||||||
form.get("timeframe", [""])[-1].strip(),
|
timeframe,
|
||||||
form.get("symbol", [""])[-1].strip().upper(),
|
symbol,
|
||||||
form.get("strategy", [""])[-1].strip(),
|
strategy,
|
||||||
int(form.get("priority", ["100"])[-1]),
|
int(form.get("priority", ["100"])[-1]),
|
||||||
form.get("message_type", ["card"])[-1],
|
"card",
|
||||||
form.get("card_title_template", ["TradingView {{symbol}} {{action}}"])[-1].strip(),
|
form.get("card_title_template", ["TradingView {{symbol}} {{action}}"])[-1].strip(),
|
||||||
form.get("card_body_template", [""])[-1].strip(),
|
form.get("card_body_template", [""])[-1].strip(),
|
||||||
1 if form.get("enabled", [""])[-1] == "on" else 0,
|
1 if form.get("enabled", [""])[-1] == "on" else 0,
|
||||||
|
|||||||
@ -1,29 +1,15 @@
|
|||||||
function updateMessageForm(scope) {
|
document.addEventListener("click", async (event) => {
|
||||||
const typeSelect = scope.querySelector("[data-message-type]");
|
const button = event.target.closest("[data-copy]");
|
||||||
if (!typeSelect) return;
|
if (!button) return;
|
||||||
const isText = typeSelect.value === "text";
|
const value = button.getAttribute("data-copy") || "";
|
||||||
const titleLabel = scope.querySelector("[data-title-label]");
|
try {
|
||||||
const bodyLabel = scope.querySelector("[data-body-label]");
|
await navigator.clipboard.writeText(value);
|
||||||
const titleTemplate = scope.querySelector("[data-title-template]");
|
const original = button.textContent;
|
||||||
const bodyTemplate = scope.querySelector("[data-body-template]");
|
button.textContent = "已复制";
|
||||||
|
setTimeout(() => {
|
||||||
scope.classList.toggle("text-message", isText);
|
button.textContent = original;
|
||||||
if (titleLabel) titleLabel.textContent = isText ? "文本标题模板" : "卡片标题模板";
|
}, 1200);
|
||||||
if (bodyLabel) bodyLabel.textContent = isText ? "文本内容模板" : "卡片正文模板";
|
} catch {
|
||||||
if (titleTemplate) {
|
window.prompt("复制下面的内容", value);
|
||||||
titleTemplate.placeholder = isText ? "例如:TradingView {{symbol}}" : "例如:TradingView {{symbol}} {{action}}";
|
|
||||||
}
|
}
|
||||||
if (bodyTemplate) {
|
|
||||||
bodyTemplate.placeholder = isText ? "{{symbol}} {{timeframe}} {{strategy}} {{action}}" : "**品种**: {{symbol}}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
document.querySelectorAll("[data-message-form]").forEach((scope) => {
|
|
||||||
updateMessageForm(scope);
|
|
||||||
const typeSelect = scope.querySelector("[data-message-type]");
|
|
||||||
if (typeSelect) {
|
|
||||||
typeSelect.addEventListener("change", () => updateMessageForm(scope));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -77,6 +77,13 @@ header {
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
@ -181,33 +188,15 @@ td textarea {
|
|||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-compact,
|
|
||||||
.field-target {
|
.field-target {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-compact {
|
|
||||||
width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-target {
|
.select-target {
|
||||||
width: clamp(220px, 34vw, 360px);
|
width: clamp(220px, 34vw, 360px);
|
||||||
}
|
}
|
||||||
|
|
||||||
td .select-compact {
|
|
||||||
min-width: 110px;
|
|
||||||
}
|
|
||||||
|
|
||||||
td .select-target {
|
|
||||||
min-width: 180px;
|
|
||||||
max-width: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-message [data-title-template] {
|
|
||||||
background: #f7f3e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check {
|
.check {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -247,6 +236,57 @@ button:hover {
|
|||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-link {
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
font: 800 14px ui-sans-serif, system-ui, sans-serif;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-link.secondary {
|
||||||
|
background: #ece7da;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-row span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-row code {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.result-panel {
|
.result-panel {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@ -264,6 +304,28 @@ button:hover {
|
|||||||
border-left: 5px solid var(--danger);
|
border-left: 5px solid var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feishu-preview {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feishu-preview-header {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #fff;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font: 800 15px ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feishu-preview pre {
|
||||||
|
margin: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.result-grid {
|
.result-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
@ -372,6 +434,10 @@ th {
|
|||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
@ -382,4 +448,8 @@ th {
|
|||||||
.result-grid {
|
.result-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.copy-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,12 @@ services:
|
|||||||
dispatcher:
|
dispatcher:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8030:8000"
|
||||||
environment:
|
environment:
|
||||||
ADMIN_USERNAME: admin
|
ADMIN_USERNAME: admin
|
||||||
ADMIN_PASSWORD: change-me-now
|
ADMIN_PASSWORD: change-me-now
|
||||||
SESSION_SECRET: replace-with-a-long-random-secret
|
SESSION_SECRET: replace-with-a-long-random-secret
|
||||||
|
WEBHOOK_TOKEN: replace-with-a-shared-webhook-secret
|
||||||
RETENTION_DAYS: 30
|
RETENTION_DAYS: 30
|
||||||
MAX_DELIVERY_ATTEMPTS: 3
|
MAX_DELIVERY_ATTEMPTS: 3
|
||||||
RETRY_BACKOFF_SECONDS: 60
|
RETRY_BACKOFF_SECONDS: 60
|
||||||
@ -20,6 +21,7 @@ services:
|
|||||||
ADMIN_USERNAME: admin
|
ADMIN_USERNAME: admin
|
||||||
ADMIN_PASSWORD: change-me-now
|
ADMIN_PASSWORD: change-me-now
|
||||||
SESSION_SECRET: replace-with-a-long-random-secret
|
SESSION_SECRET: replace-with-a-long-random-secret
|
||||||
|
WEBHOOK_TOKEN: replace-with-a-shared-webhook-secret
|
||||||
RETENTION_DAYS: 30
|
RETENTION_DAYS: 30
|
||||||
MAX_DELIVERY_ATTEMPTS: 3
|
MAX_DELIVERY_ATTEMPTS: 3
|
||||||
RETRY_BACKOFF_SECONDS: 60
|
RETRY_BACKOFF_SECONDS: 60
|
||||||
|
|||||||
@ -34,7 +34,15 @@ class DispatcherTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
return int(cur.lastrowid)
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
def add_rule(self, target_id: int, priority: int = 100, name: str = "rule") -> int:
|
def add_rule(
|
||||||
|
self,
|
||||||
|
target_id: int,
|
||||||
|
priority: int = 100,
|
||||||
|
name: str = "rule",
|
||||||
|
timeframe: str = "5m",
|
||||||
|
symbol: str = "BTCUSDT",
|
||||||
|
strategy: str = "breakout",
|
||||||
|
) -> int:
|
||||||
now = now_iso()
|
now = now_iso()
|
||||||
with self.db.connect() as conn:
|
with self.db.connect() as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
@ -45,16 +53,12 @@ class DispatcherTest(unittest.TestCase):
|
|||||||
card_title_template, card_body_template, enabled, target_ids,
|
card_title_template, card_body_template, enabled, target_ids,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, '5m', 'BTCUSDT', 'breakout', ?, 'card', 'Signal {{symbol}}', 'Price {{price}}', 1, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, 'card', 'Signal {{symbol}}', 'Price {{price}}', 1, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(name, priority, to_json([target_id]), now, now),
|
(name, timeframe, symbol, strategy, priority, to_json([target_id]), now, now),
|
||||||
)
|
)
|
||||||
return int(cur.lastrowid)
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
def test_missing_required_fields_are_rejected(self) -> None:
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
self.dispatcher.receive_alert({"symbol": "BTCUSDT"})
|
|
||||||
|
|
||||||
def test_unmatched_alert_is_stored(self) -> None:
|
def test_unmatched_alert_is_stored(self) -> None:
|
||||||
result = self.dispatcher.receive_alert({"timeframe": "15m", "symbol": "ETHUSDT", "strategy": "trend"})
|
result = self.dispatcher.receive_alert({"timeframe": "15m", "symbol": "ETHUSDT", "strategy": "trend"})
|
||||||
self.assertEqual(result["status"], "unmatched")
|
self.assertEqual(result["status"], "unmatched")
|
||||||
@ -78,6 +82,35 @@ class DispatcherTest(unittest.TestCase):
|
|||||||
delivery = conn.execute("SELECT * FROM deliveries WHERE alert_id = ?", (result["alert_id"],)).fetchone()
|
delivery = conn.execute("SELECT * FROM deliveries WHERE alert_id = ?", (result["alert_id"],)).fetchone()
|
||||||
self.assertEqual(delivery["target_id"], fast_target)
|
self.assertEqual(delivery["target_id"], fast_target)
|
||||||
|
|
||||||
|
def test_single_dimension_rule_matches(self) -> None:
|
||||||
|
target_id = self.add_target()
|
||||||
|
rule_id = self.add_rule(target_id, timeframe="", symbol="BTCUSDT", strategy="")
|
||||||
|
|
||||||
|
result = self.dispatcher.receive_alert({"symbol": "btcusdt", "action": "buy"})
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "matched")
|
||||||
|
self.assertEqual(result["matched_rule_id"], rule_id)
|
||||||
|
|
||||||
|
def test_more_specific_rule_wins_when_priority_ties(self) -> None:
|
||||||
|
broad_target = self.add_target("broad")
|
||||||
|
specific_target = self.add_target("specific")
|
||||||
|
broad_rule = self.add_rule(broad_target, priority=10, name="broad", timeframe="", symbol="BTCUSDT", strategy="")
|
||||||
|
specific_rule = self.add_rule(specific_target, priority=10, name="specific")
|
||||||
|
|
||||||
|
result = self.dispatcher.receive_alert({"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout"})
|
||||||
|
|
||||||
|
self.assertEqual(result["matched_rule_id"], specific_rule)
|
||||||
|
self.assertNotEqual(result["matched_rule_id"], broad_rule)
|
||||||
|
|
||||||
|
def test_find_matching_rule_preview(self) -> None:
|
||||||
|
target_id = self.add_target()
|
||||||
|
rule_id = self.add_rule(target_id, timeframe="", symbol="BTCUSDT", strategy="")
|
||||||
|
|
||||||
|
rule = self.dispatcher.find_matching_rule({"symbol": "btcusdt"})
|
||||||
|
|
||||||
|
self.assertIsNotNone(rule)
|
||||||
|
self.assertEqual(rule["id"], rule_id)
|
||||||
|
|
||||||
def test_failed_delivery_is_marked_for_retry(self) -> None:
|
def test_failed_delivery_is_marked_for_retry(self) -> None:
|
||||||
target_id = self.add_target()
|
target_id = self.add_target()
|
||||||
self.add_rule(target_id)
|
self.add_rule(target_id)
|
||||||
@ -106,7 +139,7 @@ class DispatcherTest(unittest.TestCase):
|
|||||||
self.assertEqual(message["card"]["header"]["title"]["content"], "BTCUSDT buy")
|
self.assertEqual(message["card"]["header"]["title"]["content"], "BTCUSDT buy")
|
||||||
self.assertEqual(message["card"]["elements"][0]["text"]["content"], "**价格** 68000")
|
self.assertEqual(message["card"]["elements"][0]["text"]["content"], "**价格** 68000")
|
||||||
|
|
||||||
def test_template_accepts_legacy_single_braces(self) -> None:
|
def test_template_accepts_legacy_single_braces_and_still_sends_card(self) -> None:
|
||||||
message = build_feishu_message(
|
message = build_feishu_message(
|
||||||
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000},
|
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000},
|
||||||
{
|
{
|
||||||
@ -116,7 +149,9 @@ class DispatcherTest(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(message["content"]["text"], "TradingView BTCUSDT buy\n价格 68000")
|
self.assertEqual(message["msg_type"], "interactive")
|
||||||
|
self.assertEqual(message["card"]["header"]["title"]["content"], "TradingView BTCUSDT buy")
|
||||||
|
self.assertEqual(message["card"]["elements"][0]["text"]["content"], "价格 68000")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user