Files
study-tool/src/study_tool/plugins/daidai_push/main.py
T
study-tool aa48145557 feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
2026-11-01 17:47:22 +08:00

112 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""呆呆面板推送:换 token(缓存到过期)+ 发通知。
接口
POST {base_url}/api/open-api/token {app_key, app_secret} -> token
POST {base_url}/api/notifications/send Authorization: Bearer <token>
配置全部来自系统设置 `notify`(base_url / app_key / app_secret / channel_name),
本文件不写死任何地址或密钥。换 token 只在内存里缓存,重启后自动重取。
"""
import threading
import time
import requests
from study_tool import notify
from study_tool.config.settings import load_settings
TOKEN_PATH = "/api/open-api/token"
SEND_PATH = "/api/notifications/send"
TOKEN_TTL = 3600 # 接口没给有效期时的兜底
_CACHE = {} # (base_url, app_key) -> (token, 过期时间)
_LOCK = threading.Lock()
def _base(cfg):
return (cfg.get("base_url") or "https://corn.hanhan.ltd").rstrip("/")
def _find(obj, names, depth=0):
"""在返回体里找字段:顶层找不到就往 data / result 里找一层。"""
if not isinstance(obj, dict) or depth > 3:
return ""
for n in names:
v = obj.get(n)
if isinstance(v, (str, int, float)) and str(v).strip():
return v
for n in ("data", "result", "content", "body"):
v = _find(obj.get(n), names, depth + 1)
if v != "":
return v
return ""
def _json(r, what):
try:
return r.json()
except Exception:
raise RuntimeError("%s 返回非 JSON(HTTP %s):%s" % (what, r.status_code, (r.text or "")[:120]))
class DaidaiPush(notify.Channel):
id = "daidai_push"
label = "呆呆面板推送"
def _token(self, cfg, force=False):
ck = (_base(cfg), cfg.get("app_key"))
with _LOCK:
hit = _CACHE.get(ck)
if hit and not force and hit[1] > time.time() + 60:
return hit[0]
r = requests.post(
ck[0] + TOKEN_PATH,
json={"app_key": cfg["app_key"], "app_secret": cfg["app_secret"]},
timeout=20,
)
j = _json(r, "换 token")
token = str(_find(j, ("token", "access_token", "accessToken")) or "")
if not token:
raise RuntimeError("换 token 失败:%s" % str(j)[:200])
try:
ttl = int(float(_find(j, ("expires_in", "expire_in", "expiresIn")) or 0))
except (TypeError, ValueError):
ttl = 0
with _LOCK:
_CACHE[ck] = (token, time.time() + (ttl or TOKEN_TTL))
return token
def send(self, title, content, context=None):
cfg = load_settings()["notify"]
if not cfg.get("app_key") or not cfg.get("app_secret"):
raise RuntimeError("推送未配置:系统设置 → 推送通知 里填 app_key / app_secret")
base = _base(cfg)
payload = {
"title": title,
"content": content,
"content_type": "html",
"context": {str(k): v for k, v in (context or {}).items()},
}
if cfg.get("channel_name"):
payload["channel_name"] = cfg["channel_name"]
for attempt in (0, 1):
token = self._token(cfg, force=attempt == 1)
r = requests.post(
base + SEND_PATH,
headers={"Authorization": "Bearer " + token},
json=payload,
timeout=20,
)
if r.status_code in (401, 403) and attempt == 0:
continue # token 过期:丢掉缓存换一张再发一次
j = _json(r, "推送")
ok = str(_find(j, ("code",)) or 200) in ("0", "200") or _find(j, ("success", "ok")) in (True, "true")
if r.status_code >= 400 or not ok:
raise RuntimeError("推送失败:HTTP %s %s" % (r.status_code, str(j)[:200]))
return j
raise RuntimeError("推送失败:token 被拒(换新 token 后仍然 401/403)")
NOTIFY = notify.register(DaidaiPush())