tradingview-alert-dispatcher/app/dispatcher.py
2026-05-15 22:04:05 +08:00

302 lines
11 KiB
Python

from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
from typing import Any
from app.config import Settings
from app.db import Database, from_json, now_iso, to_json
UTC = timezone.utc
TEMPLATE_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_.-]+)\s*}}|(?<!{){\s*([a-zA-Z0-9_.-]+)\s*}(?!})")
class ValidationError(ValueError):
pass
def normalize_alert(payload: dict[str, Any]) -> dict[str, Any]:
normalized = dict(payload)
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"])
except (TypeError, ValueError) as exc:
raise ValidationError("price must be numeric") from exc
return normalized
def resolve_template_value(alert: dict[str, Any], field: str) -> str:
value: Any = alert
for part in field.split("."):
if isinstance(value, dict) and part in value:
value = value[part]
else:
return ""
if value is None:
return ""
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False)
return str(value)
def render_template(template: str, alert: dict[str, Any]) -> str:
return TEMPLATE_PATTERN.sub(lambda match: resolve_template_value(alert, match.group(1) or match.group(2)), template)
def default_body(alert: dict[str, Any]) -> str:
action = alert.get("action") or alert.get("signal") or "alert"
lines = [
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:
lines.append(f"价格: {alert['price']}")
if alert.get("time"):
lines.append(f"时间: {alert['time']}")
return "\n".join(lines)
def build_feishu_message(alert: dict[str, Any], rule: dict[str, Any] | None = None) -> dict[str, Any]:
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.get('symbol') or 'Alert'}"
body = render_template(body_template, alert).strip() or default_body(alert)
return {
"msg_type": "interactive",
"card": {
"config": {"wide_screen_mode": True},
"header": {
"template": "blue",
"title": {"tag": "plain_text", "content": title},
},
"elements": [
{"tag": "div", "text": {"tag": "lark_md", "content": body}},
{
"tag": "hr",
},
{
"tag": "note",
"elements": [
{
"tag": "plain_text",
"content": f"{alert.get('symbol') or '-'} · {alert.get('timeframe') or '-'} · {alert.get('strategy') or '-'}",
}
],
},
],
},
}
class Dispatcher:
def __init__(self, db: Database, settings: Settings):
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 = self.find_matching_rule(alert)
status = "matched" if rule else "unmatched"
cur = conn.execute(
"""
INSERT INTO alerts (
timeframe, symbol, strategy, action, price, payload,
matched_rule_id, status, error, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
alert["timeframe"],
alert["symbol"],
alert["strategy"],
alert.get("action") or alert.get("signal"),
alert.get("price"),
to_json(alert),
rule["id"] if rule else None,
status,
None if rule else "No enabled routing rule matched this alert.",
created_at,
),
)
alert_id = int(cur.lastrowid)
delivery_ids: list[int] = []
if rule:
target_ids = from_json(rule["target_ids"], [])
if target_ids:
placeholders = ",".join("?" for _ in target_ids)
targets = conn.execute(
f"SELECT * FROM webhook_targets WHERE id IN ({placeholders})",
target_ids,
).fetchall()
for target in targets:
delivery = conn.execute(
"""
INSERT INTO deliveries (
alert_id, rule_id, target_id, target_name, webhook_url,
status, attempts, next_attempt_at, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?)
""",
(
alert_id,
rule["id"],
target["id"],
target["name"],
target["webhook_url"],
created_at,
created_at,
created_at,
),
)
delivery_ids.append(int(delivery.lastrowid))
if rule and not delivery_ids:
conn.execute(
"UPDATE alerts SET status = ?, error = ? WHERE id = ?",
("unmatched", "Matched rule has no webhook targets.", alert_id),
)
self.process_due_deliveries()
return {
"alert_id": alert_id,
"status": status,
"matched_rule_id": rule["id"] if rule else None,
"delivery_ids": delivery_ids,
}
def process_due_deliveries(self, limit: int = 25) -> int:
now = now_iso()
with self.db.connect() as conn:
rows = conn.execute(
"""
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
WHERE d.status IN ('pending', 'retry')
AND (d.next_attempt_at IS NULL OR d.next_attempt_at <= ?)
ORDER BY d.created_at ASC
LIMIT ?
""",
(now, limit),
).fetchall()
processed = 0
for row in rows:
delivery = dict(row)
payload = from_json(delivery["payload"], {})
self._send_delivery(delivery, payload)
processed += 1
return processed
def _send_delivery(self, delivery: dict[str, Any], alert: dict[str, Any]) -> None:
attempts = int(delivery["attempts"]) + 1
message = build_feishu_message(alert, delivery)
encoded = json.dumps(message, ensure_ascii=False).encode()
request = urllib.request.Request(
delivery["webhook_url"],
data=encoded,
headers={"Content-Type": "application/json"},
method="POST",
)
response_code: int | None = None
response_body: str | None = None
error: str | None = None
status = "sent"
try:
with urllib.request.urlopen(request, timeout=self.settings.feishu_timeout_seconds) as response:
response_code = response.getcode()
response_body = response.read(2048).decode(errors="replace")
if response_code >= 400:
status = "failed"
error = f"Feishu webhook returned HTTP {response_code}"
except urllib.error.HTTPError as exc:
response_code = exc.code
response_body = exc.read(2048).decode(errors="replace")
status = "failed"
error = f"Feishu webhook returned HTTP {exc.code}"
except Exception as exc:
status = "failed"
error = str(exc)
next_attempt_at = None
if status == "failed" and attempts < self.settings.max_delivery_attempts:
status = "retry"
next_time = datetime.now(UTC) + timedelta(seconds=self.settings.retry_backoff_seconds * attempts)
next_attempt_at = next_time.replace(microsecond=0).isoformat()
with self.db.connect() as conn:
conn.execute(
"""
UPDATE deliveries
SET status = ?, attempts = ?, next_attempt_at = ?, last_attempt_at = ?,
response_code = ?, response_body = ?, error = ?, updated_at = ?
WHERE id = ?
""",
(
status,
attempts,
next_attempt_at,
now_iso(),
response_code,
response_body,
error,
now_iso(),
delivery["id"],
),
)
failed_open = conn.execute(
"""
SELECT COUNT(*) AS count
FROM deliveries
WHERE alert_id = ? AND status IN ('pending', 'retry', 'failed')
""",
(delivery["alert_id"],),
).fetchone()["count"]
conn.execute(
"UPDATE alerts SET status = ? WHERE id = ?",
("delivered" if failed_open == 0 else "partial", delivery["alert_id"]),
)