feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8

体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
study-tool
2026-11-01 17:47:22 +08:00
parent 491fd08c1e
commit aa48145557
114 changed files with 7779 additions and 3302 deletions
@@ -0,0 +1 @@
"""呆呆面板推送插件:实现在 main.py,配置在「系统设置 → 推送通知」。"""
+111
View File
@@ -0,0 +1,111 @@
"""呆呆面板推送:换 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())
@@ -0,0 +1,6 @@
{
"id": "daidai_push",
"title": "呆呆面板推送",
"name": "daidai_push",
"kind": "notify"
}