- 原生 fnOS 应用(CGI 入口 + Python 守护进程),依赖 python312 运行时 - 三平台签到:TRAE 每日 200 积分、WorkBuddy 7 天循环、Qoder 每日 100 Credits - WorkBuddy 网页授权(OAuth 设备流)、TRAE icubeAuth 解密 Token、Qoder Cosy 设备头 - 多时段调度(默认 00:05/09:00/12:30/19:00)、启动补跑、幂等签到+回查确认 - 多平台 Webhook 通知(企业微信/钉钉/飞书/Bark/Server酱/PushPlus 自动识别) - Qoder 双站点 Token 隔离自动探测与修复 - 前端:仪表盘/账号管理/签到记录/设置四个页签,30s 轮询+变化检测+隐藏暂停 - 测试:34 后端 + 25 E2E + 9 前端回归 + 真实时钟调度测试
387 lines
13 KiB
Python
387 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""AgentCheckin CGI API server (invoked by app/ui/index.cgi).
|
|
|
|
All endpoints are JSON. The fnOS web server validates the NAS login session
|
|
before calling the CGI, so no extra auth is implemented here.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.parse
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from common import (load_accounts, load_settings, save_settings, load_state,
|
|
save_state, log, fmt_now, now_ts, PID_FILE, TRIGGER_FILE,
|
|
ensure_dirs, mask_token, public_account)
|
|
import engine
|
|
import workbuddy
|
|
|
|
MAX_BODY = 256 * 1024
|
|
OAUTH_TTL = 10 * 60 # seconds a login link stays valid
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CGI plumbing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def cgi_env():
|
|
return {
|
|
"method": os.environ.get("REQUEST_METHOD", "GET").upper(),
|
|
"uri": os.environ.get("REQUEST_URI", ""),
|
|
"query": os.environ.get("QUERY_STRING", ""),
|
|
}
|
|
|
|
|
|
def read_body():
|
|
try:
|
|
length = int(os.environ.get("CONTENT_LENGTH") or 0)
|
|
except ValueError:
|
|
length = 0
|
|
if length <= 0:
|
|
return {}
|
|
if length > MAX_BODY:
|
|
return None
|
|
raw = sys.stdin.buffer.read(length)
|
|
try:
|
|
data = json.loads(raw.decode("utf-8"))
|
|
return data if isinstance(data, dict) else {}
|
|
except (ValueError, json.JSONDecodeError):
|
|
return {}
|
|
|
|
|
|
def respond(obj, status="200 OK"):
|
|
body = json.dumps(obj, ensure_ascii=False)
|
|
out = sys.stdout
|
|
out.write("Status: %s\r\n" % status)
|
|
out.write("Content-Type: application/json; charset=utf-8\r\n")
|
|
out.write("Cache-Control: no-store\r\n")
|
|
out.write("Content-Length: %d\r\n" % len(body.encode("utf-8")))
|
|
out.write("\r\n")
|
|
out.write(body)
|
|
|
|
|
|
def error(msg, status="400 Bad Request"):
|
|
respond({"error": msg}, status)
|
|
|
|
|
|
def route_path(uri):
|
|
path = uri.split("?", 1)[0]
|
|
idx = path.find("index.cgi")
|
|
if idx >= 0:
|
|
path = path[idx + len("index.cgi"):]
|
|
return path.rstrip("/") or "/"
|
|
|
|
|
|
def query_params(qs):
|
|
return {k: v[0] for k, v in urllib.parse.parse_qs(qs, keep_blank_values=True).items()}
|
|
|
|
|
|
def daemon_alive():
|
|
try:
|
|
with open(PID_FILE) as f:
|
|
pid = int(f.read().strip())
|
|
os.kill(pid, 0)
|
|
return True
|
|
except (OSError, ValueError):
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def h_state(_params, _body):
|
|
data = engine.dashboard()
|
|
data["daemon_alive"] = daemon_alive()
|
|
return data
|
|
|
|
|
|
def h_checkin(_params, body):
|
|
account_id = body.get("account_id")
|
|
accounts = load_accounts()
|
|
if account_id and not any(a.get("id") == account_id for a in accounts):
|
|
return ("error", "账号不存在")
|
|
if daemon_alive():
|
|
ensure_dirs()
|
|
with open(TRIGGER_FILE, "w") as f:
|
|
f.write(json.dumps({"account_id": account_id, "ts": now_ts()}))
|
|
return {"ok": True, "mode": "trigger", "message": "已触发签到,守护进程正在执行"}
|
|
# daemon not running: run synchronously as a fallback
|
|
results = engine.run_all(account_id=account_id, reason="manual-cgi")
|
|
return {"ok": True, "mode": "sync", "results": results}
|
|
|
|
|
|
def h_accounts(_params, _body):
|
|
return {"accounts": [public_account(a) for a in load_accounts()]}
|
|
|
|
|
|
def h_add_wb_manual(_params, body):
|
|
token = (body.get("access_token") or "").strip()
|
|
if not token:
|
|
return ("error", "access_token 不能为空")
|
|
if len(token) > 8192:
|
|
return ("error", "access_token 过长")
|
|
acc = engine.add_workbuddy_manual(
|
|
name=(body.get("name") or "").strip()[:60],
|
|
access_token=token,
|
|
refresh_token=(body.get("refresh_token") or "").strip(),
|
|
domain=(body.get("domain") or "codebuddy.cn").strip(),
|
|
)
|
|
return {"ok": True, "account": public_account(acc)}
|
|
|
|
|
|
def h_add_trae(_params, body):
|
|
token = (body.get("access_token") or "").strip()
|
|
if not token:
|
|
return ("error", "access_token 不能为空")
|
|
if len(token) > 8192:
|
|
return ("error", "access_token 过长")
|
|
acc = engine.add_trae(
|
|
name=(body.get("name") or "").strip()[:60],
|
|
access_token=token,
|
|
refresh_token=(body.get("refresh_token") or "").strip(),
|
|
device_id_str=(body.get("device_id") or "").strip(),
|
|
)
|
|
return {"ok": True, "account": public_account(acc)}
|
|
|
|
|
|
|
|
def h_add_qoder(_params, body):
|
|
token = (body.get("access_token") or "").strip()
|
|
if not token:
|
|
return ("error", "access_token 不能为空")
|
|
if len(token) > 8192:
|
|
return ("error", "access_token 过长")
|
|
device = body.get("device") or {}
|
|
if not isinstance(device, dict):
|
|
return ("error", "device 必须是对象")
|
|
acc = engine.add_qoder(
|
|
name=(body.get("name") or "").strip()[:60],
|
|
access_token=token,
|
|
refresh_token=(body.get("refresh_token") or "").strip(),
|
|
device=device,
|
|
domain=(body.get("domain") or "openapi.qoder.com.cn").strip(),
|
|
)
|
|
return {"ok": True, "account": public_account(acc)}
|
|
|
|
def h_update_account(_params, body):
|
|
acc_id = body.get("id")
|
|
if not acc_id:
|
|
return ("error", "缺少 id")
|
|
fields = {}
|
|
if "name" in body:
|
|
fields["name"] = str(body["name"])[:60]
|
|
if "enabled" in body:
|
|
fields["enabled"] = bool(body["enabled"])
|
|
acc = engine.update_account(acc_id, **fields)
|
|
if not acc:
|
|
return ("error", "账号不存在")
|
|
return {"ok": True, "account": public_account(acc)}
|
|
|
|
|
|
|
|
def h_fix_qoder_domain(_params, body):
|
|
"""Probe both Qoder sites with the account token and switch to the one
|
|
that authenticates (the two sites' tokens are not interchangeable)."""
|
|
acc_id = body.get("id")
|
|
if not acc_id:
|
|
return ("error", "缺少 id")
|
|
accounts = load_accounts()
|
|
acc = engine.find_account(accounts, acc_id)
|
|
if not acc or acc.get("platform") != "qoder":
|
|
return ("error", "账号不存在或不是 Qoder 账号")
|
|
import qoder as _q
|
|
probe = _q.probe_domains(acc)
|
|
for dom, r in probe.items():
|
|
if r.get("auth"):
|
|
acc.setdefault("auth", {})["domain"] = dom
|
|
engine.save_accounts(accounts)
|
|
log("qoder account %s domain switched to %s" % (acc_id, dom))
|
|
return {"ok": True, "domain": dom, "probe": probe}
|
|
return {"ok": False, "probe": probe,
|
|
"error": "两个站点均无法认证(Token 可能已过期),请重新获取 Token"}
|
|
|
|
def h_delete_account(_params, body):
|
|
acc_id = body.get("id")
|
|
if not acc_id:
|
|
return ("error", "缺少 id")
|
|
ok = engine.delete_account(acc_id)
|
|
if not ok:
|
|
return ("error", "账号不存在")
|
|
return {"ok": True}
|
|
|
|
|
|
def h_test_account(_params, body):
|
|
acc_id = body.get("id")
|
|
if not acc_id:
|
|
return ("error", "缺少 id")
|
|
return engine.test_account(acc_id)
|
|
|
|
|
|
def h_wb_oauth_begin(_params, _body):
|
|
state, login_url, err = workbuddy.begin_oauth()
|
|
if err:
|
|
return ("error", err)
|
|
st = load_state()
|
|
sessions = st.setdefault("oauth_sessions", {})
|
|
# drop expired sessions
|
|
now = now_ts()
|
|
for k in list(sessions.keys()):
|
|
if now - sessions[k].get("created", 0) > OAUTH_TTL:
|
|
del sessions[k]
|
|
sessions[state] = {"created": now}
|
|
save_state(st)
|
|
log("workbuddy oauth session started")
|
|
return {"ok": True, "state": state, "login_url": login_url,
|
|
"ttl": OAUTH_TTL}
|
|
|
|
|
|
def h_wb_oauth_poll(params, _body):
|
|
state = params.get("state", "")
|
|
if not state:
|
|
return ("error", "缺少 state")
|
|
st = load_state()
|
|
sessions = st.get("oauth_sessions") or {}
|
|
sess = sessions.get(state)
|
|
if not sess:
|
|
return ("error", "授权会话不存在或已过期,请重新生成链接")
|
|
if now_ts() - sess.get("created", 0) > OAUTH_TTL:
|
|
del sessions[state]
|
|
save_state(st)
|
|
return ("error", "授权链接已过期,请重新生成")
|
|
status, payload, err = workbuddy.poll_oauth(state)
|
|
if status == "done":
|
|
acc = engine.add_workbuddy_oauth(payload)
|
|
del sessions[state]
|
|
save_state(st)
|
|
return {"ok": True, "status": "done", "account": public_account(acc)}
|
|
return {"ok": True, "status": "waiting"}
|
|
|
|
|
|
def h_settings_get(_params, _body):
|
|
return {"settings": load_settings()}
|
|
|
|
|
|
def h_settings_post(_params, body):
|
|
settings = load_settings()
|
|
if "schedule_times" in body:
|
|
times = body["schedule_times"]
|
|
if not isinstance(times, list):
|
|
return ("error", "schedule_times 必须是数组")
|
|
clean = []
|
|
for t in times:
|
|
t = str(t)
|
|
if len(t) == 5 and t[2] == ":" and t[:2].isdigit() and t[3:].isdigit():
|
|
hh, mm = int(t[:2]), int(t[3:])
|
|
if 0 <= hh < 24 and 0 <= mm < 60:
|
|
clean.append(t)
|
|
if not clean:
|
|
return ("error", "至少需要一个有效的签到时间(HH:MM)")
|
|
settings["schedule_times"] = sorted(set(clean))
|
|
if "webhook_url" in body:
|
|
url = str(body["webhook_url"]).strip()
|
|
if url and not url.startswith(("http://", "https://")):
|
|
return ("error", "webhook_url 必须以 http(s):// 开头")
|
|
settings["webhook_url"] = url[:500]
|
|
if "webhook_type" in body:
|
|
wt = str(body["webhook_type"])
|
|
if wt in engine.WEBHOOK_TYPES:
|
|
settings["webhook_type"] = wt
|
|
else:
|
|
return ("error", "不支持的 webhook_type")
|
|
if "notify_on" in body:
|
|
nf = body["notify_on"]
|
|
if isinstance(nf, list):
|
|
settings["notify_on"] = [n for n in nf if n in ("success", "fail")]
|
|
save_settings(settings)
|
|
return {"ok": True, "settings": settings}
|
|
|
|
|
|
def h_logs(params, _body):
|
|
try:
|
|
lines = int(params.get("lines", "200"))
|
|
except ValueError:
|
|
lines = 200
|
|
lines = max(10, min(lines, 2000))
|
|
from common import LOG_FILE
|
|
if not os.path.exists(LOG_FILE):
|
|
return {"lines": []}
|
|
try:
|
|
with open(LOG_FILE, encoding="utf-8") as f:
|
|
content = f.read().splitlines()
|
|
return {"lines": content[-lines:]}
|
|
except OSError as e:
|
|
return {"lines": [], "error": str(e)}
|
|
|
|
|
|
def h_debug(_params, _body):
|
|
"""Diagnostics: resolved paths, env, daemon liveness (no secrets)."""
|
|
import common as _c
|
|
info = {
|
|
"data_dir": _c.DATA_DIR,
|
|
"accounts_file": _c.ACCOUNTS_FILE,
|
|
"state_file": _c.STATE_FILE,
|
|
"pid_file": _c.PID_FILE,
|
|
"log_file": _c.LOG_FILE,
|
|
"env": {
|
|
"TRIM_PKGVAR": os.environ.get("TRIM_PKGVAR", "<unset>"),
|
|
"TRIM_APPDEST": os.environ.get("TRIM_APPDEST", "<unset>"),
|
|
"AGENTCHECKIN_DEV": os.environ.get("AGENTCHECKIN_DEV", "<unset>"),
|
|
},
|
|
"file": __file__,
|
|
}
|
|
info["state_file_exists"] = os.path.exists(_c.STATE_FILE)
|
|
if info["state_file_exists"]:
|
|
st = _c.load_state()
|
|
info["state_daemon"] = st.get("daemon")
|
|
info["state_last_run"] = st.get("last_run")
|
|
info["pid_file_exists"] = os.path.exists(_c.PID_FILE)
|
|
info["daemon_alive"] = daemon_alive()
|
|
return info
|
|
|
|
|
|
ROUTES = {
|
|
("GET", "/api/state"): h_state,
|
|
("GET", "/api/debug"): h_debug,
|
|
("POST", "/api/checkin"): h_checkin,
|
|
("GET", "/api/accounts"): h_accounts,
|
|
("POST", "/api/accounts/add_wb_manual"): h_add_wb_manual,
|
|
("POST", "/api/accounts/add_trae"): h_add_trae,
|
|
("POST", "/api/accounts/add_qoder"): h_add_qoder,
|
|
("POST", "/api/accounts/update"): h_update_account,
|
|
("POST", "/api/accounts/delete"): h_delete_account,
|
|
("POST", "/api/accounts/fix_domain"): h_fix_qoder_domain,
|
|
("POST", "/api/accounts/test"): h_test_account,
|
|
("POST", "/api/wb_oauth_begin"): h_wb_oauth_begin,
|
|
("GET", "/api/wb_oauth_poll"): h_wb_oauth_poll,
|
|
("GET", "/api/settings"): h_settings_get,
|
|
("POST", "/api/settings"): h_settings_post,
|
|
("GET", "/api/logs"): h_logs,
|
|
}
|
|
|
|
|
|
def main():
|
|
env = cgi_env()
|
|
path = route_path(env["uri"])
|
|
params = query_params(env["query"])
|
|
handler = ROUTES.get((env["method"], path))
|
|
if not handler:
|
|
return error("not found: %s %s" % (env["method"], path), "404 Not Found")
|
|
body = read_body() if env["method"] == "POST" else {}
|
|
if body is None:
|
|
return error("payload too large")
|
|
try:
|
|
result = handler(params, body)
|
|
except Exception as e:
|
|
log("api error on %s %s: %s" % (env["method"], path, e), "ERROR")
|
|
return error("internal error: %s" % e, "500 Internal Server Error")
|
|
if isinstance(result, tuple) and len(result) == 2 and result[0] == "error":
|
|
return error(result[1])
|
|
respond(result if isinstance(result, dict) else {"data": result})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|