164 lines
5.0 KiB
Python
164 lines
5.0 KiB
Python
"""企业微信群机器人通知(独立实现,不依赖策略仓)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import time
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from packages.config import get_settings
|
||
|
||
log = logging.getLogger("notify.wecom")
|
||
|
||
_last_fault_key: str | None = None
|
||
_last_fault_ms: float = 0.0
|
||
_fault_active: bool = False
|
||
|
||
|
||
def _as_bool(raw: Any, default: bool = False) -> bool:
|
||
if raw is None or raw == "":
|
||
return default
|
||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||
|
||
|
||
def wecom_enabled() -> bool:
|
||
s = get_settings()
|
||
return _as_bool(getattr(s, "wecom_enabled", False))
|
||
|
||
|
||
def wecom_webhook_url() -> str:
|
||
s = get_settings()
|
||
return (getattr(s, "wecom_webhook_url", "") or "").strip()
|
||
|
||
|
||
def wecom_machine_name() -> str:
|
||
s = get_settings()
|
||
return (getattr(s, "wecom_machine_name", "") or "").strip()[:64]
|
||
|
||
|
||
def alert_fail_threshold() -> int:
|
||
s = get_settings()
|
||
try:
|
||
return max(1, int(getattr(s, "alert_fail_threshold", 5) or 5))
|
||
except (TypeError, ValueError):
|
||
return 5
|
||
|
||
|
||
def build_markdown(*, tag: str, title: str, lines: list[str] | None = None) -> str:
|
||
machine = wecom_machine_name()
|
||
prefix = f"【{machine}】" if machine else ""
|
||
parts = [
|
||
f"## {prefix}{title}",
|
||
f"> **标识**: `{tag}`",
|
||
f"> **系统**: 比特骆驼行情采集分析",
|
||
f"> **时间**: {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
||
]
|
||
if machine:
|
||
parts.append(f"> **机器**: {machine}")
|
||
if lines:
|
||
parts.append("")
|
||
for ln in lines:
|
||
parts.append(f"> {ln}" if not ln.startswith(">") else ln)
|
||
return "\n".join(parts)
|
||
|
||
|
||
def post_markdown(content: str) -> tuple[bool, str]:
|
||
if not wecom_enabled():
|
||
return False, "未开启企业微信通知"
|
||
url = wecom_webhook_url()
|
||
if not url:
|
||
return False, "未配置 Webhook"
|
||
raw = content.encode("utf-8")
|
||
if len(raw) > 4000:
|
||
content = raw[:3900].decode("utf-8", errors="ignore") + "\n…"
|
||
payload = {"msgtype": "markdown", "markdown": {"content": content}}
|
||
try:
|
||
with httpx.Client(timeout=10.0) as client:
|
||
r = client.post(url, json=payload)
|
||
body = r.json() if r.content else {}
|
||
if r.status_code != 200 or str(body.get("errcode", 0)) not in ("0", "0.0"):
|
||
return False, f"webhook failed status={r.status_code} body={body}"
|
||
return True, "ok"
|
||
except Exception as e: # noqa: BLE001
|
||
return False, str(e)
|
||
|
||
|
||
def notify_test() -> tuple[bool, str]:
|
||
md = build_markdown(
|
||
tag="TEST",
|
||
title="行情采集分析 · 测试推送",
|
||
lines=["这是一条测试消息,说明企微 Webhook 可用。"],
|
||
)
|
||
return post_markdown(md)
|
||
|
||
|
||
def notify_collector_fault(
|
||
*,
|
||
error: str,
|
||
consecutive_failures: int,
|
||
dedup_sec: float = 300.0,
|
||
) -> tuple[bool, str]:
|
||
"""连续失败告警;同错误键在 dedup_sec 内不重复推。"""
|
||
global _last_fault_key, _last_fault_ms, _fault_active
|
||
threshold = alert_fail_threshold()
|
||
if consecutive_failures < threshold:
|
||
return False, f"below threshold ({consecutive_failures}<{threshold})"
|
||
|
||
key = f"{consecutive_failures // threshold}:{(error or '')[:120]}"
|
||
now = time.time()
|
||
if (
|
||
_last_fault_key == key
|
||
and (now - _last_fault_ms) < dedup_sec
|
||
):
|
||
return False, "dedup"
|
||
_last_fault_key = key
|
||
_last_fault_ms = now
|
||
_fault_active = True
|
||
|
||
# 脱敏:避免日志/推送里出现完整密钥形态串
|
||
err_show = (error or "unknown").replace("\n", " ")[:300]
|
||
md = build_markdown(
|
||
tag="FAULT",
|
||
title="行情采集异常",
|
||
lines=[
|
||
f"**连续失败**: {consecutive_failures}(阈值 {threshold})",
|
||
f"**错误**: {err_show}",
|
||
"请检查 OKX 连通性 / 代理 / 合约是否可交易。",
|
||
],
|
||
)
|
||
ok, msg = post_markdown(md)
|
||
if ok:
|
||
log.info("wecom fault notified failures=%s", consecutive_failures)
|
||
else:
|
||
log.warning("wecom fault notify failed: %s", msg)
|
||
return ok, msg
|
||
|
||
|
||
def notify_collector_recovered(*, consecutive_failures: int = 0) -> tuple[bool, str]:
|
||
global _fault_active, _last_fault_key
|
||
if not _fault_active:
|
||
return False, "no active fault"
|
||
_fault_active = False
|
||
_last_fault_key = None
|
||
md = build_markdown(
|
||
tag="RECOVER",
|
||
title="行情采集已恢复",
|
||
lines=["采样已恢复正常。"],
|
||
)
|
||
ok, msg = post_markdown(md)
|
||
if ok:
|
||
log.info("wecom recovered notified")
|
||
else:
|
||
log.warning("wecom recover notify failed: %s", msg)
|
||
return ok, msg
|
||
|
||
|
||
def reset_alert_state() -> None:
|
||
"""测试用。"""
|
||
global _last_fault_key, _last_fault_ms, _fault_active
|
||
_last_fault_key = None
|
||
_last_fault_ms = 0.0
|
||
_fault_active = False
|