feat: AgentCheckin v1.2.2 — fnOS 应用:TRAE/WorkBuddy/Qoder 三平台每日积分自动签到
- 原生 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 前端回归 + 真实时钟调度测试
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
|
||||
# 本地开发/测试数据(含账号 Token,禁止提交)
|
||||
AgentCheckin/devdata/
|
||||
AgentCheckin/var/
|
||||
AgentCheckin/app/backend/*.tmp.*
|
||||
|
||||
# 打包产物(由 build.sh 生成)
|
||||
AgentCheckin/AgentCheckin.fpk
|
||||
|
||||
# 系统垃圾
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,386 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AgentCheckin shared utilities: paths, HTTP, atomic JSON storage, logging.
|
||||
|
||||
All persistent data lives under TRIM_PKGVAR (/vol{n}/@appdata/AgentCheckin).
|
||||
Falls back to a local ./data directory when running outside fnOS (dev/test).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import fcntl
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import ssl
|
||||
|
||||
APP_NAME = "AgentCheckin"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
APP_NAME = "AgentCheckin"
|
||||
|
||||
|
||||
def _volume_root():
|
||||
"""Volume root of the install layout.
|
||||
|
||||
@appcenter layout: /vol{n}/@appcenter/{app}/backend -> /vol{n}
|
||||
(three levels up from the backend directory)
|
||||
"""
|
||||
p = os.path.dirname(os.path.abspath(__file__))
|
||||
for _ in range(3):
|
||||
p = os.path.dirname(p)
|
||||
return p
|
||||
|
||||
|
||||
def _candidate_dirs():
|
||||
"""All plausible persistent-data locations across fnOS install layouts."""
|
||||
here = os.path.dirname(os.path.abspath(__file__)) # .../{app}/backend
|
||||
vol = _volume_root()
|
||||
cands = [
|
||||
# documented layout: /var/apps/{app}/var -> /vol{n}/@appdata/{app}
|
||||
"/var/apps/%s/var" % APP_NAME,
|
||||
# @appcenter layout: {volume}/@appcenter/{app} -> {volume}/@appdata/{app}
|
||||
os.path.join(vol, "@appdata", APP_NAME),
|
||||
# well-known volume data dirs (in case the app dir layout differs)
|
||||
"/vol1/@appdata/%s" % APP_NAME,
|
||||
"/vol2/@appdata/%s" % APP_NAME,
|
||||
# relative fallbacks for manual unpacking
|
||||
os.path.join(here, "..", "..", "var"),
|
||||
os.path.join(here, "..", "var"),
|
||||
]
|
||||
return [os.path.abspath(c) for c in cands]
|
||||
|
||||
|
||||
def _data_dir():
|
||||
# 1. explicit override (set by cmd/main and index.cgi so the daemon and
|
||||
# the CGI always agree on one location)
|
||||
explicit = os.environ.get("AGENTCHECKIN_DATA_DIR", "")
|
||||
if explicit:
|
||||
return os.path.abspath(explicit)
|
||||
if os.environ.get("AGENTCHECKIN_DEV"):
|
||||
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "devdata")
|
||||
return os.path.abspath(base)
|
||||
# 2. fnOS lifecycle env (set for cmd/main; often absent for CGI processes)
|
||||
env = os.environ.get("TRIM_PKGVAR", "")
|
||||
if env and os.path.isdir(env):
|
||||
return env
|
||||
# 3. first existing candidate
|
||||
for cand in _candidate_dirs():
|
||||
if os.path.isdir(cand):
|
||||
return cand
|
||||
# 4. nothing exists yet: use the @appdata convention on the same volume
|
||||
return os.path.abspath(os.path.join(_volume_root(), "@appdata", APP_NAME))
|
||||
|
||||
|
||||
DATA_DIR = _data_dir()
|
||||
ACCOUNTS_FILE = os.path.join(DATA_DIR, "accounts.json")
|
||||
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
||||
STATE_FILE = os.path.join(DATA_DIR, "state.json")
|
||||
TRIGGER_FILE = os.path.join(DATA_DIR, "trigger")
|
||||
PID_FILE = os.path.join(DATA_DIR, "daemon.pid")
|
||||
LOG_DIR = os.path.join(DATA_DIR, "logs")
|
||||
LOG_FILE = os.path.join(LOG_DIR, "checkin.log")
|
||||
|
||||
USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
|
||||
|
||||
_SSL_CTX = ssl.create_default_context()
|
||||
_SSL_CTX.check_hostname = False
|
||||
_SSL_CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
def ensure_dirs():
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Time helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def now_ts():
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def today_str():
|
||||
return time.strftime("%Y-%m-%d", time.localtime())
|
||||
|
||||
|
||||
def fmt_ts(ts):
|
||||
if not ts:
|
||||
return ""
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
|
||||
|
||||
|
||||
def fmt_now():
|
||||
return fmt_ts(now_ts())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging (append to checkin.log; never log tokens)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def log(msg, level="INFO"):
|
||||
line = "[%s] [%s] %s\n" % (fmt_now(), level, msg)
|
||||
try:
|
||||
ensure_dirs()
|
||||
# simple rotation: keep one previous generation, cap at ~2 MiB
|
||||
try:
|
||||
if os.path.exists(LOG_FILE) and os.path.getsize(LOG_FILE) > 2 * 1024 * 1024:
|
||||
os.replace(LOG_FILE, LOG_FILE + ".1")
|
||||
except OSError:
|
||||
pass
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
except OSError:
|
||||
pass
|
||||
return line
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def http_post_json(url, body=None, headers=None, timeout=20):
|
||||
"""POST JSON. Returns (data, http_status, error).
|
||||
|
||||
On success data is the parsed JSON (envelope or raw). On failure data is
|
||||
None and error describes the problem. HTTP error bodies that carry a
|
||||
{code,msg} envelope are surfaced through error as 'code=.. msg=..'.
|
||||
"""
|
||||
payload = json.dumps(body if body is not None else {}).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=payload, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Accept", "application/json")
|
||||
req.add_header("User-Agent", USER_AGENT)
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp:
|
||||
status = resp.status
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = ""
|
||||
try:
|
||||
raw = e.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
env = json.loads(raw)
|
||||
if isinstance(env, dict) and env.get("code", 0) != 0:
|
||||
return None, e.code, "code=%s msg=%s" % (env.get("code"), env.get("msg", ""))
|
||||
if isinstance(env, dict):
|
||||
return env, e.code, None
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
return None, e.code, "HTTP %s: %s" % (e.code, raw[:200])
|
||||
except urllib.error.URLError as e:
|
||||
return None, 0, "network error: %s" % (getattr(e, "reason", e))
|
||||
except Exception as e: # timeout, reset, ...
|
||||
return None, 0, "request failed: %s" % e
|
||||
|
||||
try:
|
||||
env = json.loads(raw)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None, status, "non-JSON response: %s" % raw[:200]
|
||||
return env, status, None
|
||||
|
||||
|
||||
def http_get(url, headers=None, timeout=20):
|
||||
"""GET. Returns (parsed_json_or_None, http_status, error)."""
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
req.add_header("Accept", "application/json")
|
||||
req.add_header("User-Agent", USER_AGENT)
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp:
|
||||
status = resp.status
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = ""
|
||||
try:
|
||||
raw = e.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
env = json.loads(raw)
|
||||
if isinstance(env, dict):
|
||||
return env, e.code, None
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
return None, e.code, "HTTP %s: %s" % (e.code, raw[:200])
|
||||
except urllib.error.URLError as e:
|
||||
return None, 0, "network error: %s" % (getattr(e, "reason", e))
|
||||
except Exception as e:
|
||||
return None, 0, "request failed: %s" % e
|
||||
try:
|
||||
return json.loads(raw), status, None
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None, status, "non-JSON response: %s" % raw[:200]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Atomic JSON storage with flock
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _locked(path, mode="r"):
|
||||
"""Open path with an exclusive flock held for the lifetime of the handle.
|
||||
|
||||
Returns (fileobj, lock_path). Caller must close the fileobj to release.
|
||||
"""
|
||||
lock_path = path + ".lock"
|
||||
lf = open(lock_path, "a+")
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
||||
if mode == "r" and not os.path.exists(path):
|
||||
return None, lf
|
||||
f = open(path, mode, encoding="utf-8")
|
||||
return f, lf
|
||||
|
||||
|
||||
def read_json(path, default):
|
||||
try:
|
||||
f, lf = _locked(path, "r")
|
||||
except OSError:
|
||||
return default
|
||||
try:
|
||||
if f is None:
|
||||
return default
|
||||
try:
|
||||
return json.load(f)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return default
|
||||
finally:
|
||||
if f is not None:
|
||||
f.close()
|
||||
lf.close()
|
||||
|
||||
|
||||
def write_json(path, obj, private=True):
|
||||
ensure_dirs()
|
||||
tmp = "%s.tmp.%d" % (path, os.getpid())
|
||||
data = json.dumps(obj, ensure_ascii=False, indent=2)
|
||||
f, lf = _locked(path, "a")
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as t:
|
||||
t.write(data)
|
||||
t.flush()
|
||||
os.fsync(t.fileno())
|
||||
os.replace(tmp, path)
|
||||
if private:
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
f.close()
|
||||
lf.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings / accounts / state accessors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"schedule_times": ["00:05", "09:00", "12:30", "19:00"],
|
||||
"webhook_url": "",
|
||||
"webhook_type": "auto", # auto | wecom | dingtalk | feishu | bark | generic
|
||||
"notify_on": ["fail"], # subset of ["success", "fail"]
|
||||
}
|
||||
|
||||
|
||||
def load_settings():
|
||||
s = read_json(SETTINGS_FILE, None)
|
||||
if not isinstance(s, dict):
|
||||
s = {}
|
||||
out = dict(DEFAULT_SETTINGS)
|
||||
out.update({k: v for k, v in s.items() if k in DEFAULT_SETTINGS})
|
||||
times = [t for t in out.get("schedule_times", []) if isinstance(t, str) and len(t) == 5 and t[2] == ":"]
|
||||
out["schedule_times"] = times or list(DEFAULT_SETTINGS["schedule_times"])
|
||||
notify = [n for n in out.get("notify_on", []) if n in ("success", "fail")]
|
||||
out["notify_on"] = notify
|
||||
return out
|
||||
|
||||
|
||||
def save_settings(settings):
|
||||
write_json(SETTINGS_FILE, settings)
|
||||
|
||||
|
||||
def load_accounts():
|
||||
data = read_json(ACCOUNTS_FILE, {"accounts": []})
|
||||
if isinstance(data, dict) and isinstance(data.get("accounts"), list):
|
||||
return data["accounts"]
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
|
||||
|
||||
def save_accounts(accounts):
|
||||
write_json(ACCOUNTS_FILE, {"accounts": accounts})
|
||||
|
||||
|
||||
def load_state():
|
||||
s = read_json(STATE_FILE, {})
|
||||
return s if isinstance(s, dict) else {}
|
||||
|
||||
|
||||
def save_state(state):
|
||||
write_json(STATE_FILE, state, private=False)
|
||||
|
||||
|
||||
def mask_token(tok):
|
||||
if not tok or not isinstance(tok, str):
|
||||
return ""
|
||||
if len(tok) <= 10:
|
||||
return "***"
|
||||
return tok[:6] + "***" + tok[-4:]
|
||||
|
||||
|
||||
def public_account(acc):
|
||||
"""Account view for the UI: never expose raw tokens."""
|
||||
a = dict(acc)
|
||||
auth = dict(acc.get("auth", {}))
|
||||
if "accessToken" in auth:
|
||||
auth["accessToken"] = mask_token(auth.get("accessToken"))
|
||||
if "refreshToken" in auth:
|
||||
auth["refreshToken"] = mask_token(auth.get("refreshToken"))
|
||||
a["auth"] = auth
|
||||
return a
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# tiny self-check helper
|
||||
print("data dir:", DATA_DIR)
|
||||
print("exists:", os.path.isdir(DATA_DIR))
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AgentCheckin scheduler daemon.
|
||||
|
||||
Responsibilities:
|
||||
* run check-ins at the configured daily times (default 00:05/09:00/12:30/19:00),
|
||||
each slot at most once per day; idempotent so missed slots self-heal
|
||||
* honour manual trigger files written by the CGI API ("run now")
|
||||
* keep state.json fresh (daemon liveness, next run time)
|
||||
* survive reboots: on start, if today has no run yet, run once (catch-up)
|
||||
|
||||
The daemon writes its own pidfile so cmd/main and the web UI can check it.
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from common import (DATA_DIR, PID_FILE, TRIGGER_FILE, load_settings, load_state,
|
||||
save_state, log, today_str, fmt_now, now_ts, fmt_ts,
|
||||
ensure_dirs)
|
||||
import engine
|
||||
|
||||
TICK_SECONDS = 15
|
||||
_running = True
|
||||
|
||||
|
||||
def _handle_signal(signum, frame):
|
||||
global _running
|
||||
_running = False
|
||||
|
||||
|
||||
def write_pidfile():
|
||||
ensure_dirs()
|
||||
with open(PID_FILE, "w") as f:
|
||||
f.write(str(os.getpid()))
|
||||
|
||||
|
||||
def remove_pidfile():
|
||||
try:
|
||||
if os.path.exists(PID_FILE):
|
||||
with open(PID_FILE) as f:
|
||||
if f.read().strip() == str(os.getpid()):
|
||||
os.unlink(PID_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def slot_today_ts(hhmm):
|
||||
"""Unix ts of today's HH:MM local time."""
|
||||
now = time.localtime()
|
||||
hh, mm = int(hhmm[:2]), int(hhmm[3:5])
|
||||
return int(time.mktime((now.tm_year, now.tm_mon, now.tm_mday, hh, mm, 0, 0, 0, -1)))
|
||||
|
||||
|
||||
def compute_next_run(settings):
|
||||
"""Next scheduled slot ts (today or tomorrow), or None."""
|
||||
times = settings.get("schedule_times") or []
|
||||
now = now_ts()
|
||||
candidates = []
|
||||
for hhmm in times:
|
||||
ts = slot_today_ts(hhmm)
|
||||
candidates.append(ts if ts > now else ts + 86400)
|
||||
return min(candidates) if candidates else None
|
||||
|
||||
|
||||
def tick():
|
||||
settings = load_settings()
|
||||
state = load_state()
|
||||
today = today_str()
|
||||
times = settings.get("schedule_times") or []
|
||||
slot_runs = state.get("slot_runs") or {}
|
||||
now = now_ts()
|
||||
|
||||
# 1) manual trigger
|
||||
if os.path.exists(TRIGGER_FILE):
|
||||
try:
|
||||
os.unlink(TRIGGER_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
log("trigger file detected -> running check-in")
|
||||
engine.run_all(reason="manual")
|
||||
return
|
||||
|
||||
# 2) catch-up: today has no run recorded at all (fresh boot / first install).
|
||||
# Mark every slot whose time has already passed as covered by this run.
|
||||
# NOTE: check key existence — an empty result dict is still "a run".
|
||||
if today not in (state.get("today") or {}):
|
||||
if times:
|
||||
log("no run recorded today -> startup catch-up")
|
||||
engine.run_all(reason="startup-catchup")
|
||||
state = load_state()
|
||||
slot_runs = state.setdefault("slot_runs", {})
|
||||
changed = False
|
||||
for hhmm in times:
|
||||
if now >= slot_today_ts(hhmm) and slot_runs.get(hhmm) != today:
|
||||
slot_runs[hhmm] = today
|
||||
changed = True
|
||||
if changed:
|
||||
save_state(state)
|
||||
return
|
||||
|
||||
# 3) scheduled slots (each at most once per day)
|
||||
for hhmm in times:
|
||||
ts = slot_today_ts(hhmm)
|
||||
if now >= ts and slot_runs.get(hhmm) != today:
|
||||
log("schedule slot %s due" % hhmm)
|
||||
engine.run_all(reason="schedule:%s" % hhmm)
|
||||
mark_slot_ran(hhmm)
|
||||
return
|
||||
|
||||
|
||||
def mark_slot_ran(hhmm):
|
||||
state = load_state()
|
||||
state.setdefault("slot_runs", {})[hhmm] = today_str()
|
||||
save_state(state)
|
||||
|
||||
|
||||
def update_liveness():
|
||||
state = load_state()
|
||||
settings = load_settings()
|
||||
prev = state.get("daemon") or {}
|
||||
# reset started_at when the pid changed (fresh daemon process)
|
||||
started_at = prev.get("started_at") if prev.get("pid") == os.getpid() else fmt_now()
|
||||
state["daemon"] = {
|
||||
"pid": os.getpid(),
|
||||
"started_at": started_at,
|
||||
"last_tick": fmt_now(),
|
||||
}
|
||||
nxt = compute_next_run(settings)
|
||||
state["next_run_at"] = fmt_ts(nxt) if nxt else None
|
||||
state["next_run_ts"] = nxt
|
||||
save_state(state)
|
||||
|
||||
|
||||
def main():
|
||||
signal.signal(signal.SIGTERM, _handle_signal)
|
||||
signal.signal(signal.SIGINT, _handle_signal)
|
||||
ensure_dirs()
|
||||
write_pidfile()
|
||||
log("daemon started (pid %d), data dir %s" % (os.getpid(), DATA_DIR))
|
||||
try:
|
||||
while _running:
|
||||
try:
|
||||
tick()
|
||||
except Exception as e:
|
||||
log("tick error: %s" % e, "ERROR")
|
||||
try:
|
||||
update_liveness()
|
||||
except Exception as e:
|
||||
log("liveness update failed: %s" % e, "ERROR")
|
||||
# sleep in small slices so SIGTERM is responsive
|
||||
for _ in range(TICK_SECONDS):
|
||||
if not _running:
|
||||
break
|
||||
time.sleep(1)
|
||||
finally:
|
||||
remove_pidfile()
|
||||
log("daemon stopped")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,472 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AgentCheckin engine: account orchestration, check-in runs, notifications."""
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from common import (load_accounts, save_accounts, load_settings, load_state,
|
||||
save_state, log, now_ts, today_str, fmt_ts, fmt_now,
|
||||
public_account, USER_AGENT, _SSL_CTX)
|
||||
import workbuddy
|
||||
import trae
|
||||
import qoder
|
||||
|
||||
# result statuses considered "handled" (no retry needed)
|
||||
DONE_STATUSES = {"ok", "ok_unverified", "already", "inactive"}
|
||||
|
||||
|
||||
def new_account_id(platform):
|
||||
return "%s_%s" % (platform, secrets.token_hex(4))
|
||||
|
||||
|
||||
def find_account(accounts, account_id):
|
||||
for acc in accounts:
|
||||
if acc.get("id") == account_id:
|
||||
return acc
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Account creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_workbuddy_manual(name, access_token, refresh_token="", domain="codebuddy.cn"):
|
||||
accounts = load_accounts()
|
||||
acc = {
|
||||
"id": new_account_id("wb"),
|
||||
"platform": "workbuddy",
|
||||
"name": name or "WorkBuddy 账号",
|
||||
"enabled": True,
|
||||
"created_at": fmt_now(),
|
||||
"auth": {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
"expiresAt": 0,
|
||||
"domain": domain,
|
||||
},
|
||||
"account": {},
|
||||
"last_result": None,
|
||||
}
|
||||
accounts.append(acc)
|
||||
save_accounts(accounts)
|
||||
log("added workbuddy account %s (%s)" % (acc["id"], acc["name"]))
|
||||
return acc
|
||||
|
||||
|
||||
def add_workbuddy_oauth(payload):
|
||||
"""payload from workbuddy.poll_oauth(): {accessToken, refreshToken, ...}."""
|
||||
accounts = load_accounts()
|
||||
acct_info = payload.get("account") or {}
|
||||
domain = payload.get("domain") or "codebuddy.cn"
|
||||
acc = {
|
||||
"id": new_account_id("wb"),
|
||||
"platform": "workbuddy",
|
||||
"name": acct_info.get("nickname") or "WorkBuddy 账号",
|
||||
"enabled": True,
|
||||
"created_at": fmt_now(),
|
||||
"auth": {
|
||||
"accessToken": payload.get("accessToken", ""),
|
||||
"refreshToken": payload.get("refreshToken", ""),
|
||||
"expiresAt": payload.get("expiresAt") or 0,
|
||||
"domain": domain,
|
||||
},
|
||||
"account": {
|
||||
"uid": acct_info.get("uid", ""),
|
||||
"nickname": acct_info.get("nickname", ""),
|
||||
"enterpriseId": acct_info.get("enterpriseId", ""),
|
||||
},
|
||||
"last_result": None,
|
||||
}
|
||||
accounts.append(acc)
|
||||
save_accounts(accounts)
|
||||
log("added workbuddy account via oauth: %s (%s)" % (acc["id"], acc["name"]))
|
||||
return acc
|
||||
|
||||
|
||||
def add_trae(name, access_token, refresh_token="", device_id_str=""):
|
||||
accounts = load_accounts()
|
||||
acc = {
|
||||
"id": new_account_id("trae"),
|
||||
"platform": "trae",
|
||||
"name": name or "TRAE 账号",
|
||||
"enabled": True,
|
||||
"created_at": fmt_now(),
|
||||
"auth": {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
},
|
||||
"device_id": device_id_str or trae.stable_device_id(new_account_id("trae")),
|
||||
"last_result": None,
|
||||
}
|
||||
accounts.append(acc)
|
||||
save_accounts(accounts)
|
||||
log("added trae account %s (%s)" % (acc["id"], acc["name"]))
|
||||
return acc
|
||||
|
||||
|
||||
def add_qoder(name, access_token, refresh_token="", device=None, domain="openapi.qoder.com.cn"):
|
||||
accounts = load_accounts()
|
||||
acc = {
|
||||
"id": new_account_id("qoder"),
|
||||
"platform": "qoder",
|
||||
"name": name or "Qoder 账号",
|
||||
"enabled": True,
|
||||
"created_at": fmt_now(),
|
||||
"auth": {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
"expiresAt": 0,
|
||||
"domain": domain,
|
||||
},
|
||||
"device": {k: str(v) for k, v in (device or {}).items()
|
||||
if k in qoder.DEVICE_DEFAULTS},
|
||||
"last_result": None,
|
||||
}
|
||||
accounts.append(acc)
|
||||
save_accounts(accounts)
|
||||
log("added qoder account %s (%s)" % (acc["id"], acc["name"]))
|
||||
return acc
|
||||
|
||||
|
||||
def delete_account(account_id):
|
||||
accounts = load_accounts()
|
||||
left = [a for a in accounts if a.get("id") != account_id]
|
||||
save_accounts(left)
|
||||
return len(left) != len(accounts)
|
||||
|
||||
|
||||
def update_account(account_id, **fields):
|
||||
accounts = load_accounts()
|
||||
acc = find_account(accounts, account_id)
|
||||
if not acc:
|
||||
return None
|
||||
for k, v in fields.items():
|
||||
if k in ("name", "enabled"):
|
||||
acc[k] = v
|
||||
elif k == "accessToken" and v:
|
||||
acc.setdefault("auth", {})["accessToken"] = v
|
||||
elif k == "refreshToken" and v:
|
||||
acc.setdefault("auth", {})["refreshToken"] = v
|
||||
elif k == "domain" and v:
|
||||
acc.setdefault("auth", {})["domain"] = v
|
||||
save_accounts(accounts)
|
||||
return acc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check-in runs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_account(acc, force=False):
|
||||
"""Run check-in for one account. Returns result dict."""
|
||||
platform = acc.get("platform")
|
||||
started = fmt_now()
|
||||
if platform == "workbuddy":
|
||||
result = _run_workbuddy(acc)
|
||||
elif platform == "trae":
|
||||
result = trae.checkin_once(acc)
|
||||
elif platform == "qoder":
|
||||
result = qoder.checkin_once(acc)
|
||||
else:
|
||||
result = {"status": "fail", "message": "未知平台 %s" % platform, "points": 0}
|
||||
result["time"] = started
|
||||
result["finished_at"] = fmt_now()
|
||||
|
||||
acc["last_result"] = result
|
||||
acc["last_run"] = now_ts()
|
||||
if result.get("status") == "no_session":
|
||||
acc["need_reauth"] = True
|
||||
elif result.get("status") in DONE_STATUSES:
|
||||
acc["need_reauth"] = False
|
||||
_reload(acc)
|
||||
return result
|
||||
|
||||
|
||||
def _reload(acc):
|
||||
"""Persist the mutated account back into the accounts file."""
|
||||
accounts = load_accounts()
|
||||
for i, a in enumerate(accounts):
|
||||
if a.get("id") == acc.get("id"):
|
||||
accounts[i] = acc
|
||||
break
|
||||
else:
|
||||
accounts.append(acc)
|
||||
save_accounts(accounts)
|
||||
return accounts
|
||||
|
||||
|
||||
def _run_workbuddy(acc):
|
||||
auth = acc.get("auth", {})
|
||||
if not auth.get("accessToken"):
|
||||
return {"status": "no_auth", "message": "缺少 accessToken,请重新授权", "points": 0}
|
||||
if workbuddy.is_global(auth):
|
||||
return {"status": "inactive", "message": "Global(workbuddy.ai)账号不支持每日签到", "points": 0}
|
||||
# refresh token when close to expiry
|
||||
if workbuddy.token_expiring(auth) and auth.get("refreshToken"):
|
||||
ok, msg = workbuddy.refresh_token(acc)
|
||||
if not ok:
|
||||
log("workbuddy %s token refresh: %s" % (acc.get("id"), msg), "WARN")
|
||||
if "失效" in msg or "过期" in msg:
|
||||
return {"status": "no_session", "message": msg, "points": 0}
|
||||
else:
|
||||
_reload(acc)
|
||||
# status first (idempotent)
|
||||
checked, detail, err = workbuddy.query_status(acc)
|
||||
if err == "no_session":
|
||||
return {"status": "no_session", "message": "登录态已失效,请重新授权", "points": 0}
|
||||
if checked is True:
|
||||
credits = None
|
||||
if detail:
|
||||
credits = detail.get("total_credits")
|
||||
if credits is None:
|
||||
summary, _ = workbuddy.fetch_credits(acc)
|
||||
credits = (summary or {}).get("remain")
|
||||
return {"status": "already", "message": "今日已签到", "points": 0,
|
||||
"credits": credits, "detail": detail or {}}
|
||||
# claim
|
||||
status, message, points = workbuddy.perform_checkin(acc)
|
||||
if status == "no_session":
|
||||
return {"status": "no_session", "message": message, "points": 0}
|
||||
if status == "ok":
|
||||
# verify
|
||||
for _ in range(3):
|
||||
time.sleep(2)
|
||||
v_checked, v_detail, v_err = workbuddy.query_status(acc)
|
||||
if v_err == "no_session":
|
||||
break
|
||||
if v_checked is True:
|
||||
credits = None
|
||||
if v_detail:
|
||||
credits = v_detail.get("total_credits")
|
||||
if credits is None:
|
||||
summary, _ = workbuddy.fetch_credits(acc)
|
||||
credits = (summary or {}).get("remain")
|
||||
return {"status": "ok", "message": "签到成功(已回查确认)",
|
||||
"points": points, "credits": credits, "detail": v_detail or {}}
|
||||
if v_checked is False:
|
||||
break
|
||||
return {"status": "ok_unverified", "message": "领取成功(+%s)但回查未确认" % points,
|
||||
"points": points}
|
||||
return {"status": status, "message": message, "points": points}
|
||||
|
||||
|
||||
def run_all(account_id=None, reason="manual"):
|
||||
"""Run check-in for all enabled accounts (or one). Returns summary dict."""
|
||||
accounts = load_accounts()
|
||||
targets = [a for a in accounts if a.get("enabled")]
|
||||
if account_id:
|
||||
targets = [a for a in targets if a.get("id") == account_id]
|
||||
results = {}
|
||||
for acc in targets:
|
||||
try:
|
||||
res = run_account(acc)
|
||||
except Exception as e: # never let one account kill the run
|
||||
res = {"status": "fail", "message": "内部错误:%s" % e, "points": 0,
|
||||
"time": fmt_now(), "finished_at": fmt_now()}
|
||||
acc["last_result"] = res
|
||||
_reload(acc)
|
||||
results[acc["id"]] = {
|
||||
"platform": acc.get("platform"),
|
||||
"name": acc.get("name"),
|
||||
"status": res.get("status"),
|
||||
"message": res.get("message"),
|
||||
"points": res.get("points", 0),
|
||||
"credits": res.get("credits"),
|
||||
}
|
||||
log("checkin[%s] %s (%s): %s %s" % (reason, acc.get("id"), acc.get("platform"),
|
||||
res.get("status"), res.get("message")))
|
||||
# record today's summary in state (always, even with zero accounts, so the
|
||||
# daemon's startup catch-up does not repeat when no accounts exist)
|
||||
state = load_state()
|
||||
today = today_str()
|
||||
state.setdefault("today", {})[today] = results
|
||||
state["last_run"] = fmt_now()
|
||||
state["last_run_reason"] = reason
|
||||
# prune history: keep the last 30 days
|
||||
days = sorted(state.get("today", {}).keys())
|
||||
for old in days[:-30]:
|
||||
state["today"].pop(old, None)
|
||||
save_state(state)
|
||||
if results:
|
||||
notify(results, reason)
|
||||
return results
|
||||
|
||||
|
||||
def test_account(account_id):
|
||||
"""Query status + credits without claiming."""
|
||||
accounts = load_accounts()
|
||||
acc = find_account(accounts, account_id)
|
||||
if not acc:
|
||||
return {"error": "账号不存在"}
|
||||
out = {"id": account_id, "platform": acc.get("platform")}
|
||||
try:
|
||||
if acc.get("platform") == "workbuddy":
|
||||
if workbuddy.token_expiring(acc.get("auth", {})) and acc["auth"].get("refreshToken"):
|
||||
ok, msg = workbuddy.refresh_token(acc)
|
||||
_reload(acc)
|
||||
out["refresh"] = msg
|
||||
checked, detail, err = workbuddy.query_status(acc)
|
||||
out["checked_in"] = checked
|
||||
out["error"] = err
|
||||
summary, cerr = workbuddy.fetch_credits(acc)
|
||||
if summary:
|
||||
out["credits"] = summary
|
||||
if cerr and cerr != "no_session":
|
||||
out["credits_error"] = cerr
|
||||
elif acc.get("platform") == "trae":
|
||||
checked, enable, credits, err = trae.query_status(acc)
|
||||
out["checked_in"] = checked
|
||||
out["enable"] = enable
|
||||
out["credits"] = credits
|
||||
out["error"] = err
|
||||
elif acc.get("platform") == "qoder":
|
||||
ok, msg = qoder.ensure_token(acc)
|
||||
_reload(acc)
|
||||
if ok:
|
||||
out["refresh"] = msg
|
||||
# 探测两个站点,判断 token 属于哪个域(两站 Token 不互通)
|
||||
probe = qoder.probe_domains(acc)
|
||||
out["domain_probe"] = probe
|
||||
current = (acc.get("auth") or {}).get("domain")
|
||||
ok_domain = None
|
||||
for dom, r in probe.items():
|
||||
if r.get("auth"):
|
||||
ok_domain = dom
|
||||
out["token_domain"] = ok_domain
|
||||
out["domain_match"] = (ok_domain == current) if ok_domain else None
|
||||
if ok_domain and ok_domain != current:
|
||||
out["hint"] = "Token 属于 %s,但当前域名是 %s,请切换" % (ok_domain, current)
|
||||
campaigns, err = qoder.query_campaigns(acc)
|
||||
if err:
|
||||
out["error"] = err
|
||||
else:
|
||||
benefits = qoder._benefits(campaigns)
|
||||
claimable = [c for c in benefits if c.get("claimStatus") == "CLAIMABLE"]
|
||||
out["claimable"] = len(claimable)
|
||||
out["campaigns"] = len(benefits)
|
||||
amounts = []
|
||||
for c in claimable:
|
||||
b = c.get("benefit") or {}
|
||||
if isinstance(b, dict) and b.get("amount"):
|
||||
amounts.append(b["amount"])
|
||||
if amounts:
|
||||
out["credits"] = sum(amounts)
|
||||
except Exception as e:
|
||||
out["error"] = str(e)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notifications (multi-platform webhook)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WEBHOOK_TYPES = ("auto", "wecom", "dingtalk", "feishu", "bark", "generic")
|
||||
|
||||
|
||||
def _detect_webhook_type(url):
|
||||
u = url.lower()
|
||||
if "qyapi.weixin.qq.com" in u:
|
||||
return "wecom"
|
||||
if "oapi.dingtalk.com" in u:
|
||||
return "dingtalk"
|
||||
if "open.feishu.cn" in u or "open.larkoffice.com" in u:
|
||||
return "feishu"
|
||||
if "api.day.app" in u or "bark" in u:
|
||||
return "bark"
|
||||
return "generic"
|
||||
|
||||
|
||||
def notify_payload(wtype, url, title, content):
|
||||
"""Build the platform-correct request. Returns (final_url, body_or_None).
|
||||
|
||||
body=None means a plain GET (Bark URL-path API).
|
||||
"""
|
||||
if wtype == "auto" or wtype not in WEBHOOK_TYPES:
|
||||
wtype = _detect_webhook_type(url)
|
||||
text = title + "\n" + content
|
||||
if wtype in ("wecom", "dingtalk"):
|
||||
# 企业微信 / 钉钉自定义机器人: {"msgtype":"text","text":{"content":...}}
|
||||
return url, json.dumps({"msgtype": "text", "text": {"content": text}},
|
||||
ensure_ascii=False).encode("utf-8")
|
||||
if wtype == "feishu":
|
||||
# 飞书自定义机器人: {"msg_type":"text","content":{"text":...}}
|
||||
return url, json.dumps({"msg_type": "text", "content": {"text": text}},
|
||||
ensure_ascii=False).encode("utf-8")
|
||||
if wtype == "bark":
|
||||
# Bark: <base>/<title>/<body>(URL 路径传参,GET 即可)
|
||||
import urllib.parse
|
||||
base = url.rstrip("/")
|
||||
return "%s/%s/%s" % (base, urllib.parse.quote(title), urllib.parse.quote(content)), None
|
||||
# Server酱 / PushPlus / 通用: 尽量多带常见字段
|
||||
return url, json.dumps({"title": title, "content": content, "text": text,
|
||||
"msgtype": "text", "desp": content},
|
||||
ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def notify(results, reason):
|
||||
settings = load_settings()
|
||||
url = (settings.get("webhook_url") or "").strip()
|
||||
if not url:
|
||||
return
|
||||
notify_on = settings.get("notify_on") or []
|
||||
has_fail = any(r["status"] in ("fail", "no_session", "no_auth", "retry_later")
|
||||
for r in results.values())
|
||||
has_success = any(r["status"] in ("ok", "ok_unverified") for r in results.values())
|
||||
if has_fail and "fail" not in notify_on:
|
||||
if not (has_success and "success" in notify_on):
|
||||
return
|
||||
if not has_fail and not has_success:
|
||||
return
|
||||
if not has_fail and has_success and "success" not in notify_on:
|
||||
return
|
||||
|
||||
lines = []
|
||||
for r in results.values():
|
||||
mark = {"ok": "✅", "ok_unverified": "✅", "already": "☑️",
|
||||
"inactive": "➖", "fail": "❌", "no_session": "🔒",
|
||||
"no_auth": "🔒", "retry_later": "⏳"}.get(r["status"], "•")
|
||||
lines.append("%s %s %s:%s" % (mark, r.get("platform", ""), r.get("name", ""), r.get("message", "")))
|
||||
content = "\n".join(lines)
|
||||
title = "Agent 签到结果(%s)" % today_str()
|
||||
wtype = settings.get("webhook_type") or "auto"
|
||||
final_url, body = notify_payload(wtype, url, title, content)
|
||||
try:
|
||||
req = urllib.request.Request(final_url, data=body, method="POST" if body else "GET")
|
||||
if body:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("User-Agent", USER_AGENT)
|
||||
with urllib.request.urlopen(req, timeout=15, context=_SSL_CTX) as resp:
|
||||
resp.read()
|
||||
log("webhook notification sent via %s (%d bytes)" % (wtype, len(body or 0)))
|
||||
except Exception as e:
|
||||
log("webhook notification failed (%s): %s" % (wtype, e), "WARN")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def dashboard():
|
||||
accounts = load_accounts()
|
||||
settings = load_settings()
|
||||
state = load_state()
|
||||
today = today_str()
|
||||
today_results = (state.get("today") or {}).get(today, {})
|
||||
items = []
|
||||
for acc in accounts:
|
||||
item = public_account(acc)
|
||||
item["today"] = today_results.get(acc.get("id"))
|
||||
item["need_reauth"] = bool(acc.get("need_reauth"))
|
||||
item["disabled"] = bool(acc.get("disabled"))
|
||||
items.append(item)
|
||||
return {
|
||||
"accounts": items,
|
||||
"settings": settings,
|
||||
"today": today,
|
||||
"today_results": today_results,
|
||||
"last_run": state.get("last_run"),
|
||||
"last_run_reason": state.get("last_run_reason"),
|
||||
"next_run_at": state.get("next_run_at"),
|
||||
"daemon": state.get("daemon"),
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Qoder daily check-in client (CN edition).
|
||||
|
||||
Endpoints (reverse-engineered from the Qoder desktop client, see community
|
||||
projects wallechfox/qoder-checkin and L0NE-6/Qoder-Daily):
|
||||
|
||||
CN base https://openapi.qoder.com.cn
|
||||
Global base https://openapi.qoder.sh
|
||||
|
||||
campaigns GET /sash/api/v1/me/campaigns
|
||||
claim POST /sash/api/v1/me/campaigns/{cid}/claim
|
||||
refresh POST /api/v1/deviceToken/refresh body {"refresh_token": ...}
|
||||
|
||||
Auth: Authorization: Bearer <accessToken> plus Cosy-* device headers
|
||||
(machineId / machineToken / machineCode / machineType / clientType ...).
|
||||
|
||||
Campaign shape: campaigns[] entries with actionType == "CLAIM_BENEFIT" and
|
||||
claimStatus in ("CLAIMABLE", "CLAIMED"); benefit.amount is the credit grant.
|
||||
|
||||
Result codes used across the app: ok / already / fail / no_session /
|
||||
retry_later (activity not released yet — Qoder activities are not published
|
||||
exactly at midnight, so multi-slot retries matter).
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
from common import http_post_json, http_get, log, now_ts
|
||||
|
||||
CN_BASE = "https://openapi.qoder.com.cn"
|
||||
GLOBAL_BASE = "https://openapi.qoder.sh"
|
||||
PATH_CAMPAIGNS = "/sash/api/v1/me/campaigns"
|
||||
PATH_CLAIM = "/sash/api/v1/me/campaigns/%s/claim"
|
||||
PATH_REFRESH = "/api/v1/deviceToken/refresh"
|
||||
|
||||
REFRESH_MARGIN = 72 * 3600 # refresh 72h before expiry (client behaviour)
|
||||
|
||||
DEVICE_DEFAULTS = {
|
||||
"clientType": "10",
|
||||
"version": "", # 不默认发送,用户可在设备标识里按客户端实际版本填
|
||||
"machineOS": "",
|
||||
"machineHostname": "",
|
||||
"machineId": "",
|
||||
"machineToken": "",
|
||||
"machineCode": "",
|
||||
"machineType": "",
|
||||
"version": "",
|
||||
}
|
||||
|
||||
|
||||
def is_global(auth):
|
||||
return "qoder.sh" in (auth.get("domain") or "").lower()
|
||||
|
||||
|
||||
def billing_base(auth):
|
||||
return GLOBAL_BASE if is_global(auth) else CN_BASE
|
||||
|
||||
|
||||
def device_headers(account):
|
||||
"""Cosy-* device headers from the account's device block."""
|
||||
dev = dict(DEVICE_DEFAULTS)
|
||||
dev.update({k: v for k, v in (account.get("device") or {}).items() if k in DEVICE_DEFAULTS})
|
||||
return {
|
||||
"Cosy-ClientType": str(dev["clientType"] or "10"),
|
||||
"Cosy-MachineOS": str(dev["machineOS"] or ""),
|
||||
"Cosy-MachineHostname": str(dev["machineHostname"] or ""),
|
||||
"Cosy-MachineId": str(dev["machineId"] or ""),
|
||||
"Cosy-MachineToken": str(dev["machineToken"] or ""),
|
||||
"Cosy-MachineCode": str(dev["machineCode"] or ""),
|
||||
"Cosy-MachineType": str(dev["machineType"] or ""),
|
||||
"Cosy-Version": str(dev["version"] or ""),
|
||||
}
|
||||
|
||||
|
||||
def _headers(account):
|
||||
h = {"Accept": "application/json", "Accept-Language": "zh-CN", "User-Agent": "Qoder"}
|
||||
# only send device headers that actually have values (empty values can
|
||||
# trip server-side device validation)
|
||||
for k, v in device_headers(account).items():
|
||||
if v:
|
||||
h[k] = v
|
||||
token = (account.get("auth") or {}).get("accessToken", "")
|
||||
if token:
|
||||
h["Authorization"] = "Bearer %s" % token
|
||||
return h
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _b64_json(seg):
|
||||
seg += "=" * (-len(seg) % 4)
|
||||
try:
|
||||
return json.loads(base64.urlsafe_b64decode(seg).decode("utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def jwt_exp(token):
|
||||
parts = str(token or "").split(".")
|
||||
if len(parts) != 3:
|
||||
return 0
|
||||
try:
|
||||
return int(_b64_json(parts[1]).get("exp") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def jwt_uid(token):
|
||||
parts = str(token or "").split(".")
|
||||
if len(parts) != 3:
|
||||
return ""
|
||||
data = _b64_json(parts[1])
|
||||
for k in ("sub", "uid", "user_id", "userId", "id"):
|
||||
if data.get(k):
|
||||
return str(data[k])
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def token_expiring(account, margin=REFRESH_MARGIN):
|
||||
auth = account.get("auth") or {}
|
||||
at = auth.get("accessToken") or ""
|
||||
if not at:
|
||||
return True
|
||||
exp = auth.get("expiresAt") or jwt_exp(at)
|
||||
if not exp:
|
||||
return False
|
||||
return now_ts() + margin > exp
|
||||
|
||||
|
||||
def refresh_token(account):
|
||||
"""Refresh access token in place. Returns (ok, message)."""
|
||||
rt = (account.get("auth") or {}).get("refreshToken", "")
|
||||
if not rt:
|
||||
return False, "未配置 refreshToken,无法自动续期"
|
||||
# jrt- 前缀的刷新串走 jobToken 端点(社区实现验证)
|
||||
path = "/api/v1/jobToken/refresh" if rt.startswith("jrt-") else PATH_REFRESH
|
||||
data, status, err = http_post_json(
|
||||
billing_base(account.get("auth") or {}) + path,
|
||||
body={"refresh_token": rt},
|
||||
headers={"User-Agent": "qoder-local-proxy", "Accept": "application/json"})
|
||||
if status != 200 or not isinstance(data, dict):
|
||||
detail = ""
|
||||
if isinstance(data, dict):
|
||||
detail = data.get("errorMessage") or data.get("message") or data.get("msg") or ""
|
||||
return False, "续期失败(HTTP %s)%s%s" % (status, (":" + str(detail)[:80]) if detail else "", (" " + str(err)[:80]) if err and not detail else "")
|
||||
# 响应可能是 {"data": {...}} 嵌套,也可能是平铺
|
||||
payload = data.get("data") if isinstance(data.get("data"), dict) else data
|
||||
tok = None
|
||||
for k in ("device_token", "deviceToken", "token", "accessToken", "access_token"):
|
||||
if payload.get(k):
|
||||
tok = payload[k]
|
||||
break
|
||||
if not tok:
|
||||
return False, "续期响应缺少 token(HTTP %s)" % status
|
||||
auth = account.setdefault("auth", {})
|
||||
auth["accessToken"] = tok
|
||||
new_rt = None
|
||||
for k in ("device_refresh_token", "refresh_token", "refreshToken"):
|
||||
if payload.get(k):
|
||||
new_rt = payload[k]
|
||||
break
|
||||
if new_rt:
|
||||
auth["refreshToken"] = new_rt
|
||||
exp = jwt_exp(tok)
|
||||
if exp:
|
||||
auth["expiresAt"] = exp
|
||||
if not account.get("uid"):
|
||||
account["uid"] = jwt_uid(tok)
|
||||
return True, "已续期"
|
||||
|
||||
|
||||
def ensure_token(account, force=False):
|
||||
if not force and not token_expiring(account):
|
||||
return True, ""
|
||||
return refresh_token(account)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Campaigns / claim
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _api(account, path, method="GET", body=None):
|
||||
return http_post_json(billing_base(account.get("auth") or {}) + path,
|
||||
body=body, headers=_headers(account)) if method == "POST" \
|
||||
else http_get(billing_base(account.get("auth") or {}) + path, headers=_headers(account))
|
||||
|
||||
|
||||
def query_campaigns(account):
|
||||
"""Returns (campaigns, error). campaigns is a list (possibly empty)."""
|
||||
data, status, err = _api(account, PATH_CAMPAIGNS + "?forceRefresh=true", "GET")
|
||||
if status == 401:
|
||||
return None, "no_session"
|
||||
if not isinstance(data, dict) or status != 200:
|
||||
detail = ""
|
||||
if isinstance(data, dict):
|
||||
detail = data.get("message") or data.get("errorMessage") or ""
|
||||
return None, "查询失败(HTTP %s)%s" % (status, (":" + str(detail)[:80]) if detail else "")
|
||||
campaigns = data.get("campaigns")
|
||||
return (campaigns if isinstance(campaigns, list) else []), None
|
||||
|
||||
|
||||
def _benefits(campaigns):
|
||||
"""CLAIM_BENEFIT campaigns; prefer kind == CREDITS when the field exists."""
|
||||
out = []
|
||||
for c in campaigns:
|
||||
if not (isinstance(c, dict) and c.get("actionType") == "CLAIM_BENEFIT"):
|
||||
continue
|
||||
benefit = c.get("benefit")
|
||||
if isinstance(benefit, dict) and benefit.get("kind") and benefit["kind"] != "CREDITS":
|
||||
continue
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
USAGE_PATH = "/sash/api/v2/me/usage"
|
||||
|
||||
|
||||
def query_balance(account):
|
||||
"""Remaining credits (userQuota + addOnQuota). Returns (total, error)."""
|
||||
data, status, err = _api(account, USAGE_PATH, "GET")
|
||||
if status == 401:
|
||||
return None, "no_session"
|
||||
if not isinstance(data, dict) or status != 200:
|
||||
return None, err
|
||||
usage = data.get("qoderUsage")
|
||||
if not isinstance(usage, dict):
|
||||
return None, None
|
||||
total = 0
|
||||
for key in ("userQuota", "addOnQuota"):
|
||||
q = usage.get(key)
|
||||
if isinstance(q, dict) and isinstance(q.get("remaining"), (int, float)):
|
||||
total += q["remaining"]
|
||||
return total, None
|
||||
|
||||
|
||||
def _claim_one(account, cid):
|
||||
data, status, err = _api(account, PATH_CLAIM % cid, "POST", body={})
|
||||
if status == 401:
|
||||
return None, "no_session"
|
||||
d = data.get("data") if isinstance(data, dict) and isinstance(data.get("data"), dict) else data
|
||||
if status == 200 and isinstance(d, dict) and d.get("status") == "CLAIMED":
|
||||
amt = 0
|
||||
if isinstance(d.get("benefit"), dict):
|
||||
amt = d["benefit"].get("amount") or 0
|
||||
return amt, None
|
||||
return 0, "领取失败(HTTP %s)" % status
|
||||
|
||||
|
||||
def _token_diag(account):
|
||||
auth = account.get("auth") or {}
|
||||
at = auth.get("accessToken") or ""
|
||||
rt = auth.get("refreshToken") or ""
|
||||
return "(accessToken %d 字符 / refreshToken %d 字符)" % (len(at), len(rt))
|
||||
|
||||
|
||||
def checkin_once(account):
|
||||
"""Full single-account check-in. Returns a result dict."""
|
||||
auth = account.get("auth") or {}
|
||||
if not auth.get("accessToken") and not auth.get("refreshToken"):
|
||||
return {"status": "no_auth", "message": "缺少 accessToken / refreshToken,请重新添加账号",
|
||||
"points": 0}
|
||||
# 注意:Qoder 的 dt-/drt- Token 原生就只有 27/28 字符(社区实测确认),
|
||||
# 这里只拦截明显异常短的值。
|
||||
if auth.get("accessToken") and len(auth["accessToken"]) < 10:
|
||||
return {"status": "no_auth",
|
||||
"message": "accessToken 仅 %d 字符,明显不完整,请重新复制" % len(auth["accessToken"]),
|
||||
"points": 0}
|
||||
if auth.get("refreshToken") and len(auth["refreshToken"]) < 10:
|
||||
return {"status": "no_auth",
|
||||
"message": "refreshToken 仅 %d 字符,明显不完整,请重新复制" % len(auth["refreshToken"]),
|
||||
"points": 0}
|
||||
|
||||
ok, msg = ensure_token(account)
|
||||
if not ok:
|
||||
# maybe the access token is still valid even though refresh failed
|
||||
at = auth.get("accessToken") or ""
|
||||
exp = auth.get("expiresAt") or jwt_exp(at)
|
||||
if not (at and (not exp or exp > now_ts())):
|
||||
return {"status": "no_session", "message": msg, "points": 0}
|
||||
|
||||
campaigns, err = query_campaigns(account)
|
||||
if err == "no_session":
|
||||
ok2, msg2 = ensure_token(account, force=True)
|
||||
if ok2:
|
||||
campaigns, err = query_campaigns(account)
|
||||
if err == "no_session":
|
||||
dom = (account.get("auth") or {}).get("domain") or CN_BASE
|
||||
other = GLOBAL_BASE if "com.cn" in dom else CN_BASE
|
||||
return {"status": "no_session",
|
||||
"message": "认证失败(401)。两个站点 Token 不互通——若这是国际版 Token,"
|
||||
"请把域名改为 %s(当前 %s)%s"
|
||||
% (other, dom, _token_diag(account)),
|
||||
"points": 0}
|
||||
if err:
|
||||
return {"status": "fail", "message": "续期后查询仍失败:%s" % err, "points": 0}
|
||||
if err:
|
||||
return {"status": "fail", "message": err, "points": 0}
|
||||
|
||||
balance, berr = query_balance(account)
|
||||
if berr == "no_session":
|
||||
return {"status": "no_session",
|
||||
"message": "Token 已过期,请重新获取 Token" + _token_diag(account), "points": 0}
|
||||
if berr:
|
||||
balance = None
|
||||
|
||||
benefits = _benefits(campaigns)
|
||||
claimable = [c for c in benefits if c.get("claimStatus") == "CLAIMABLE"]
|
||||
|
||||
if claimable:
|
||||
got, fail = 0.0, 0
|
||||
for c in claimable:
|
||||
cid = str(c.get("campaignId") or "")
|
||||
amt, cerr = _claim_one(account, cid)
|
||||
if cerr == "no_session":
|
||||
return {"status": "no_session",
|
||||
"message": "登录态已失效,请重新提取 Token", "points": 0}
|
||||
if cerr:
|
||||
fail += 1
|
||||
else:
|
||||
try:
|
||||
got += float(amt or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if fail:
|
||||
return {"status": "fail",
|
||||
"message": "%d/%d 个活动领取失败" % (fail, len(claimable)), "points": 0}
|
||||
return {"status": "ok", "message": "签到成功 +%s Credits" % int(got),
|
||||
"points": int(got), "credits": balance}
|
||||
|
||||
# nothing claimable: already claimed today / activity not released / none
|
||||
now = now_ts()
|
||||
claimed_today = [c for c in benefits
|
||||
if c.get("claimStatus") == "CLAIMED"
|
||||
and (c.get("startAt") or 0) <= now < (c.get("endAt") or 0)]
|
||||
if claimed_today:
|
||||
return {"status": "already", "message": "今日已领取", "points": 0,
|
||||
"credits": balance}
|
||||
if not benefits:
|
||||
return {"status": "already", "message": "活动列表为空(活动结束或未开放)", "points": 0}
|
||||
return {"status": "retry_later", "message": "活动未下发,下个时段再试", "points": 0}
|
||||
|
||||
|
||||
def probe_domains(account):
|
||||
"""Probe both CN/Global with the current token.
|
||||
|
||||
Returns {domain: {"status": http_status, "auth": bool}} — a 404 means the
|
||||
token authenticated (path simply doesn't exist), 401 means rejected.
|
||||
"""
|
||||
out = {}
|
||||
for dom, base in ((CN_BASE, CN_BASE), (GLOBAL_BASE, GLOBAL_BASE)):
|
||||
acc = dict(account)
|
||||
acc["auth"] = dict(account.get("auth") or {})
|
||||
acc["auth"]["domain"] = dom
|
||||
try:
|
||||
data, status, err = _api(acc, PATH_CAMPAIGNS, "GET")
|
||||
except Exception as e:
|
||||
out[dom] = {"status": 0, "auth": False, "error": str(e)}
|
||||
continue
|
||||
out[dom] = {"status": status, "auth": status not in (401, 403)}
|
||||
return out
|
||||
|
||||
|
||||
def account_display(account):
|
||||
return account.get("name") or account.get("uid") or "Qoder 账号"
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TRAE (ByteDance, CN edition) daily check-in client.
|
||||
|
||||
Endpoints (reverse-engineered from the TRAE desktop client, see community
|
||||
projects wb-trae-checkin / Trae-AutoCheckin):
|
||||
|
||||
base https://api.trae.cn
|
||||
status POST /trae/api/v2/ug/checkin_credits/status body {"req_source":1}
|
||||
claim POST /trae/api/v2/ug/checkin_credits/claim body {"req_source":1}
|
||||
|
||||
Auth: Authorization: Cloud-IDE-JWT <accessToken> (a JWT issued by the client).
|
||||
The server also expects desktop-client headers including a 16-digit device id.
|
||||
|
||||
Result codes:
|
||||
0 success (claim returns data.points, usually 200)
|
||||
9095 already checked in today
|
||||
9074 device id remembered by the server -> rotate to a fresh device id
|
||||
1001/1002/401/403 session invalid -> re-login required
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
|
||||
from common import http_post_json, log
|
||||
|
||||
API_BASE = "https://api.trae.cn"
|
||||
TRAE_VERSION = "1.107.1"
|
||||
EP_STATUS = "/trae/api/v2/ug/checkin_credits/status"
|
||||
EP_CLAIM = "/trae/api/v2/ug/checkin_credits/claim"
|
||||
|
||||
AUTH_FAIL_CODES = {1001, 1002, 401, 403}
|
||||
|
||||
|
||||
def _b64url_decode(seg):
|
||||
seg += "=" * (-len(seg) % 4)
|
||||
return base64.urlsafe_b64decode(seg.encode("ascii"))
|
||||
|
||||
|
||||
def jwt_payload(token):
|
||||
"""Decode a JWT payload without verifying the signature."""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) < 2:
|
||||
return {}
|
||||
return json.loads(_b64url_decode(parts[1]).decode("utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def user_region(account):
|
||||
auth = account.get("auth", {})
|
||||
if auth.get("region"):
|
||||
return auth["region"]
|
||||
payload = jwt_payload(auth.get("accessToken", ""))
|
||||
region = (payload.get("userRegion") or {}).get("region")
|
||||
return region or "CN"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device id (16 digits): stable per account, rotated on 9074
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def stable_device_id(account_id):
|
||||
digest = hashlib.sha256(("agentcheckin:%s" % account_id).encode("utf-8")).hexdigest()
|
||||
n = int(digest[:13], 16)
|
||||
return str(n).ljust(16, "0")[:16]
|
||||
|
||||
|
||||
def rotate_device_id():
|
||||
return (str(int(time.time() * 1000))[-13:] + str(int(time.time() * 1000) % 1000).zfill(3))[:16]
|
||||
|
||||
|
||||
def device_id(account):
|
||||
did = account.get("device_id")
|
||||
if not did:
|
||||
did = stable_device_id(account.get("id", "default"))
|
||||
account["device_id"] = did
|
||||
return did
|
||||
|
||||
|
||||
def headers(account):
|
||||
auth = account.get("auth", {})
|
||||
return {
|
||||
"Authorization": "Cloud-IDE-JWT %s" % auth.get("accessToken", ""),
|
||||
"X-User-Region": user_region(account),
|
||||
"User-Agent": "Trae/%s" % TRAE_VERSION,
|
||||
"Content-Type": "application/json",
|
||||
"x-device-id": device_id(account),
|
||||
"x-device-type": "Windows",
|
||||
"x-os-version": "10.0.19045",
|
||||
"x-app-version": TRAE_VERSION,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check-in API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def query_status(account):
|
||||
"""Return (checked, enable, credits, error). checked True/False/None."""
|
||||
data, status, err = http_post_json(API_BASE + EP_STATUS, body={"req_source": 1},
|
||||
headers=headers(account), timeout=20)
|
||||
if err or data is None:
|
||||
if status in (401, 403):
|
||||
return None, None, None, "no_session"
|
||||
return None, None, None, err or "status query failed"
|
||||
if isinstance(data, dict):
|
||||
code = data.get("code")
|
||||
if code in AUTH_FAIL_CODES:
|
||||
return None, None, None, "no_session"
|
||||
if code == 0:
|
||||
return (
|
||||
bool(data.get("checked_in")),
|
||||
data.get("enable") is not False,
|
||||
data.get("credits"),
|
||||
None,
|
||||
)
|
||||
return None, None, None, None
|
||||
|
||||
|
||||
def claim(account):
|
||||
"""Claim today's credits. Returns (status, message, points).
|
||||
|
||||
status: ok / already / fail / no_session / retry_later
|
||||
"""
|
||||
hdrs = headers(account)
|
||||
data, status, err = http_post_json(API_BASE + EP_CLAIM, body={"req_source": 1},
|
||||
headers=hdrs, timeout=20)
|
||||
if status in (401, 403):
|
||||
return "no_session", "登录态已失效,请重新获取 Token", 0
|
||||
if data is None:
|
||||
return "fail", "请求失败(HTTP %s:%s)" % (status, err), 0
|
||||
if not isinstance(data, dict):
|
||||
return "fail", "非 JSON 返回(HTTP %s)" % status, 0
|
||||
code = data.get("code")
|
||||
if code == 0:
|
||||
d = data.get("data") if isinstance(data.get("data"), dict) else {}
|
||||
return "ok", "签到成功", d.get("points") or 200
|
||||
if code == 9095:
|
||||
return "already", "今天已签到", 0
|
||||
if code in AUTH_FAIL_CODES:
|
||||
return "no_session", "登录态已失效(code=%s),请重新获取 Token" % code, 0
|
||||
if code == 9074:
|
||||
return "retry_later", "触发风控(9074),需更换设备号重试", 0
|
||||
return "fail", "领取返回 code=%s %s" % (code, str(data.get("message") or data.get("msg") or "")[:80]), 0
|
||||
|
||||
|
||||
def checkin_once(account):
|
||||
"""Full single-account check-in: status -> claim -> verify.
|
||||
|
||||
Returns dict {status, message, points, credits}.
|
||||
"""
|
||||
checked, enable, credits, err = query_status(account)
|
||||
if err == "no_session":
|
||||
return {"status": "no_session", "message": "登录态已失效,请重新获取 Token", "points": 0}
|
||||
if checked is True:
|
||||
return {"status": "already", "message": "今日已签到",
|
||||
"points": 0, "credits": credits}
|
||||
if enable is False:
|
||||
return {"status": "inactive", "message": "当前账号/时段未开放签到活动", "points": 0}
|
||||
|
||||
rotated = False
|
||||
for attempt in range(2):
|
||||
status, message, points = claim(account)
|
||||
if status == "ok":
|
||||
# verify server-side
|
||||
for _ in range(3):
|
||||
time.sleep(2)
|
||||
v_checked, _, v_credits, _ = query_status(account)
|
||||
if v_checked is True:
|
||||
return {"status": "ok", "message": "签到成功(已回查确认)",
|
||||
"points": points, "credits": v_credits}
|
||||
if v_checked is False:
|
||||
break
|
||||
return {"status": "ok_unverified",
|
||||
"message": "领取成功(+%s)但回查未确认" % points,
|
||||
"points": points, "credits": credits}
|
||||
if status == "already":
|
||||
return {"status": "already", "message": "今天已签到", "points": 0,
|
||||
"credits": credits}
|
||||
if status == "no_session":
|
||||
return {"status": "no_session", "message": message, "points": 0}
|
||||
if status == "retry_later" and not rotated:
|
||||
account["device_id"] = rotate_device_id()
|
||||
rotated = True
|
||||
log("trae account %s: 9074 -> rotated device id" % account.get("id"))
|
||||
time.sleep(3)
|
||||
continue
|
||||
return {"status": "fail", "message": message, "points": 0}
|
||||
return {"status": "retry_later", "message": "风控限流(9074),换设备号后仍失败,下个时段自动补签",
|
||||
"points": 0}
|
||||
|
||||
|
||||
def account_display(account):
|
||||
return account.get("name") or (account.get("auth") or {}).get("nickname") or "TRAE 账号"
|
||||
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WorkBuddy / CodeBuddy (Tencent) daily check-in client.
|
||||
|
||||
Endpoints (reverse-engineered from the official desktop client, see
|
||||
community projects cpa-plugin/workbuddy and wb-trae-checkin):
|
||||
|
||||
region CN base https://www.codebuddy.cn (daily check-in supported)
|
||||
region Global base https://www.workbuddy.ai (no daily check-in)
|
||||
|
||||
status POST {base}/v2/billing/meter/checkin-activity-status
|
||||
claim POST {base}/v2/billing/meter/daily-checkin
|
||||
credits POST {base}/v2/billing/meter/get-user-resource
|
||||
refresh POST https://copilot.tencent.com/v2/plugin/auth/token/refresh
|
||||
(Global: https://www.workbuddy.ai/v2/plugin/auth/token/refresh)
|
||||
oauth GET https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI
|
||||
GET https://copilot.tencent.com/v2/plugin/auth/token?state=...
|
||||
GET https://copilot.tencent.com/v2/plugin/login/account?state=...
|
||||
|
||||
Result codes used across the app: ok / already / fail / no_session / inactive.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
from common import (http_post_json, http_get, log, now_ts, fmt_now)
|
||||
|
||||
CN_BASE = "https://www.codebuddy.cn"
|
||||
GLOBAL_BASE = "https://www.workbuddy.ai"
|
||||
REFRESH_URL_CN = "https://copilot.tencent.com/v2/plugin/auth/token/refresh"
|
||||
REFRESH_URL_GLOBAL = "https://www.workbuddy.ai/v2/plugin/auth/token/refresh"
|
||||
AUTH_BASE = "https://copilot.tencent.com"
|
||||
|
||||
EP_STATUS = "/v2/billing/meter/checkin-activity-status"
|
||||
EP_DAILY_CHECKIN = "/v2/billing/meter/daily-checkin"
|
||||
EP_USER_RESOURCE = "/v2/billing/meter/get-user-resource"
|
||||
|
||||
REFRESH_MARGIN = 3 * 24 * 3600 # refresh 3 days before expiry
|
||||
SESSION_DEAD = ("12153", "Offline user session not found")
|
||||
|
||||
|
||||
def is_global(auth):
|
||||
return (auth.get("domain") or "").lower() in ("workbuddy.ai", "global")
|
||||
|
||||
|
||||
def billing_base(auth):
|
||||
return GLOBAL_BASE if is_global(auth) else CN_BASE
|
||||
|
||||
|
||||
def refresh_url(auth):
|
||||
return REFRESH_URL_GLOBAL if is_global(auth) else REFRESH_URL_CN
|
||||
|
||||
|
||||
def billing_headers(auth, account=None):
|
||||
account = account or {}
|
||||
headers = {"Authorization": "Bearer %s" % auth.get("accessToken", "")}
|
||||
if account.get("uid"):
|
||||
headers["X-User-Id"] = str(account["uid"])
|
||||
if account.get("enterpriseId"):
|
||||
headers["X-Enterprise-Id"] = str(account["enterpriseId"])
|
||||
headers["X-Tenant-Id"] = str(account["enterpriseId"])
|
||||
if auth.get("domain"):
|
||||
headers["X-Domain"] = auth["domain"]
|
||||
base = billing_base(auth)
|
||||
headers["Referer"] = base + "/"
|
||||
headers["Origin"] = base
|
||||
return headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _expires_at(auth):
|
||||
exp = auth.get("expiresAt") or 0
|
||||
try:
|
||||
exp = int(exp)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
if exp > 10 ** 12: # milliseconds
|
||||
exp //= 1000
|
||||
return exp
|
||||
|
||||
|
||||
def token_expiring(auth, margin=REFRESH_MARGIN):
|
||||
exp = _expires_at(auth)
|
||||
if not exp:
|
||||
return False
|
||||
return now_ts() + margin > exp
|
||||
|
||||
|
||||
def refresh_token(account):
|
||||
"""Refresh access token in place. Returns (ok, message)."""
|
||||
auth = account.setdefault("auth", {})
|
||||
rt = auth.get("refreshToken", "")
|
||||
if not rt:
|
||||
return False, "未配置 refreshToken,无法自动续期"
|
||||
headers = {
|
||||
"X-Refresh-Token": rt,
|
||||
"X-Auth-Refresh-Source": "workbuddy",
|
||||
}
|
||||
if (account.get("account") or {}).get("enterpriseId"):
|
||||
headers["X-Enterprise-Id"] = str(account["account"]["enterpriseId"])
|
||||
data, status, err = http_post_json(refresh_url(auth), body={}, headers=headers)
|
||||
if err or not data:
|
||||
if status == 401 and any(m in (err or "") for m in SESSION_DEAD):
|
||||
account["disabled"] = True
|
||||
account["note"] = "会话已失效,需要重新授权"
|
||||
return False, "登录态已失效,请重新授权"
|
||||
return False, "续期失败(HTTP %s):%s" % (status, err)
|
||||
if not isinstance(data, dict) or not data.get("accessToken"):
|
||||
return False, "续期响应缺少 accessToken"
|
||||
auth["accessToken"] = data["accessToken"]
|
||||
if data.get("refreshToken"):
|
||||
auth["refreshToken"] = data["refreshToken"]
|
||||
if data.get("domain"):
|
||||
auth["domain"] = data["domain"]
|
||||
if data.get("expiresAt") or data.get("expires_at"):
|
||||
auth["expiresAt"] = data.get("expiresAt") or data.get("expires_at")
|
||||
account["note"] = ""
|
||||
return True, "已续期"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Billing API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def query_status(account):
|
||||
"""Return (checked, detail, error). checked is True/False/None."""
|
||||
auth = account.get("auth", {})
|
||||
headers = billing_headers(auth, account.get("account"))
|
||||
data, status, err = http_post_json(billing_base(auth) + EP_STATUS, body={}, headers=headers)
|
||||
if err or data is None:
|
||||
return None, None, err or "empty response"
|
||||
if status in (401, 403) or any(m in (err or "") for m in SESSION_DEAD):
|
||||
return None, None, "no_session"
|
||||
d = data.get("data") if isinstance(data, dict) else None
|
||||
d = d if isinstance(d, dict) else {}
|
||||
for key in ("today_checked_in", "checked_in", "checkedIn", "hasCheckin", "isCheckedIn", "checked"):
|
||||
if isinstance(d.get(key), bool):
|
||||
return d[key], d, None
|
||||
if isinstance(data.get(key), bool):
|
||||
return data[key], d, None
|
||||
return None, d, None
|
||||
|
||||
|
||||
def perform_checkin(account):
|
||||
"""Claim today's credits. Returns (status, message, points).
|
||||
|
||||
status: ok / already / fail / no_session
|
||||
"""
|
||||
auth = account.get("auth", {})
|
||||
headers = billing_headers(auth, account.get("account"))
|
||||
data, status, err = http_post_json(billing_base(auth) + EP_DAILY_CHECKIN, body={}, headers=headers)
|
||||
if status in (401, 403):
|
||||
return "no_session", "登录态已失效,请重新授权", 0
|
||||
if data is None:
|
||||
return "fail", "请求失败(HTTP %s:%s)" % (status, err), 0
|
||||
code = data.get("code") if isinstance(data, dict) else 0
|
||||
if code in (10001,):
|
||||
return "already", "今天已签到", 0
|
||||
if code in (0, 200) or code is None:
|
||||
d = data.get("data") if isinstance(data, dict) else {}
|
||||
d = d if isinstance(d, dict) else {}
|
||||
points = d.get("today_credit", d.get("daily_credit", d.get("points", 0)))
|
||||
return "ok", "签到成功", points or 0
|
||||
if any(m in (err or "") for m in SESSION_DEAD):
|
||||
return "no_session", "登录态已失效,请重新授权", 0
|
||||
return "fail", "领取返回 code=%s %s" % (code, (data.get("msg") or "")[:80]), 0
|
||||
|
||||
|
||||
def fetch_credits(account):
|
||||
"""Aggregate credit packages. Returns (summary, error)."""
|
||||
auth = account.get("auth", {})
|
||||
headers = billing_headers(auth, account.get("account"))
|
||||
body = {
|
||||
"PageNumber": 1,
|
||||
"PageSize": 100,
|
||||
"ProductCode": "p_tcaca",
|
||||
"Status": [0, 3],
|
||||
"PackageEndTimeRangeBegin": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
"PackageEndTimeRangeEnd": "2126-12-31 23:59:59",
|
||||
}
|
||||
data, status, err = http_post_json(billing_base(auth) + EP_USER_RESOURCE, body=body, headers=headers)
|
||||
if err or data is None:
|
||||
if status in (401, 403):
|
||||
return None, "no_session"
|
||||
return None, err or "查询失败"
|
||||
resp = data
|
||||
if isinstance(data, dict) and "Response" in data:
|
||||
resp = data.get("Response") or {}
|
||||
resp_data = resp.get("Data", {}) if isinstance(resp, dict) else {}
|
||||
accounts = resp_data.get("Accounts", []) if isinstance(resp_data, dict) else []
|
||||
if not isinstance(accounts, list):
|
||||
accounts = []
|
||||
total_remain = total_used = total_size = 0
|
||||
packages = []
|
||||
for pkg in accounts:
|
||||
if not isinstance(pkg, dict):
|
||||
continue
|
||||
remain, used, size = _package_remain_used(pkg)
|
||||
total_remain += remain
|
||||
total_used += used
|
||||
total_size += size
|
||||
packages.append({
|
||||
"name": pkg.get("PackageName", ""),
|
||||
"remain": remain, "used": used, "size": size,
|
||||
})
|
||||
dosage = resp_data.get("TotalDosage", 0)
|
||||
if isinstance(dosage, (int, float)) and dosage > total_size:
|
||||
total_size = dosage
|
||||
derived = total_size - total_remain
|
||||
if derived > total_used:
|
||||
total_used = derived
|
||||
return {
|
||||
"remain": total_remain,
|
||||
"used": total_used,
|
||||
"size": total_size,
|
||||
"packages": packages,
|
||||
}, None
|
||||
|
||||
|
||||
def _package_remain_used(pkg):
|
||||
def num(v):
|
||||
return v if isinstance(v, (int, float)) else 0
|
||||
cycle_size = num(pkg.get("CycleCapacitySize"))
|
||||
if cycle_size > 0:
|
||||
remain = max(num(pkg.get("CycleCapacityRemain")), 0)
|
||||
remain = min(remain, cycle_size)
|
||||
used = cycle_size - remain
|
||||
explicit = num(pkg.get("CycleCapacityUsed"))
|
||||
if explicit > used:
|
||||
used = explicit
|
||||
if cycle_size >= used:
|
||||
remain = cycle_size - used
|
||||
return remain, used, cycle_size
|
||||
cycle_remain = num(pkg.get("CycleCapacityRemain"))
|
||||
cycle_used = num(pkg.get("CycleCapacityUsed"))
|
||||
if cycle_remain > 0 or cycle_used > 0:
|
||||
remain = max(cycle_remain, 0)
|
||||
used = max(cycle_used, 0)
|
||||
size = remain + used
|
||||
cap = num(pkg.get("CapacitySize"))
|
||||
if cap > size:
|
||||
size = cap
|
||||
if size >= remain:
|
||||
used = size - remain
|
||||
return remain, used, size
|
||||
remain = max(num(pkg.get("CapacityRemain")), 0)
|
||||
used = max(num(pkg.get("CapacityUsed")), 0)
|
||||
size = num(pkg.get("CapacitySize"))
|
||||
if size <= 0:
|
||||
size = remain + used
|
||||
if used == 0 and size > remain:
|
||||
used = size - remain
|
||||
return remain, used, size
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth web login (CLI-style device flow, driven from the NAS web UI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def begin_oauth():
|
||||
"""Start a login session. Returns (state, login_url, error)."""
|
||||
data, status, err = http_post_json(AUTH_BASE + "/v2/plugin/auth/state?platform=CLI",
|
||||
body={}, timeout=15)
|
||||
if err or not isinstance(data, dict):
|
||||
return None, None, "获取授权状态失败(HTTP %s:%s)" % (status, err)
|
||||
d = data.get("data") if isinstance(data.get("data"), dict) else data
|
||||
state = d.get("state") or d.get("State")
|
||||
if not state:
|
||||
return None, None, "未能获取授权 state"
|
||||
login_url = "%s/login?state=%s&platform=CLI" % (AUTH_BASE, state)
|
||||
return state, login_url, None
|
||||
|
||||
|
||||
def poll_oauth(state):
|
||||
"""Poll the token endpoint. Returns (status, payload, error).
|
||||
|
||||
status: waiting / done / failed
|
||||
"""
|
||||
data, status_code, err = http_get(
|
||||
AUTH_BASE + "/v2/plugin/auth/token?state=%s" % state, timeout=15)
|
||||
if status_code == 200 and isinstance(data, dict):
|
||||
code = data.get("code", 0)
|
||||
d = data.get("data") if isinstance(data.get("data"), dict) else {}
|
||||
if code in (0, 200) and (d.get("accessToken") or d.get("access_token")):
|
||||
expires_at = d.get("expiresAt") or d.get("expires_at") or 0
|
||||
if not expires_at and (d.get("expires_in") or d.get("expiresIn")):
|
||||
from common import now_ts
|
||||
expires_at = now_ts() + int(d.get("expires_in") or d.get("expiresIn"))
|
||||
payload = {
|
||||
"accessToken": d.get("accessToken") or d.get("access_token"),
|
||||
"refreshToken": d.get("refreshToken") or d.get("refresh_token"),
|
||||
"expiresAt": expires_at,
|
||||
"domain": d.get("domain") or "codebuddy.cn",
|
||||
}
|
||||
account = fetch_account_info(state, payload["accessToken"])
|
||||
if account:
|
||||
payload["account"] = account
|
||||
return "done", payload, None
|
||||
# not logged in yet (or state expired) — keep waiting within the UI timeout
|
||||
return "waiting", None, None
|
||||
|
||||
|
||||
def fetch_account_info(state, access_token):
|
||||
"""Best-effort account info for the OAuth session."""
|
||||
headers = {"Authorization": "Bearer %s" % access_token} if access_token else None
|
||||
data, status, err = http_get(
|
||||
AUTH_BASE + "/v2/plugin/login/account?state=%s" % state, headers=headers, timeout=15)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
d = data.get("data") if isinstance(data.get("data"), dict) else data
|
||||
uid = d.get("uid") or d.get("userId") or d.get("Uid")
|
||||
nickname = d.get("nickname") or d.get("name") or d.get("Nickname")
|
||||
enterprise = d.get("enterpriseId") or d.get("EnterpriseId") or d.get("enterprise_id")
|
||||
if not (uid or nickname):
|
||||
return None
|
||||
return {
|
||||
"uid": str(uid) if uid else "",
|
||||
"nickname": str(nickname) if nickname else "",
|
||||
"enterpriseId": str(enterprise) if enterprise else "",
|
||||
}
|
||||
|
||||
|
||||
def account_display(account):
|
||||
acct = account.get("account") or {}
|
||||
return acct.get("nickname") or acct.get("uid") or account.get("name") or "WorkBuddy 账号"
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
".url": {
|
||||
"AgentCheckin.Application": {
|
||||
"title": "Agent 签到助手",
|
||||
"icon": "images/icon_{0}.png",
|
||||
"type": "iframe",
|
||||
"url": "/cgi/ThirdParty/AgentCheckin/index.cgi/",
|
||||
"allUsers": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,130 @@
|
||||
#!/bin/bash
|
||||
# AgentCheckin CGI entry for fnOS.
|
||||
# - serves static files from the app's www directory
|
||||
# - /api/* requests are dispatched to the Python backend (api.py)
|
||||
#
|
||||
# Installed layout (see fnOS app framework docs):
|
||||
# /var/apps/AgentCheckin/target/ui/index.cgi (this file)
|
||||
# /var/apps/AgentCheckin/target/app/backend (python backend)
|
||||
# /var/apps/AgentCheckin/target/app/www (static files)
|
||||
# /vol{n}/@appdata/AgentCheckin (TRIM_PKGVAR: persistent data)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
||||
APP_ROOT="${SCRIPT_DIR}/.."
|
||||
BASE_PATH="${APP_ROOT}/www"
|
||||
BACKEND_DIR="${APP_ROOT}/backend"
|
||||
|
||||
# fnOS CGI processes carry no TRIM_PKGVAR. Resolve the persistent data dir
|
||||
# (@appdata/AgentCheckin) and export it explicitly so the Python backend and
|
||||
# the scheduler daemon always agree on one location.
|
||||
if [ -n "${TRIM_PKGVAR}" ] && [ -d "${TRIM_PKGVAR}" ]; then
|
||||
export AGENTCHECKIN_DATA_DIR="${TRIM_PKGVAR}"
|
||||
else
|
||||
for _cand in \
|
||||
"${APP_ROOT}/../../@appdata/AgentCheckin" \
|
||||
"/var/apps/AgentCheckin/var" \
|
||||
"/vol1/@appdata/AgentCheckin" \
|
||||
"/vol2/@appdata/AgentCheckin"; do
|
||||
if [ -d "$_cand" ]; then
|
||||
export AGENTCHECKIN_DATA_DIR="$_cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Resolve a python3 interpreter: prefer the fnOS runtime dependency app.
|
||||
PYBIN=""
|
||||
for candidate in \
|
||||
/var/apps/python312/target/bin/python3 \
|
||||
/var/apps/python311/target/bin/python3 \
|
||||
/var/apps/python310/target/bin/python3 \
|
||||
/usr/bin/python3 \
|
||||
/usr/local/bin/python3; do
|
||||
if [ -x "$candidate" ]; then PYBIN="$candidate"; break; fi
|
||||
done
|
||||
|
||||
json_error() {
|
||||
# $1 = http status body, $2 = message
|
||||
printf 'Status: %s\r\n' "$1"
|
||||
printf 'Content-Type: application/json; charset=utf-8\r\n'
|
||||
printf 'Cache-Control: no-store\r\n'
|
||||
printf '\r\n'
|
||||
printf '{"error":"%s"}\n' "$2"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse the relative request path (everything after "index.cgi")
|
||||
# ---------------------------------------------------------------------------
|
||||
URI_NO_QUERY="${REQUEST_URI%%\?*}"
|
||||
REL_PATH="/"
|
||||
case "$URI_NO_QUERY" in
|
||||
*index.cgi*)
|
||||
REL_PATH="${URI_NO_QUERY#*index.cgi}"
|
||||
;;
|
||||
esac
|
||||
REL_PATH="${REL_PATH#/}"
|
||||
REL_PATH="${REL_PATH%/}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API requests -> python backend
|
||||
# ---------------------------------------------------------------------------
|
||||
case "$REL_PATH" in
|
||||
api/*)
|
||||
if [ -z "$PYBIN" ]; then
|
||||
json_error "500 Internal Server Error" "python3 runtime unavailable"
|
||||
fi
|
||||
if [ ! -f "${BACKEND_DIR}/api.py" ]; then
|
||||
json_error "500 Internal Server Error" "backend missing"
|
||||
fi
|
||||
export AGENTCHECKIN_BACKEND_DIR="$BACKEND_DIR"
|
||||
exec "$PYBIN" "${BACKEND_DIR}/api.py"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static files (with path-traversal protection)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -z "$REL_PATH" ] || [ "$REL_PATH" = "/" ]; then
|
||||
REL_PATH="index.html"
|
||||
fi
|
||||
|
||||
case "$REL_PATH" in
|
||||
*..*) json_error "400 Bad Request" "bad path" ;;
|
||||
esac
|
||||
OLD_IFS="$IFS"
|
||||
IFS='/'
|
||||
for SEG in $REL_PATH; do
|
||||
case "$SEG" in
|
||||
''|.|*[!A-Za-z0-9._-]*) IFS="$OLD_IFS"; json_error "400 Bad Request" "bad path" ;;
|
||||
esac
|
||||
done
|
||||
IFS="$OLD_IFS"
|
||||
|
||||
TARGET_FILE="${BASE_PATH}/${REL_PATH}"
|
||||
if [ ! -f "$TARGET_FILE" ]; then
|
||||
json_error "404 Not Found" "not found"
|
||||
fi
|
||||
|
||||
case "${TARGET_FILE##*.}" in
|
||||
html|htm) mime="text/html; charset=utf-8" ;;
|
||||
css) mime="text/css; charset=utf-8" ;;
|
||||
js) mime="application/javascript; charset=utf-8" ;;
|
||||
json) mime="application/json; charset=utf-8" ;;
|
||||
png) mime="image/png" ;;
|
||||
jpg|jpeg) mime="image/jpeg" ;;
|
||||
gif) mime="image/gif" ;;
|
||||
svg) mime="image/svg+xml" ;;
|
||||
ico) mime="image/x-icon" ;;
|
||||
webmanifest) mime="application/manifest+json" ;;
|
||||
txt) mime="text/plain; charset=utf-8" ;;
|
||||
woff) mime="font/woff" ;;
|
||||
woff2) mime="font/woff2" ;;
|
||||
*) mime="application/octet-stream" ;;
|
||||
esac
|
||||
|
||||
printf 'Content-Type: %s\r\n' "$mime"
|
||||
printf 'Cache-Control: no-cache\r\n'
|
||||
printf '\r\n'
|
||||
cat "$TARGET_FILE"
|
||||
exit 0
|
||||
@@ -0,0 +1,512 @@
|
||||
/* AgentCheckin frontend — vanilla JS, no dependencies */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var state = null;
|
||||
var pollTimer = null;
|
||||
var oauthState = null;
|
||||
var oauthTimer = null;
|
||||
var logTimer = null;
|
||||
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
function toast(msg, kind) {
|
||||
var t = $("toast");
|
||||
t.textContent = msg;
|
||||
t.className = "toast" + (kind ? " " + kind : "");
|
||||
clearTimeout(t._timer);
|
||||
t._timer = setTimeout(function () { t.classList.add("hidden"); }, 3200);
|
||||
}
|
||||
|
||||
function api(method, path, body) {
|
||||
var opts = { method: method, headers: {} };
|
||||
if (body !== undefined && body !== null) {
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
return fetch("api" + path, opts).then(function (r) {
|
||||
return r.json().catch(function () { return {}; });
|
||||
}).then(function (d) {
|
||||
if (d && d.error) throw new Error(d.error);
|
||||
return d;
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||||
});
|
||||
}
|
||||
|
||||
var STATUS = {
|
||||
ok: { label: "签到成功", cls: "ok", icon: "✅" },
|
||||
ok_unverified: { label: "已领取·待确认", cls: "ok_unverified", icon: "✅" },
|
||||
already: { label: "今日已签", cls: "already", icon: "☑️" },
|
||||
inactive: { label: "未开放签到", cls: "inactive", icon: "➖" },
|
||||
fail: { label: "签到失败", cls: "fail", icon: "❌" },
|
||||
no_session: { label: "需重新授权", cls: "no_session", icon: "🔒" },
|
||||
no_auth: { label: "缺少凭据", cls: "no_auth", icon: "🔒" },
|
||||
retry_later: { label: "风控冷却", cls: "retry_later", icon: "⏳" }
|
||||
};
|
||||
|
||||
function platformName(p) { return p === "trae" ? "TRAE" : (p === "qoder" ? "Qoder" : "WorkBuddy"); }
|
||||
|
||||
// ---------------------------------------------------------------- tabs
|
||||
document.querySelectorAll(".tab").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
document.querySelectorAll(".tab").forEach(function (b) { b.classList.remove("active"); });
|
||||
document.querySelectorAll(".tabpage").forEach(function (p) { p.classList.remove("active"); });
|
||||
btn.classList.add("active");
|
||||
$("tab-" + btn.dataset.tab).classList.add("active");
|
||||
if (btn.dataset.tab === "logs") loadLogs();
|
||||
if (btn.dataset.tab === "accounts") renderAccounts();
|
||||
if (btn.dataset.tab === "settings") openSettingsTab();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- dashboard
|
||||
function renderTop() {
|
||||
var alive = state && state.daemon_alive;
|
||||
var chip = $("daemon-chip");
|
||||
chip.innerHTML = '<span class="dot"></span>守护进程 ' + (alive ? "运行中" : "已停止");
|
||||
chip.className = "chip " + (alive ? "chip-green" : "chip-red");
|
||||
var nr = $("nextrun-chip");
|
||||
nr.textContent = "下次签到 " + ((state && state.next_run_at) || "—");
|
||||
nr.className = "chip " + (state && state.next_run_at ? "chip-blue" : "chip-gray");
|
||||
}
|
||||
|
||||
function renderCards() {
|
||||
var wrap = $("dash-cards");
|
||||
var accounts = (state && state.accounts) || [];
|
||||
if (!accounts.length) {
|
||||
wrap.innerHTML = '<div class="panel" style="grid-column:1/-1;text-align:center;color:var(--muted)">' +
|
||||
"暂无账号,请先到「账号管理」添加 TRAE / WorkBuddy 账号</div>";
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML = accounts.map(function (a) {
|
||||
var st = a.today && a.today.status ? STATUS[a.today.status] : null;
|
||||
var statusHtml = st
|
||||
? '<span class="status-chip ' + st.cls + '">' + st.icon + " " + st.label + "</span>"
|
||||
: '<span class="status-chip pending">⏳ 今日未签</span>';
|
||||
var credits = "";
|
||||
if (a.last_result && typeof a.last_result.credits === "number") {
|
||||
credits = "剩余积分 <b>" + a.last_result.credits + "</b>";
|
||||
} else if (a.last_result && a.last_result.points) {
|
||||
credits = "本次 +<b>" + a.last_result.points + "</b>";
|
||||
}
|
||||
var msg = a.today && a.today.message ? esc(a.today.message) : "";
|
||||
return (
|
||||
'<div class="acct-card' + (a.enabled ? "" : " disabled") + '">' +
|
||||
'<div class="acct-head"><span class="platform-badge ' + esc(a.platform) + '">' + platformName(a.platform) + "</span>" +
|
||||
'<span class="acct-name">' + esc(a.name) + "</span></div>" +
|
||||
'<div class="acct-status">' + statusHtml + (msg ? '<span class="muted">' + msg + "</span>" : "") + "</div>" +
|
||||
'<div class="acct-meta">' + (credits ? "<span>" + credits + "</span>" : "") +
|
||||
(a.last_result ? "<span>最近运行 " + esc(a.last_result.time || "") + "</span>" : "") +
|
||||
(!a.enabled ? "<span>已禁用</span>" : "") + "</div>" +
|
||||
'<div class="acct-actions">' +
|
||||
'<button class="btn btn-sm" data-act="run" data-id="' + esc(a.id) + '">立即签到</button>' +
|
||||
'<button class="btn btn-sm" data-act="test" data-id="' + esc(a.id) + '">测连接</button>' +
|
||||
"</div></div>"
|
||||
);
|
||||
}).join("");
|
||||
|
||||
wrap.querySelectorAll("button[data-act]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
if (btn.dataset.act === "run") runCheckin(btn.dataset.id, btn);
|
||||
else testAccount(btn.dataset.id, btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var lastStateJson = "";
|
||||
function refreshState(force) {
|
||||
return api("GET", "/state").then(function (d) {
|
||||
var json = JSON.stringify(d);
|
||||
if (!force && json === lastStateJson) return; // 数据没变,跳过重渲染
|
||||
lastStateJson = json;
|
||||
state = d;
|
||||
renderTop();
|
||||
renderCards();
|
||||
}).catch(function (e) { /* keep old state on transient errors */ });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- run / test
|
||||
var runningBtn = null;
|
||||
function runCheckin(accountId, btn) {
|
||||
if (btn) { btn.disabled = true; btn.textContent = "执行中…"; runningBtn = btn; }
|
||||
api("POST", "/checkin", accountId ? { account_id: accountId } : {})
|
||||
.then(function (d) {
|
||||
if (d.mode === "trigger") {
|
||||
toast("已触发签到,守护进程正在执行…");
|
||||
waitForRun(accountId);
|
||||
} else {
|
||||
toast("签到完成", "ok");
|
||||
refreshState();
|
||||
}
|
||||
})
|
||||
.catch(function (e) { toast(e.message, "err"); })
|
||||
.finally(function () {
|
||||
if (runningBtn) { runningBtn.disabled = false; runningBtn.textContent = "立即签到"; runningBtn = null; }
|
||||
});
|
||||
}
|
||||
|
||||
function waitForRun(accountId) {
|
||||
// poll until today_results contains a fresh entry for the target
|
||||
var started = Date.now();
|
||||
var before = JSON.stringify((state && state.today_results) || {});
|
||||
(function poll() {
|
||||
if (Date.now() - started > 90000) { refreshState(); return; }
|
||||
refreshState().then(function () {
|
||||
var after = JSON.stringify((state && state.today_results) || {});
|
||||
var done = after !== before && (!accountId || (state.today_results || {})[accountId]);
|
||||
if (done) {
|
||||
var r = accountId ? state.today_results[accountId] : null;
|
||||
if (r) toast(platformName(r.platform) + " " + r.name + ":" + (r.message || r.status), r.status === "fail" || r.status === "no_session" ? "err" : "ok");
|
||||
else toast("签到完成", "ok");
|
||||
} else {
|
||||
setTimeout(poll, 3000);
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
function testAccount(id, btn) {
|
||||
btn.disabled = true; btn.textContent = "检测中…";
|
||||
api("POST", "/accounts/test", { id: id })
|
||||
.then(function (d) {
|
||||
var parts = [];
|
||||
if (d.refresh) parts.push("续期:" + d.refresh);
|
||||
if (typeof d.checked_in === "boolean") parts.push("今日" + (d.checked_in ? "已签" : "未签"));
|
||||
if (d.enable === false) parts.push("活动未开放");
|
||||
if (d.credits && typeof d.credits.remain === "number") parts.push("剩余积分 " + d.credits.remain);
|
||||
else if (typeof d.credits === "number") parts.push("积分 " + d.credits);
|
||||
if (d.hint) parts.push(d.hint);
|
||||
if (d.error) parts.push("错误:" + d.error);
|
||||
toast(parts.join(";") || "检测完成", d.error ? "err" : "ok");
|
||||
})
|
||||
.catch(function (e) { toast(e.message, "err"); })
|
||||
.finally(function () { btn.disabled = false; btn.textContent = "测连接"; });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- accounts tab
|
||||
function renderAccounts() {
|
||||
var wrap = $("account-table-wrap");
|
||||
var accounts = (state && state.accounts) || [];
|
||||
if (!accounts.length) {
|
||||
wrap.innerHTML = '<p class="muted">暂无账号,点击右上角「添加账号」。</p>';
|
||||
return;
|
||||
}
|
||||
var rows = accounts.map(function (a) {
|
||||
var st = a.today && a.today.status ? STATUS[a.today.status] : null;
|
||||
return "<tr>" +
|
||||
"<td><span class='platform-badge " + esc(a.platform) + "'>" + platformName(a.platform) + "</span></td>" +
|
||||
"<td>" + esc(a.name) + (a.need_reauth ? ' <span class="status-chip no_session">需重新授权</span>' : "") +
|
||||
(a.platform === "qoder" ? '<div class="muted" style="font-size:11px">' + esc((a.auth && a.auth.domain) || "") + "</div>" : "") + "</td>" +
|
||||
"<td>" + esc(a.auth.accessToken || "") + "</td>" +
|
||||
"<td>" + (st ? st.icon + " " + st.label : "⏳ 今日未签") + "</td>" +
|
||||
"<td>" + (a.last_result && typeof a.last_result.credits === "number" ? a.last_result.credits : "—") + "</td>" +
|
||||
"<td>" + (a.enabled ? "启用" : "禁用") + "</td>" +
|
||||
'<td class="actions">' +
|
||||
'<button class="btn btn-sm" data-a2="run" data-id="' + esc(a.id) + '">签到</button>' +
|
||||
(a.platform === "qoder" ? '<button class="btn btn-sm" data-a2="fixdomain" data-id="' + esc(a.id) + '" title="自动探测 Token 所属站点并切换">修复站点</button>' : "") +
|
||||
'<button class="btn btn-sm" data-a2="toggle" data-id="' + esc(a.id) + '">' + (a.enabled ? "禁用" : "启用") + "</button>" +
|
||||
'<button class="btn btn-sm btn-danger" data-a2="del" data-id="' + esc(a.id) + '">删除</button>' +
|
||||
"</td></tr>";
|
||||
}).join("");
|
||||
wrap.innerHTML = '<table class="tbl"><thead><tr><th>平台</th><th>名称</th><th>Token</th><th>今日</th><th>积分</th><th>状态</th><th>操作</th></tr></thead><tbody>' + rows + "</tbody></table>";
|
||||
wrap.querySelectorAll("button[data-a2]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var id = btn.dataset.id, act = btn.dataset.a2;
|
||||
if (act === "run") runCheckin(id, btn);
|
||||
else if (act === "fixdomain") {
|
||||
btn.disabled = true; btn.textContent = "探测中…";
|
||||
api("POST", "/accounts/fix_domain", { id: id })
|
||||
.then(function (d) {
|
||||
if (d.ok) { toast("已切换到 " + d.domain, "ok"); refreshState(); }
|
||||
else { toast(d.error || "探测失败", "err"); }
|
||||
})
|
||||
.catch(function (e) { toast(e.message, "err"); })
|
||||
.finally(function () { btn.disabled = false; btn.textContent = "修复站点"; });
|
||||
}
|
||||
else if (act === "toggle") {
|
||||
var acc = state.accounts.find(function (x) { return x.id === id; });
|
||||
api("POST", "/accounts/update", { id: id, enabled: !acc.enabled })
|
||||
.then(function () { refreshState(); }).catch(function (e) { toast(e.message, "err"); });
|
||||
} else if (act === "del") {
|
||||
if (!confirm("确定删除该账号?")) return;
|
||||
api("POST", "/accounts/delete", { id: id })
|
||||
.then(function () { toast("已删除", "ok"); refreshState(); })
|
||||
.catch(function (e) { toast(e.message, "err"); });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- add modal
|
||||
function openModal() { $("modal-add").classList.remove("hidden"); }
|
||||
function closeModal(id) { $(id).classList.add("hidden"); stopOauth(); $("add-msg").textContent = ""; }
|
||||
|
||||
document.querySelectorAll("[data-close]").forEach(function (b) {
|
||||
b.addEventListener("click", function () { closeModal(b.dataset.close); });
|
||||
});
|
||||
$("modal-add").addEventListener("click", function (e) {
|
||||
if (e.target === this) closeModal("modal-add");
|
||||
});
|
||||
$("btn-add-account").addEventListener("click", openModal);
|
||||
|
||||
document.querySelectorAll(".mtab").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
document.querySelectorAll(".mtab").forEach(function (b) { b.classList.remove("active"); });
|
||||
document.querySelectorAll(".mtabpage").forEach(function (p) { p.classList.remove("active"); });
|
||||
btn.classList.add("active");
|
||||
$("mtab-" + btn.dataset.mtab).classList.add("active");
|
||||
stopOauth();
|
||||
});
|
||||
});
|
||||
|
||||
function addMsg(msg, ok) {
|
||||
var m = $("add-msg");
|
||||
m.textContent = msg;
|
||||
m.className = "msg " + (ok ? "ok" : "err");
|
||||
}
|
||||
|
||||
$("btn-wb-manual-add").addEventListener("click", function () {
|
||||
var token = $("wb-token").value.trim();
|
||||
if (!token) return addMsg("请填写 Access Token", false);
|
||||
api("POST", "/accounts/add_wb_manual", {
|
||||
name: $("wb-name").value.trim(),
|
||||
access_token: token,
|
||||
refresh_token: $("wb-refresh").value.trim(),
|
||||
domain: $("wb-domain").value
|
||||
}).then(function () {
|
||||
toast("WorkBuddy 账号已添加", "ok");
|
||||
closeModal("modal-add"); refreshState();
|
||||
}).catch(function (e) { addMsg(e.message, false); });
|
||||
});
|
||||
|
||||
$("btn-trae-add").addEventListener("click", function () {
|
||||
var token = $("trae-token").value.trim();
|
||||
if (!token) return addMsg("请填写 Access Token", false);
|
||||
api("POST", "/accounts/add_trae", {
|
||||
name: $("trae-name").value.trim(),
|
||||
access_token: token,
|
||||
refresh_token: $("trae-refresh").value.trim()
|
||||
}).then(function () {
|
||||
toast("TRAE 账号已添加", "ok");
|
||||
closeModal("modal-add"); refreshState();
|
||||
}).catch(function (e) { addMsg(e.message, false); });
|
||||
});
|
||||
|
||||
|
||||
$("btn-qoder-add").addEventListener("click", function () {
|
||||
var token = $("qoder-token").value.trim();
|
||||
if (!token) return addMsg("请填写 Access Token", false);
|
||||
if (token.length < 10) return addMsg("Access Token 仅 " + token.length + " 字符,明显不完整,请重新复制", false);
|
||||
var rt0 = $("qoder-refresh").value.trim();
|
||||
if (rt0 && rt0.length < 10) return addMsg("Refresh Token 仅 " + rt0.length + " 字符,明显不完整,请重新复制", false);
|
||||
var dev = {};
|
||||
var map = {
|
||||
"qoder-machine-id": "machineId",
|
||||
"qoder-machine-token": "machineToken",
|
||||
"qoder-machine-code": "machineCode",
|
||||
"qoder-machine-type": "machineType",
|
||||
"qoder-version": "version",
|
||||
};
|
||||
Object.keys(map).forEach(function (id) {
|
||||
var v = $(id).value.trim();
|
||||
if (v) dev[map[id]] = v;
|
||||
});
|
||||
api("POST", "/accounts/add_qoder", {
|
||||
name: $("qoder-name").value.trim(),
|
||||
access_token: token,
|
||||
refresh_token: $("qoder-refresh").value.trim(),
|
||||
domain: $("qoder-domain").value,
|
||||
device: dev
|
||||
}).then(function () {
|
||||
toast("Qoder 账号已添加", "ok");
|
||||
closeModal("modal-add"); refreshState();
|
||||
}).catch(function (e) { addMsg(e.message, false); });
|
||||
});
|
||||
|
||||
// ---- workbuddy oauth flow
|
||||
function stopOauth() {
|
||||
if (oauthTimer) { clearInterval(oauthTimer); oauthTimer = null; }
|
||||
oauthState = null;
|
||||
$("oauth-box").classList.add("hidden");
|
||||
}
|
||||
|
||||
$("btn-oauth-begin").addEventListener("click", function () {
|
||||
this.disabled = true;
|
||||
api("POST", "/wb_oauth_begin", {})
|
||||
.then(function (d) {
|
||||
oauthState = d.state;
|
||||
$("oauth-url").value = d.login_url;
|
||||
$("oauth-box").classList.remove("hidden");
|
||||
$("oauth-status").textContent = "等待登录…(链接 " + Math.round(d.ttl / 60) + " 分钟内有效)";
|
||||
oauthTimer = setInterval(pollOauth, 2000);
|
||||
})
|
||||
.catch(function (e) { addMsg(e.message, false); })
|
||||
.finally(function () { this.disabled = false; }.bind(this));
|
||||
});
|
||||
|
||||
$("btn-oauth-copy").addEventListener("click", function () {
|
||||
var input = $("oauth-url");
|
||||
input.select();
|
||||
var done = function () { toast("链接已复制", "ok"); };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(input.value).then(done, function () { document.execCommand("copy"); done(); });
|
||||
} else { document.execCommand("copy"); done(); }
|
||||
});
|
||||
|
||||
function pollOauth() {
|
||||
if (!oauthState) return;
|
||||
api("GET", "/wb_oauth_poll?state=" + encodeURIComponent(oauthState))
|
||||
.then(function (d) {
|
||||
if (d.status === "done") {
|
||||
stopOauth();
|
||||
toast("WorkBuddy 账号已绑定", "ok");
|
||||
closeModal("modal-add");
|
||||
refreshState();
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
$("oauth-status").textContent = e.message;
|
||||
stopOauth();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- logs
|
||||
function loadLogs() {
|
||||
api("GET", "/logs?lines=300").then(function (d) {
|
||||
$("log-view").textContent = (d.lines || []).join("\n") || "暂无日志";
|
||||
$("log-view").scrollTop = $("log-view").scrollHeight;
|
||||
}).catch(function () {
|
||||
$("log-view").textContent = "日志加载失败";
|
||||
});
|
||||
}
|
||||
$("btn-refresh-logs").addEventListener("click", loadLogs);
|
||||
setInterval(function () {
|
||||
if (document.hidden) return;
|
||||
if ($("log-autorefresh").checked && $("tab-logs").classList.contains("active")) loadLogs();
|
||||
}, 15000);
|
||||
|
||||
// ---------------------------------------------------------------- settings
|
||||
// Local editing copy: initialised from server state ONLY when the tab is
|
||||
// opened (and there are no unsaved edits). The 10s state poll must never
|
||||
// overwrite it, otherwise in-progress edits are silently discarded.
|
||||
var settings = null;
|
||||
var settingsDirty = false;
|
||||
|
||||
function openSettingsTab() {
|
||||
if (!settingsDirty) {
|
||||
settings = JSON.parse(JSON.stringify(
|
||||
(state && state.settings) || { schedule_times: [], webhook_url: "", notify_on: [] }));
|
||||
}
|
||||
renderSettings();
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
// renders ONLY from the local copy — never re-reads from `state` here
|
||||
settings = settings || { schedule_times: [], webhook_url: "", notify_on: [] };
|
||||
var chips = $("time-chips");
|
||||
chips.innerHTML = "";
|
||||
(settings.schedule_times || []).forEach(function (t) {
|
||||
var el = document.createElement("span");
|
||||
el.className = "time-chip";
|
||||
el.innerHTML = esc(t) + '<button title="删除">×</button>';
|
||||
el.querySelector("button").addEventListener("click", function () {
|
||||
settings.schedule_times = settings.schedule_times.filter(function (x) { return x !== t; });
|
||||
settingsDirty = true;
|
||||
renderSettings();
|
||||
});
|
||||
chips.appendChild(el);
|
||||
});
|
||||
$("webhook-url").value = settings.webhook_url || "";
|
||||
$("webhook-type").value = settings.webhook_type || "auto";
|
||||
$("notify-fail").checked = (settings.notify_on || []).indexOf("fail") >= 0;
|
||||
$("notify-success").checked = (settings.notify_on || []).indexOf("success") >= 0;
|
||||
}
|
||||
|
||||
$("btn-add-time").addEventListener("click", function () {
|
||||
var v = $("new-time").value;
|
||||
if (!v) { toast("请先选择要添加的时间", "err"); return; }
|
||||
settings.schedule_times = settings.schedule_times || [];
|
||||
if (settings.schedule_times.indexOf(v) >= 0) {
|
||||
toast("该时间已存在", "err");
|
||||
return;
|
||||
}
|
||||
settings.schedule_times.push(v);
|
||||
settings.schedule_times.sort();
|
||||
settingsDirty = true;
|
||||
renderSettings();
|
||||
$("new-time").value = "";
|
||||
});
|
||||
|
||||
$("btn-save-settings").addEventListener("click", function () {
|
||||
var times = (settings.schedule_times || []).slice().sort();
|
||||
if (!times.length) { $("settings-msg").textContent = "至少保留一个签到时间点"; $("settings-msg").className = "msg err"; return; }
|
||||
var notify = [];
|
||||
if ($("notify-fail").checked) notify.push("fail");
|
||||
if ($("notify-success").checked) notify.push("success");
|
||||
api("POST", "/settings", {
|
||||
schedule_times: times,
|
||||
webhook_url: $("webhook-url").value.trim(),
|
||||
webhook_type: $("webhook-type").value,
|
||||
notify_on: notify
|
||||
}).then(function () {
|
||||
settingsDirty = false;
|
||||
$("settings-msg").textContent = "已保存";
|
||||
$("settings-msg").className = "msg ok";
|
||||
toast("设置已保存", "ok");
|
||||
refreshState();
|
||||
}).catch(function (e) {
|
||||
$("settings-msg").textContent = e.message;
|
||||
$("settings-msg").className = "msg err";
|
||||
});
|
||||
});
|
||||
|
||||
$("btn-test-notify").addEventListener("click", function () {
|
||||
var url = $("webhook-url").value.trim();
|
||||
if (!url) { $("settings-msg").textContent = "请先填写 Webhook 地址"; $("settings-msg").className = "msg err"; return; }
|
||||
// save first so the daemon/notify uses the new url, then fire a real run
|
||||
var notify = [];
|
||||
if ($("notify-fail").checked) notify.push("fail");
|
||||
if ($("notify-success").checked) notify.push("success");
|
||||
api("POST", "/settings", { webhook_url: url, webhook_type: $("webhook-type").value, notify_on: notify })
|
||||
.then(function () {
|
||||
settingsDirty = false;
|
||||
return api("POST", "/checkin", {});
|
||||
})
|
||||
.then(function () {
|
||||
$("settings-msg").textContent = "已触发一次签到,通知将随结果发送";
|
||||
$("settings-msg").className = "msg ok";
|
||||
waitForRun(null);
|
||||
})
|
||||
.catch(function (e) {
|
||||
$("settings-msg").textContent = e.message;
|
||||
$("settings-msg").className = "msg err";
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- run all
|
||||
$("btn-run-all").addEventListener("click", function () { runCheckin(null, null); });
|
||||
|
||||
// ---------------------------------------------------------------- boot
|
||||
var STATE_POLL_MS = 30000;
|
||||
function boot() {
|
||||
refreshState(true);
|
||||
renderAccounts();
|
||||
pollTimer = setInterval(function () {
|
||||
if (document.hidden) return; // 页面/iframe 不可见时暂停轮询
|
||||
refreshState().then(renderAccountsIfVisible);
|
||||
}, STATE_POLL_MS);
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (!document.hidden) refreshState(true).then(renderAccountsIfVisible);
|
||||
});
|
||||
}
|
||||
function renderAccountsIfVisible() {
|
||||
if ($("tab-accounts").classList.contains("active")) renderAccounts();
|
||||
}
|
||||
boot();
|
||||
})();
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3b6cff"/>
|
||||
<stop offset="1" stop-color="#7a5af8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#g)"/>
|
||||
<rect x="14" y="16" width="36" height="34" rx="6" fill="none" stroke="#fff" stroke-width="3.5"/>
|
||||
<rect x="22" y="11" width="4.5" height="8" rx="2.25" fill="#fff"/>
|
||||
<rect x="37.5" y="11" width="4.5" height="8" rx="2.25" fill="#fff"/>
|
||||
<line x1="14" y1="26" x2="50" y2="26" stroke="#fff" stroke-width="3.5"/>
|
||||
<path d="M23 38 L29 44 L42 31" fill="none" stroke="#7ef0b2" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 769 B |
@@ -0,0 +1,241 @@
|
||||
/* AgentCheckin — fnOS app styles */
|
||||
:root {
|
||||
--bg: #f5f6f8;
|
||||
--panel: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--text: #1f2329;
|
||||
--muted: #8a909a;
|
||||
--primary: #3b6cff;
|
||||
--primary-dark: #2f57d6;
|
||||
--green: #16a34a;
|
||||
--green-bg: #eaf7ef;
|
||||
--red: #dc2626;
|
||||
--red-bg: #fdecec;
|
||||
--amber: #d97706;
|
||||
--amber-bg: #fdf3e3;
|
||||
--blue-bg: #e8efff;
|
||||
--gray-bg: #f0f1f3;
|
||||
--radius: 12px;
|
||||
--shadow: 0 1px 3px rgba(16, 24, 40, .06), 0 1px 2px rgba(16, 24, 40, .04);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 16px; flex-wrap: wrap;
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(135deg, #3b6cff 0%, #7a5af8 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 14px; }
|
||||
.brand-logo { width: 44px; height: 44px; }
|
||||
.brand h1 { font-size: 18px; margin: 0; font-weight: 600; }
|
||||
.brand .sub { margin: 0; font-size: 12px; opacity: .85; }
|
||||
.topbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 12px; border-radius: 999px;
|
||||
font-size: 12px; font-weight: 500;
|
||||
background: rgba(255,255,255,.22); color: #fff;
|
||||
}
|
||||
.chip .dot { width: 8px; height: 8px; border-radius: 50%; background: #fff; }
|
||||
.chip.chip-green .dot { background: #4ade80; }
|
||||
.chip.chip-red .dot { background: #f87171; }
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
.tabs {
|
||||
display: flex; gap: 4px;
|
||||
padding: 0 24px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.tab {
|
||||
border: none; background: none; cursor: pointer;
|
||||
padding: 12px 18px; font-size: 14px; color: var(--muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
|
||||
main { max-width: 1080px; margin: 0 auto; padding: 20px 24px 60px; }
|
||||
.tabpage { display: none; }
|
||||
.tabpage.active { display: block; }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
border: 1px solid var(--border); background: var(--panel); color: var(--text);
|
||||
border-radius: 8px; padding: 7px 14px; font-size: 13px; cursor: pointer;
|
||||
transition: all .15s ease; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: #c7cdd6; background: #fafbfc; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||
.btn-danger { color: var(--red); border-color: #f3c2c2; }
|
||||
.btn-danger:hover { background: var(--red-bg); border-color: var(--red); }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
|
||||
|
||||
/* ---------- panels & cards ---------- */
|
||||
.panel {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow);
|
||||
padding: 20px; margin-bottom: 20px;
|
||||
}
|
||||
.panel h2 { margin: 0 0 12px; font-size: 15px; font-weight: 600; }
|
||||
.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.panel-head h2 { margin: 0; }
|
||||
.muted { color: var(--muted); font-size: 13px; margin: 4px 0; }
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 14px; margin-bottom: 20px;
|
||||
}
|
||||
.acct-card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow);
|
||||
padding: 16px; display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.acct-card.disabled { opacity: .62; }
|
||||
.acct-head { display: flex; align-items: center; gap: 8px; }
|
||||
.platform-badge {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .4px;
|
||||
padding: 3px 8px; border-radius: 6px;
|
||||
}
|
||||
.platform-badge.trae { background: #e8f3ff; color: #0a5cd8; }
|
||||
.platform-badge.workbuddy { background: #e8f7ef; color: #0a8a4a; }
|
||||
.platform-badge.qoder { background: #f3eaff; color: #7c3aed; }
|
||||
.acct-name { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.acct-status { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.status-chip { padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; }
|
||||
.status-chip.ok { background: var(--green-bg); color: var(--green); }
|
||||
.status-chip.already { background: var(--blue-bg); color: var(--primary); }
|
||||
.status-chip.pending { background: var(--gray-bg); color: var(--muted); }
|
||||
.status-chip.fail, .status-chip.no_session, .status-chip.no_auth { background: var(--red-bg); color: var(--red); }
|
||||
.status-chip.inactive, .status-chip.retry_later, .status-chip.ok_unverified { background: var(--amber-bg); color: var(--amber); }
|
||||
.acct-meta { font-size: 12px; color: var(--muted); display: flex; flex-direction: column; gap: 2px; }
|
||||
.acct-meta b { color: var(--text); font-weight: 600; }
|
||||
.acct-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
|
||||
|
||||
.hint-list { margin: 0; padding-left: 20px; color: var(--text); }
|
||||
.hint-list li { margin-bottom: 6px; }
|
||||
|
||||
/* ---------- table ---------- */
|
||||
table.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.tbl th {
|
||||
text-align: left; padding: 8px 10px; color: var(--muted);
|
||||
font-weight: 500; font-size: 12px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tbl td { padding: 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
.tbl tr:last-child td { border-bottom: none; }
|
||||
.tbl .actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
/* ---------- forms ---------- */
|
||||
.input {
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 8px 12px; font-size: 13px; font-family: inherit;
|
||||
background: #fff; color: var(--text);
|
||||
outline: none; transition: border-color .15s;
|
||||
}
|
||||
.input:focus { border-color: var(--primary); }
|
||||
.input-wide { width: 100%; }
|
||||
.input-time { width: 140px; }
|
||||
label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 12px; }
|
||||
label .input { margin-top: 6px; }
|
||||
.req { color: var(--red); }
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.switch-label { display: inline-flex; align-items: center; gap: 6px; font-weight: 400; margin-bottom: 0; }
|
||||
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.time-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: var(--blue-bg); color: var(--primary);
|
||||
border-radius: 999px; padding: 4px 8px 4px 12px; font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.time-chip button {
|
||||
border: none; background: rgba(59,108,255,.15); color: var(--primary);
|
||||
width: 18px; height: 18px; border-radius: 50%; cursor: pointer;
|
||||
font-size: 12px; line-height: 1; display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.time-chip button:hover { background: var(--primary); color: #fff; }
|
||||
|
||||
.msg { margin-top: 10px; font-size: 13px; min-height: 20px; }
|
||||
.msg.ok { color: var(--green); }
|
||||
.msg.err { color: var(--red); }
|
||||
|
||||
/* ---------- modal ---------- */
|
||||
.modal-mask {
|
||||
position: fixed; inset: 0; background: rgba(15, 18, 25, .45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100; padding: 20px;
|
||||
}
|
||||
.modal-mask.hidden { display: none; }
|
||||
.modal {
|
||||
background: #fff; border-radius: 14px; width: 100%; max-width: 520px;
|
||||
max-height: 90vh; overflow-y: auto; padding: 22px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,.18);
|
||||
}
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.modal-head h3 { margin: 0; font-size: 16px; }
|
||||
.modal-close {
|
||||
border: none; background: var(--gray-bg); width: 28px; height: 28px;
|
||||
border-radius: 50%; font-size: 16px; cursor: pointer; color: var(--muted);
|
||||
}
|
||||
.modal-close:hover { background: var(--border); color: var(--text); }
|
||||
.modal-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
|
||||
.mtab {
|
||||
border: none; background: none; padding: 8px 12px; cursor: pointer;
|
||||
font-size: 13px; color: var(--muted); border-bottom: 2px solid transparent;
|
||||
}
|
||||
.mtab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.mtabpage { display: none; }
|
||||
.mtabpage.active { display: block; }
|
||||
|
||||
.help { border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 14px; background: #fafbfc; }
|
||||
.help summary { cursor: pointer; font-size: 13px; font-weight: 500; color: var(--primary); }
|
||||
.help-body { margin-top: 10px; font-size: 13px; }
|
||||
.help-body p { margin: 6px 0; }
|
||||
.help-body code { background: var(--gray-bg); padding: 2px 6px; border-radius: 4px; font-size: 12px; }
|
||||
|
||||
/* ---------- logs ---------- */
|
||||
.log-view {
|
||||
background: #0f1420; color: #c9d4e5; border-radius: 10px;
|
||||
padding: 14px 16px; font-size: 12px; line-height: 1.7;
|
||||
font-family: "SF Mono", Menlo, Consolas, "Courier New", monospace;
|
||||
max-height: 560px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---------- toast ---------- */
|
||||
.toast {
|
||||
position: fixed; left: 50%; bottom: 36px; transform: translateX(-50%);
|
||||
background: #1f2329; color: #fff; padding: 10px 20px;
|
||||
border-radius: 10px; font-size: 13px; z-index: 200;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.25);
|
||||
max-width: 80vw;
|
||||
}
|
||||
.toast.hidden { display: none; }
|
||||
.toast.err { background: var(--red); }
|
||||
.toast.ok { background: var(--green); }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar { padding: 14px 16px; }
|
||||
.tabs { padding: 0 12px; }
|
||||
main { padding: 16px 12px 40px; }
|
||||
.tab { padding: 10px 12px; }
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Agent 签到助手</title>
|
||||
<link rel="stylesheet" href="assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<img src="assets/logo.svg" alt="" class="brand-logo">
|
||||
<div>
|
||||
<h1>Agent 签到助手</h1>
|
||||
<p class="sub">TRAE · WorkBuddy 每日积分自动签到</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<span id="daemon-chip" class="chip chip-gray">守护进程 —</span>
|
||||
<span id="nextrun-chip" class="chip chip-blue">下次签到 —</span>
|
||||
<button id="btn-run-all" class="btn btn-primary">立即签到</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="dashboard">仪表盘</button>
|
||||
<button class="tab" data-tab="accounts">账号管理</button>
|
||||
<button class="tab" data-tab="logs">签到记录</button>
|
||||
<button class="tab" data-tab="settings">设置</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<!-- ======================= 仪表盘 ======================= -->
|
||||
<section id="tab-dashboard" class="tabpage active">
|
||||
<div id="dash-cards" class="card-grid"></div>
|
||||
<div class="panel">
|
||||
<h2>使用说明</h2>
|
||||
<ul class="hint-list">
|
||||
<li><b>TRAE</b>(字节跳动·国内版):每日自动领取 <b>200</b> Work 专属积分。</li>
|
||||
<li><b>WorkBuddy / CodeBuddy</b>(腾讯):7 天循环签到,第 1-6 天每天 100 分,第 7 天 1000 分。</li>
|
||||
<li><b>Qoder</b>(国内版):每日自动领取 <b>100</b> Credits(活动非零点准时下发,多时段自动补领)。</li>
|
||||
<li>签到由 NAS 本地守护进程自动执行,失败会在下一个时段自动补签; Token 过期会标记需重新授权。</li>
|
||||
<li>所有凭据仅保存在本机 <code>@appdata/AgentCheckin</code>,不会上传到任何第三方。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= 账号管理 ======================= -->
|
||||
<section id="tab-accounts" class="tabpage">
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>账号列表</h2>
|
||||
<button class="btn btn-primary" id="btn-add-account">+ 添加账号</button>
|
||||
</div>
|
||||
<div id="account-table-wrap"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= 签到记录 ======================= -->
|
||||
<section id="tab-logs" class="tabpage">
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>运行日志</h2>
|
||||
<div>
|
||||
<label class="switch-label"><input type="checkbox" id="log-autorefresh" checked> 自动刷新</label>
|
||||
<button class="btn" id="btn-refresh-logs">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="log-view" class="log-view">加载中…</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= 设置 ======================= -->
|
||||
<section id="tab-settings" class="tabpage">
|
||||
<div class="panel">
|
||||
<h2>签到时间</h2>
|
||||
<p class="muted">每天在这些时间点自动检查并签到(已签会自动跳过)。错过时段后在守护进程启动时会自动补跑。</p>
|
||||
<div id="time-chips" class="chips"></div>
|
||||
<div class="row">
|
||||
<input type="time" id="new-time" class="input input-time">
|
||||
<button class="btn" id="btn-add-time">添加时间点</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>消息通知(可选)</h2>
|
||||
<p class="muted">签到后向 Webhook 发送 POST JSON(<code>{"title","content","text","msgtype"}</code>),兼容企业微信/钉钉/飞书/Bark 等机器人。留空则不通知。</p>
|
||||
<input type="url" id="webhook-url" class="input input-wide" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...">
|
||||
<label style="margin-top:12px">通知平台
|
||||
<select id="webhook-type" class="input">
|
||||
<option value="auto">自动识别(推荐)</option>
|
||||
<option value="wecom">企业微信机器人</option>
|
||||
<option value="dingtalk">钉钉机器人</option>
|
||||
<option value="feishu">飞书机器人</option>
|
||||
<option value="bark">Bark(iOS 推送)</option>
|
||||
<option value="generic">通用 JSON(Server酱 / PushPlus 等)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="row">
|
||||
<label class="switch-label"><input type="checkbox" id="notify-fail" checked> 失败时通知</label>
|
||||
<label class="switch-label"><input type="checkbox" id="notify-success"> 成功时也通知</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn btn-primary" id="btn-save-settings">保存设置</button>
|
||||
<button class="btn" id="btn-test-notify">发送测试通知</button>
|
||||
</div>
|
||||
<div id="settings-msg" class="msg"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- ======================= 添加账号弹窗 ======================= -->
|
||||
<div id="modal-add" class="modal-mask hidden">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h3>添加账号</h3>
|
||||
<button class="modal-close" data-close="modal-add">×</button>
|
||||
</div>
|
||||
<div class="modal-tabs">
|
||||
<button class="mtab active" data-mtab="wb-oauth">WorkBuddy 网页授权</button>
|
||||
<button class="mtab" data-mtab="wb-manual">WorkBuddy 手动</button>
|
||||
<button class="mtab" data-mtab="trae">TRAE</button>
|
||||
<button class="mtab" data-mtab="qoder">Qoder</button>
|
||||
</div>
|
||||
|
||||
<div id="mtab-wb-oauth" class="mtabpage active">
|
||||
<p>点击下方按钮生成授权链接,在浏览器中打开并登录腾讯 CodeBuddy / WorkBuddy 账号,本页面会自动完成绑定。</p>
|
||||
<button class="btn btn-primary" id="btn-oauth-begin">生成授权链接</button>
|
||||
<div id="oauth-box" class="hidden">
|
||||
<p>请在浏览器中打开以下链接完成登录(10 分钟内有效):</p>
|
||||
<div class="row">
|
||||
<input id="oauth-url" class="input input-wide" readonly>
|
||||
<button class="btn" id="btn-oauth-copy">复制</button>
|
||||
</div>
|
||||
<p id="oauth-status" class="muted">等待登录…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mtab-wb-manual" class="mtabpage">
|
||||
<label>名称(可选)<input id="wb-name" class="input input-wide" placeholder="我的 CodeBuddy"></label>
|
||||
<label>Access Token <span class="req">*</span><input id="wb-token" class="input input-wide" placeholder="accessToken"></label>
|
||||
<label>Refresh Token(可选,用于自动续期)<input id="wb-refresh" class="input input-wide" placeholder="refreshToken"></label>
|
||||
<label>区域
|
||||
<select id="wb-domain" class="input">
|
||||
<option value="codebuddy.cn">国内版 codebuddy.cn(支持签到)</option>
|
||||
<option value="workbuddy.ai">国际版 workbuddy.ai(无签到)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn btn-primary" id="btn-wb-manual-add">添加</button>
|
||||
</div>
|
||||
|
||||
<div id="mtab-trae" class="mtabpage">
|
||||
<label>名称(可选)<input id="trae-name" class="input input-wide" placeholder="TRAE 主账号"></label>
|
||||
<label>Access Token <span class="req">*</span><input id="trae-token" class="input input-wide" placeholder="eyJ 开头的 JWT"></label>
|
||||
<label>Refresh Token(可选)<input id="trae-refresh" class="input input-wide" placeholder="可选"></label>
|
||||
<details class="help">
|
||||
<summary>如何获取 TRAE Token?</summary>
|
||||
<div class="help-body">
|
||||
<p>在登录了 TRAE 国内版客户端的电脑上,凭据保存在:</p>
|
||||
<p><code>Windows: %APPDATA%\Trae CN\User\globalStorage\storage.json</code></p>
|
||||
<p>其中键 <code>iCubeAuthInfo://icube.cloudide</code> 的值为加密串,解密后可得 <code>token</code>(即 Access Token,JWT 格式)。</p>
|
||||
<p>也可使用社区工具 <code>trae_get_token.py</code>(Trae-AutoCheckin 项目)一键提取。</p>
|
||||
<p class="muted">Token 约 60 天有效;过期后在本页重新粘贴即可。</p>
|
||||
</div>
|
||||
</details>
|
||||
<button class="btn btn-primary" id="btn-trae-add">添加</button>
|
||||
</div>
|
||||
|
||||
<div id="mtab-qoder" class="mtabpage">
|
||||
<label>名称(可选)<input id="qoder-name" class="input input-wide" placeholder="Qoder 主账号"></label>
|
||||
<label>Access Token <span class="req">*</span><input id="qoder-token" class="input input-wide" placeholder="dt- 开头的 JWT"></label>
|
||||
<label>Refresh Token(可选,用于自动续期)<input id="qoder-refresh" class="input input-wide" placeholder="drt- 开头,可选"></label>
|
||||
<label>站点区域(重要:两个站点 Token 不互通,选错会 401)
|
||||
<select id="qoder-domain" class="input">
|
||||
<option value="openapi.qoder.com.cn">国内版 openapi.qoder.com.cn(qoder.cn 客户端)</option>
|
||||
<option value="openapi.qoder.sh">国际版 openapi.qoder.sh(qoder.com 客户端)</option>
|
||||
</select>
|
||||
</label>
|
||||
<details class="help" open>
|
||||
<summary>如何获取 Qoder Token?(三种方式)</summary>
|
||||
<div class="help-body">
|
||||
<p><b>方式 A · 设备授权登录(推荐,无需抓包)</b></p>
|
||||
<p>在电脑上下载社区工具 <code>qoder_login.py</code>(GitHub: L0NE-6/Qoder-Daily),运行 <code>python qoder_login.py</code>,在浏览器打开授权页确认一次,工具会输出 <code>access_token|refresh_token</code>,分别粘贴到上方即可。</p>
|
||||
<p><b>方式 B · 浏览器抓包(最快)</b></p>
|
||||
<p>浏览器登录 Qoder 网页版 → F12 开发者工具 → 网络 → 点页面里任意功能,任选一个请求 → 在请求头里找到 <code>authorization: Bearer dt-...</code></p>
|
||||
<p><b>复制技巧</b>:右键该值 →「Copy value」复制完整内容,粘贴后删掉开头的 <code>Bearer </code>。</p>
|
||||
<p><b>注意</b>:Qoder 的 <code>dt-</code> Token 原生就只有 <b>27/28 个字符</b>(不是截断,这是它的原生格式),<code>drt-</code> 刷新串同理。</p>
|
||||
<p><b>关键</b>:国内版(qoder.cn)与国际版(qoder.com)的 Token <b>不互通</b>——用哪个客户端登录提的 Token,上方「站点区域」就要选对应的那个,选错会直接 401。拿不准可以先用「测连接」,它会自动探测 Token 属于哪个站点。</p>
|
||||
<p><b>方式 C · 本机客户端提取</b></p>
|
||||
<p>在装了并登录 Qoder 客户端的 Windows 上运行社区工具 <code>01_extract.py</code>(GitHub: wallechfox/qoder-checkin),自动解密 <code>auth.v1.dat</code> 取出 Token 和设备标识。</p>
|
||||
<p class="muted">Token 有效期较短,过期后重新获取即可;填了 Refresh Token 的应用会自动续期。</p>
|
||||
</div>
|
||||
</details>
|
||||
<details class="help">
|
||||
<summary>设备标识(可选,领取提示风控时填写)</summary>
|
||||
<div class="help-body">
|
||||
<p>Qoder 签到需要 Cosy-* 设备标识。一般情况留空即可;若提示风控/领取失败,用方式 C 的工具提取后填入:</p>
|
||||
<div class="row"><input id="qoder-machine-id" class="input input-wide" placeholder="Cosy-MachineId"></div>
|
||||
<div class="row"><input id="qoder-machine-token" class="input input-wide" placeholder="Cosy-MachineToken"></div>
|
||||
<div class="row"><input id="qoder-machine-code" class="input input-wide" placeholder="Cosy-MachineCode"></div>
|
||||
<div class="row"><input id="qoder-machine-type" class="input input-wide" placeholder="Cosy-MachineType"></div>
|
||||
<div class="row"><input id="qoder-version" class="input input-wide" placeholder="Cosy-Version(客户端版本号)"></div>
|
||||
</div>
|
||||
</details>
|
||||
<button class="btn btn-primary" id="btn-qoder-add">添加</button>
|
||||
</div>
|
||||
<div id="add-msg" class="msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script src="assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
# Initialize the AgentCheckin data directory and default settings.
|
||||
DATA_DIR="${TRIM_PKGVAR:-}"
|
||||
if [ -n "$DATA_DIR" ] && [ -d "$DATA_DIR" ]; then
|
||||
mkdir -p "${DATA_DIR}/logs"
|
||||
[ -f "${DATA_DIR}/accounts.json" ] || printf '{"accounts":[]}\n' > "${DATA_DIR}/accounts.json"
|
||||
[ -f "${DATA_DIR}/settings.json" ] || printf '{"schedule_times":["00:05","09:00","12:30","19:00"],"webhook_url":"","notify_on":["fail"]}\n' > "${DATA_DIR}/settings.json"
|
||||
[ -f "${DATA_DIR}/state.json" ] || printf '{}\n' > "${DATA_DIR}/state.json"
|
||||
chmod 600 "${DATA_DIR}/accounts.json" "${DATA_DIR}/settings.json" 2>/dev/null
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
# AgentCheckin service control.
|
||||
# The scheduler daemon runs as a detached background process and manages its
|
||||
# own pidfile in the persistent data dir (/vol{n}/@appdata/AgentCheckin).
|
||||
|
||||
PYBIN=""
|
||||
for candidate in \
|
||||
/var/apps/python312/target/bin/python3 \
|
||||
/vol1/@appcenter/python312/target/bin/python3 \
|
||||
/var/apps/python311/target/bin/python3 \
|
||||
/var/apps/python310/target/bin/python3 \
|
||||
/usr/bin/python3 \
|
||||
/usr/local/bin/python3; do
|
||||
if [ -x "$candidate" ]; then PYBIN="$candidate"; break; fi
|
||||
done
|
||||
|
||||
APPDEST="${TRIM_APPDEST:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
BACKEND_DIR="${APPDEST}/target/backend"
|
||||
if [ ! -d "$BACKEND_DIR" ]; then
|
||||
BACKEND_DIR="${APPDEST}/backend"
|
||||
fi
|
||||
DAEMON="${BACKEND_DIR}/daemon.py"
|
||||
|
||||
# Resolve the persistent data dir (mirrors backend/common.py).
|
||||
if [ -n "${TRIM_PKGVAR}" ] && [ -d "${TRIM_PKGVAR}" ]; then
|
||||
DATA_DIR="${TRIM_PKGVAR}"
|
||||
else
|
||||
DATA_DIR=""
|
||||
for candidate in \
|
||||
"${APPDEST}/../../@appdata/AgentCheckin" \
|
||||
"${APPDEST}/var" \
|
||||
"/var/apps/AgentCheckin/var" \
|
||||
"/vol1/@appdata/AgentCheckin" \
|
||||
"/vol2/@appdata/AgentCheckin"; do
|
||||
if [ -d "$candidate" ]; then DATA_DIR="$candidate"; break; fi
|
||||
done
|
||||
if [ -z "$DATA_DIR" ]; then
|
||||
DATA_DIR="${APPDEST}/../../@appdata/AgentCheckin"
|
||||
fi
|
||||
fi
|
||||
PIDFILE="${DATA_DIR}/daemon.pid"
|
||||
|
||||
daemon_alive() {
|
||||
[ -f "$PIDFILE" ] || return 1
|
||||
local pid
|
||||
pid="$(cat "$PIDFILE" 2>/dev/null)"
|
||||
[ -n "$pid" ] || return 1
|
||||
kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
if daemon_alive; then
|
||||
echo "AgentCheckin daemon already running (pid $(cat "$PIDFILE"))"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$PYBIN" ]; then
|
||||
echo "python3 runtime not found (install_dep_apps=python312)" > "${TRIM_TEMP_LOGFILE:-/dev/stderr}"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$DATA_DIR"
|
||||
AGENTCHECKIN_DATA_DIR="$DATA_DIR" setsid nohup "$PYBIN" "$DAEMON" >> "${DATA_DIR}/daemon.out" 2>&1 &
|
||||
# wait for the daemon to write its pidfile (max ~5s)
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if daemon_alive; then
|
||||
echo "AgentCheckin daemon started (pid $(cat "$PIDFILE"))"
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
echo "AgentCheckin daemon failed to start; see ${DATA_DIR}/daemon.out" > "${TRIM_TEMP_LOGFILE:-/dev/stderr}"
|
||||
exit 1
|
||||
;;
|
||||
stop)
|
||||
if daemon_alive; then
|
||||
pid="$(cat "$PIDFILE")"
|
||||
kill "$pid" 2>/dev/null
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
kill -0 "$pid" 2>/dev/null || break
|
||||
sleep 0.5
|
||||
done
|
||||
kill -9 "$pid" 2>/dev/null
|
||||
echo "AgentCheckin daemon stopped (pid $pid)"
|
||||
else
|
||||
echo "AgentCheckin daemon not running"
|
||||
fi
|
||||
rm -f "$PIDFILE"
|
||||
exit 0
|
||||
;;
|
||||
status)
|
||||
if daemon_alive; then
|
||||
exit 0
|
||||
fi
|
||||
exit 3
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Stop the daemon before uninstall (best effort).
|
||||
DATA_DIR="${TRIM_PKGVAR:-}"
|
||||
if [ -n "$DATA_DIR" ] && [ -f "${DATA_DIR}/daemon.pid" ]; then
|
||||
pid="$(cat "${DATA_DIR}/daemon.pid" 2>/dev/null)"
|
||||
[ -n "$pid" ] && kill "$pid" 2>/dev/null
|
||||
rm -f "${DATA_DIR}/daemon.pid"
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"defaults":
|
||||
{
|
||||
"run-as": "package"
|
||||
},
|
||||
"username": "agentcheckin",
|
||||
"groupname": "agentcheckin"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,16 @@
|
||||
appname = AgentCheckin
|
||||
version = 1.2.2
|
||||
display_name = Agent 签到助手
|
||||
desc = AI 编程工具每日积分自动签到:支持 TRAE(字节跳动,国内版每日 200 积分)与 WorkBuddy / CodeBuddy(腾讯,7 天循环签到最高 1000 积分/天)。多账号管理、多时段自动补签、积分看板、签到日志、Webhook 通知,数据全部保存在 NAS 本地。
|
||||
platform = all
|
||||
source = thirdparty
|
||||
maintainer = AgentCheckin
|
||||
maintainer_url = https://club.fnnas.com
|
||||
distributor = AgentCheckin
|
||||
distributor_url = https://club.fnnas.com
|
||||
os_min_version = 1.2.0
|
||||
install_dep_apps = python312
|
||||
desktop_uidir = ui
|
||||
desktop_applaunchname = AgentCheckin.Application
|
||||
checkport = false
|
||||
disable_authorization_path = true
|
||||
@@ -0,0 +1,234 @@
|
||||
# Agent 签到助手(AgentCheckin)· 飞牛 fnOS 应用
|
||||
|
||||
TRAE、WorkBuddy / CodeBuddy 等 AI 编程工具的**每日积分自动签到**系统,以飞牛 fnOS 第三方应用(`.fpk`)形式运行。签到由 NAS 本地守护进程自动执行,多账号、多时段补签、积分看板、运行日志一应俱全。
|
||||
|
||||
> 非官方工具,仅供个人学习交流。接口来自社区逆向与实测,可能随官方调整失效。
|
||||
|
||||
## 每天能领多少
|
||||
|
||||
| 平台 | 规则 | 每月约 |
|
||||
|---|---|---|
|
||||
| TRAE(字节跳动·国内版) | 每天 200 Work 专属积分 | ≈ 6200 分 |
|
||||
| WorkBuddy / CodeBuddy(腾讯) | 第 1-6 天每天 100 分,第 7 天 1000 分(7 天循环) | ≈ 6800 分 |
|
||||
| Qoder(国内版) | 每天 100 Credits(活动非零点准时下发,自动补领) | ≈ 3000 Credits |
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **三平台签到**:TRAE(字节·国内版,每日 200 积分)+ WorkBuddy / CodeBuddy(腾讯,7 天循环最高 1000 分/天)+ Qoder(国内版,每日 100 Credits)
|
||||
- **多账号管理**:任意数量账号,独立启用/禁用,互不影响
|
||||
- **WorkBuddy 网页授权**:生成官方登录链接,浏览器扫码/登录后自动绑定,无需手动抄 Token
|
||||
- **多时段自动补签**:默认每天 00:05 / 09:00 / 12:30 / 19:00 四个时段,已签自动跳过;NAS 重启后自动补跑
|
||||
- **幂等安全**:每次签到前先查状态,已签跳过;领取后回查服务器确认到账
|
||||
- **TRAE 风控自愈**:触发 9074 设备号风控时自动更换设备号重试
|
||||
- **Token 自动续期**:WorkBuddy 过期前自动用 refreshToken 续期;失效账号标记「需重新授权」
|
||||
- **积分看板**:TRAE 当前积分、WorkBuddy 剩余额度实时展示
|
||||
- **Webhook 通知**(可选):签到结果推送到企业微信/钉钉/飞书/Bark/Server酱/PushPlus 等机器人(按 URL 自动识别平台格式,也可手动指定)
|
||||
- **数据本地化**:所有凭据仅保存在 NAS `@appdata/AgentCheckin`,不明文展示、不上传
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式一:自动安装(推荐,已在 192.168.31.10 验证通过)
|
||||
|
||||
```bash
|
||||
cd fnos-agent-checkin
|
||||
./build.sh # 打包 AgentCheckin.fpk
|
||||
FNOS_HOST=192.168.31.10 \
|
||||
FNOS_USER=你的NAS管理员 \
|
||||
FNOS_PASS=你的密码 \
|
||||
node fnos-install.js # 自动上传并安装
|
||||
```
|
||||
|
||||
安装脚本会自动处理:
|
||||
- WebSocket 登录 → ticket → ost 会话 Cookie
|
||||
- **依赖检测**:`install_dep_apps=python312` 未安装时安装会失败(code 16005),
|
||||
需先在应用中心安装「Python 3.12」(或参考 `install_python312_full.js` 的云应用下载流程)
|
||||
- **重复安装**:已安装时自动先卸载再安装(`@appdata` 数据保留)
|
||||
|
||||
### 方式二:手动安装
|
||||
|
||||
1. `./build.sh` 生成 `AgentCheckin/AgentCheckin.fpk`
|
||||
2. 飞牛 Web 管理界面 → 应用中心 → 右上角「手动安装」→ 选择 fpk
|
||||
3. 或设备命令行:`appcenter-cli install-fpk AgentCheckin.fpk`
|
||||
|
||||
安装后桌面/启动台出现「Agent 签到助手」图标,点击即可打开管理面板
|
||||
(也可直接访问 `https://<NAS>:5667/cgi/ThirdParty/AgentCheckin/index.cgi/`)。
|
||||
|
||||
### 添加账号
|
||||
|
||||
**WorkBuddy(推荐网页授权)**
|
||||
|
||||
1. 管理面板 → 账号管理 → 添加账号 → 「WorkBuddy 网页授权」
|
||||
2. 点击「生成授权链接」,在浏览器打开链接
|
||||
3. 登录腾讯 CodeBuddy / WorkBuddy 账号(手机号/微信)
|
||||
4. 页面自动完成绑定,无需其他操作
|
||||
|
||||
**WorkBuddy(手动)**:粘贴 accessToken(+ 可选 refreshToken)。
|
||||
|
||||
**TRAE**:粘贴 accessToken(`eyJ` 开头的 JWT)。
|
||||
|
||||
获取 TRAE Token:在登录了 TRAE 国内版客户端的电脑上,凭据在
|
||||
`%APPDATA%\Trae CN\User\globalStorage\storage.json`,键
|
||||
`iCubeAuthInfo://icube.cloudide` 的值为加密串(AES-128-CBC),解密后即得
|
||||
`token`。也可用社区工具 `trae_get_token.py`(Trae-AutoCheckin 项目)一键提取。
|
||||
Token 约 60 天有效,过期后在面板重新粘贴即可。
|
||||
|
||||
**Qoder**:粘贴 accessToken(`dt-` 开头)+ 可选 refreshToken(`drt-` 开头)。
|
||||
|
||||
获取 Qoder Token(三种方式,任选其一):
|
||||
|
||||
| 方式 | 操作 | 适合 |
|
||||
|---|---|---|
|
||||
| **A · 设备授权登录(推荐)** | 下载社区工具 `qoder_login.py`([L0NE-6/Qoder-Daily](https://github.com/L0NE-6/Qoder-Daily)),运行 `python qoder_login.py`,浏览器打开授权页确认一次,工具输出 `access_token\|refresh_token` | 所有人,无需抓包 |
|
||||
| **B · 浏览器抓包(最快)** | 浏览器登录 Qoder 网页版 → F12 开发者工具 → 网络 → 任选一个请求,看请求头 `authorization: Bearer dt-...`,`dt-` 后那串即 Access Token;若还能看到 `drt-...` 刷新串,一并填入可自动续期 | 会看开发者工具 |
|
||||
| **C · 本机客户端提取** | 在装了并登录 Qoder 客户端的 Windows 上运行 `01_extract.py`([wallechfox/qoder-checkin](https://github.com/wallechfox/qoder-checkin)),自动 DPAPI+AES-GCM 解密 `%APPDATA%\com.qodercn.app.*\auth.v1.dat` 取出 Token 和设备标识 | 需要设备标识时 |
|
||||
|
||||
> 注意:方式 C 必须在**登录 Qoder 的同一个 Windows 用户**下运行(DPAPI 绑定用户身份)。
|
||||
> 若同时安装了国内版和国际版客户端,`01_extract.py` 会解出两套 Token——
|
||||
> 签到时用**国内版**那套(qoder.cn / QQ 邮箱账号),并在面板选国内站域名。
|
||||
> Qoder Token 有效期较短(约两周),过期后重新获取粘贴即可;填了 Refresh Token 后应用会自动续期。
|
||||
> 若领取时提示风控,再用方式 C 提取 Cosy-* 设备标识填到账号的「设备标识」栏。
|
||||
|
||||
## 使用说明
|
||||
|
||||
- **仪表盘**:今日各账号签到状态、积分、下次自动签到时间;可手动「立即签到」
|
||||
- **账号管理**:添加/启用/禁用/删除账号,Token 脱敏展示,「测连接」验证凭据有效性
|
||||
- **签到记录**:完整运行日志(含每次签到的结果与积分变化)
|
||||
- **设置**:自定义签到时间点、Webhook 通知地址与通知时机
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
fnos-agent-checkin/
|
||||
├── AgentCheckin/ # fnpack 应用项目(fpk 源码)
|
||||
│ ├── manifest # 应用元数据(install_dep_apps=python312)
|
||||
│ ├── ICON.PNG / ICON_256.PNG
|
||||
│ ├── config/
|
||||
│ │ ├── privilege # 运行用户 agentcheckin
|
||||
│ │ └── resource
|
||||
│ ├── cmd/ # 生命周期脚本
|
||||
│ │ └── main # start/stop/status 管理调度守护进程
|
||||
│ ├── app/
|
||||
│ │ ├── ui/
|
||||
│ │ │ ├── index.cgi # CGI 入口:静态文件 + /api 路由
|
||||
│ │ │ ├── config # 桌面入口配置
|
||||
│ │ │ └── images/
|
||||
│ │ ├── backend/ # Python 后端
|
||||
│ │ │ ├── common.py # 路径/HTTP/原子存储/日志
|
||||
│ │ │ ├── workbuddy.py # WorkBuddy OAuth + 签到 + 积分 + 续期
|
||||
│ │ │ ├── trae.py # TRAE 签到(设备号管理/9074 换号)
|
||||
│ │ │ ├── engine.py # 账号编排/结果记录/通知
|
||||
│ │ │ ├── api.py # CGI JSON API
|
||||
│ │ │ └── daemon.py # 调度守护进程
|
||||
│ │ └── www/ # 前端(原生 HTML/CSS/JS,无第三方依赖)
|
||||
├── genicon.py # 图标生成(纯标准库)
|
||||
├── build.sh # 一键打包
|
||||
├── fnos-install.js # NAS 自动安装(应用中心 API)
|
||||
├── test_backend.py # 后端单元/接口测试(20 项)
|
||||
├── test_e2e.py # 端到端测试(模拟 fnOS 布局,25 项)
|
||||
├── test_schedule.py # 真实时钟调度测试
|
||||
├── test_settings_ui.js # 设置页前端回归测试(DOM 桩,9 项)
|
||||
├── test_live_nas.py # 真机全链路测试(需 NAS 凭据)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 技术形态
|
||||
|
||||
- **原生 fnOS 应用**(非 Docker):CGI 入口 + Python 守护进程,沿用 fnOS 登录态校验
|
||||
- 运行时依赖 `python312`(fnOS 应用中心运行时包,安装时自动拉起;缺失时回退系统 python3)
|
||||
- 数据目录:`/vol{N}/@appdata/AgentCheckin/`(accounts.json / settings.json / state.json / logs/)
|
||||
- 端口:不监听端口(纯 CGI),`checkport=false`
|
||||
|
||||
## 签到接口(社区逆向,仅供参考)
|
||||
|
||||
**WorkBuddy(CN: www.codebuddy.cn)**
|
||||
|
||||
| 功能 | 端点 |
|
||||
|---|---|
|
||||
| 签到状态 | `POST /v2/billing/meter/checkin-activity-status` |
|
||||
| 执行签到 | `POST /v2/billing/meter/daily-checkin` |
|
||||
| 积分查询 | `POST /v2/billing/meter/get-user-resource` |
|
||||
| Token 续期 | `POST copilot.tencent.com/v2/plugin/auth/token/refresh` |
|
||||
| 网页授权 | `POST /v2/plugin/auth/state` → 浏览器登录 → `GET /v2/plugin/auth/token?state=` |
|
||||
|
||||
**Qoder(openapi.qoder.com.cn)**
|
||||
|
||||
| 功能 | 端点 |
|
||||
|---|---|
|
||||
| 活动列表 | `GET /sash/api/v1/me/campaigns` |
|
||||
| 领取 | `POST /sash/api/v1/me/campaigns/{cid}/claim` |
|
||||
| Token 续期 | `POST /api/v1/deviceToken/refresh` |
|
||||
|
||||
请求需带 `Authorization: Bearer <token>` 与 Cosy-* 设备标识头。
|
||||
|
||||
> ⚠️ **两个重要实测结论(2026-10-09 验证)**:
|
||||
> 1. **Token 格式**:Qoder 的 `dt-`/`drt-` Token 原生就只有 **27/28 个字符**,不是截断。
|
||||
> 2. **站点隔离**:国内版(qoder.cn 客户端)的 Token 只能用于 `openapi.qoder.com.cn`,
|
||||
> 国际版(qoder.com 客户端)的 Token 只能用于 `openapi.qoder.sh`,混用直接 401。
|
||||
> 面板的「修复站点」按钮会自动探测 Token 所属站点并切换。
|
||||
> 3. **国际版无签到活动**:只有国内版有每日 100 Credits 签到,国际版账号活动列表为空。
|
||||
|
||||
**TRAE(api.trae.cn)**
|
||||
|
||||
| 功能 | 端点 |
|
||||
|---|---|
|
||||
| 签到状态 | `POST /trae/api/v2/ug/checkin_credits/status` |
|
||||
| 执行签到 | `POST /trae/api/v2/ug/checkin_credits/claim` |
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q:需要每天开着 WorkBuddy / TRAE 客户端吗?**
|
||||
不需要。用的是登录时保存的凭据。只有约 60 天完全不用导致凭据过期时,才需要重新授权(面板会标记「需重新授权」并推送通知)。
|
||||
|
||||
**Q:应用中心里显示未运行?**
|
||||
到 设置 → 应用 → AgentCheckin 点「启动」;或检查 `@appdata/AgentCheckin/daemon.out` 日志。确认 `python312` 运行时已随依赖安装。
|
||||
|
||||
**Q:签到失败会怎样?**
|
||||
每个时段都会重试;TRAE 9074 风控会自动换设备号。全天失败且配置了 Webhook 时会推送通知。日志中可看到每次失败原因。
|
||||
|
||||
**Q:想改签到时间?**
|
||||
设置页增删时间点即可,保存后守护进程下个 tick 生效。
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目仅供个人学习与研究使用,与腾讯、字节跳动无任何隶属关系。使用本工具产生的一切后果由使用者自行承担,请遵守各平台服务条款。
|
||||
|
||||
## 致谢
|
||||
|
||||
签到接口逻辑参考社区开源项目:[wb-trae-checkin](https://github.com/3338320742zh-collab/wb-trae-checkin)、
|
||||
[workbuddy-checkin](https://github.com/Maquer/workbuddy-checkin)、
|
||||
[Trae-AutoCheckin](https://github.com/L0NE-6/Trae-AutoCheckin)(MIT)。
|
||||
|
||||
## Webhook 通知申请教程
|
||||
|
||||
设置页 →「消息通知」→ 填入机器人 Webhook 地址(平台会自动识别,也可手动指定)。
|
||||
|
||||
### 企业微信群机器人(免费,推荐)
|
||||
|
||||
1. 手机/电脑安装「企业微信」,用手机号注册(个人可免费创建企业)
|
||||
2. 创建一个内部群聊(或任选一个群)
|
||||
3. 群聊右上角「…」→ **群机器人** → 添加机器人 → 复制 Webhook 地址
|
||||
4. 地址形如 `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx-xxx-xxx`
|
||||
|
||||
### 钉钉群机器人(免费)
|
||||
|
||||
1. 钉钉 APP → 进入一个群 → 群设置 → **机器人** → 添加机器人 → **自定义(通过 Webhook 接入)**
|
||||
2. 安全设置选「**自定义关键词**」,填写 `签到`(通知内容含该词才会放行)
|
||||
3. 复制 Webhook 地址,形如 `https://oapi.dingtalk.com/robot/send?access_token=xxx`
|
||||
|
||||
### 飞书群机器人(免费)
|
||||
|
||||
1. 飞书 APP → 进入一个群 → 群设置 → **群机器人** → 添加机器人 → **自定义机器人**
|
||||
2. 安全设置同样建议选「自定义关键词」填 `签到`
|
||||
3. 复制 Webhook 地址,形如 `https://open.feishu.cn/open-apis/bot/v2/hook/xxx`
|
||||
|
||||
### Bark(iOS 推送,免费)
|
||||
|
||||
1. App Store 下载 **Bark**
|
||||
2. 打开后首页即显示推送地址,形如 `https://api.day.app/XXXXXXXX/`
|
||||
3. 整条复制粘贴即可(系统自动使用 Bark 的 URL 路径格式)
|
||||
|
||||
### Server酱 / PushPlus(微信模板消息)
|
||||
|
||||
- **Server酱**:微信扫码登录 `sct.ftqq.com` → 复制 SendKey → 地址填 `https://sctapi.ftqq.com/<SendKey>.send`
|
||||
- **PushPlus**:`pushplus.plus` 登录 → 复制 token → 地址填 `http://www.pushplus.plus/send?token=<token>`
|
||||
|
||||
> 留空 Webhook 地址则不发送任何通知。测试按钮会先保存设置再触发一次真实签到,通知随结果一起送达。
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
# Build AgentCheckin.fpk from source.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
echo "== cleaning pycache/devdata =="
|
||||
find AgentCheckin -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||
rm -rf AgentCheckin/devdata AgentCheckin/var
|
||||
echo "== regenerating icons =="
|
||||
python3 genicon.py
|
||||
echo "== building fpk =="
|
||||
cd AgentCheckin
|
||||
fnpack build
|
||||
cd ..
|
||||
ls -la AgentCheckin/AgentCheckin.fpk
|
||||
echo "== verifying package =="
|
||||
tar tzf AgentCheckin/AgentCheckin.fpk | grep -c pycache && echo "WARN: pycache in package" || echo "OK: no pycache"
|
||||
tar tzf AgentCheckin/AgentCheckin.fpk | grep -E "index.cgi|manifest|main$|daemon.py|api.py"
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env node
|
||||
/* AgentCheckin .fpk installer for fnOS via the app-center web API.
|
||||
*
|
||||
* Full flow (validated against fnOS 1.x app center):
|
||||
* 1. WebSocket wss://<host>:5667/websocket?type=main
|
||||
* 2. util.getSI + util.crypto.getRSAPub (plain frames are accepted)
|
||||
* 3. user.login (username/password) -> ticket
|
||||
* 4. POST /app/ticket {ticket} -> ost cookie
|
||||
* 5. POST /app-center/v1/download/upload (FormData: taskID/file/volumeID)
|
||||
* 6. GET /app-center/v1/download/status (poll until status=2)
|
||||
* 7. POST /app-center/v1/install/task (packageType=local)
|
||||
* 8. POST /app-center/v1/install/status (poll until status=2)
|
||||
*
|
||||
* Usage:
|
||||
* FNOS_HOST=192.168.31.10 FNOS_USER=admin FNOS_PASS=*** \
|
||||
* node fnos-install.js [path/to/AgentCheckin.fpk]
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// fnOS uses a self-signed certificate on the LAN; accept it for this installer.
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||||
|
||||
const HOST = process.env.FNOS_HOST || "192.168.31.10";
|
||||
const PORT = process.env.FNOS_PORT || "5667";
|
||||
const BASE = `https://${HOST}:${PORT}`;
|
||||
const USER = process.env.FNOS_USER;
|
||||
const PASS = process.env.FNOS_PASS;
|
||||
const APPNAME = "AgentCheckin";
|
||||
const VERSION = "1.2.2";
|
||||
const FPK = process.argv[2] ||
|
||||
path.join(__dirname, "AgentCheckin", "AgentCheckin.fpk");
|
||||
|
||||
const log = (...a) => console.log(...a);
|
||||
|
||||
if (!USER || !PASS) {
|
||||
console.error("请设置环境变量 FNOS_USER 和 FNOS_PASS(NAS 管理员账号密码)");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(FPK)) {
|
||||
console.error("找不到 fpk 文件:", FPK);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 1. WebSocket login -> ticket */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function wsLogin() {
|
||||
const ws = new WebSocket(`wss://${HOST}:${PORT}/websocket?type=main`);
|
||||
let reqId = 1000;
|
||||
const pending = new Map();
|
||||
|
||||
const send = (obj) => ws.send(JSON.stringify(obj));
|
||||
const rpc = (req, data, opts = {}) => new Promise((resolve, reject) => {
|
||||
const id = String(reqId++);
|
||||
const frame = Object.assign({ req, reqid: id }, data || {});
|
||||
pending.set(id, { resolve, reject });
|
||||
send(opts.raw ? frame : { data: frame, config: {} });
|
||||
setTimeout(() => {
|
||||
if (pending.has(id)) { pending.delete(id); reject(new Error("timeout " + req)); }
|
||||
}, opts.timeout || 15000);
|
||||
});
|
||||
|
||||
await new Promise((res, rej) => {
|
||||
ws.onopen = res;
|
||||
ws.onerror = (e) => rej(new Error("ws error: " + (e.message || e.type)));
|
||||
});
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
||||
const p = pending.get(msg.reqid);
|
||||
if (p) { pending.delete(msg.reqid); p.resolve(msg); }
|
||||
};
|
||||
|
||||
await rpc("util.getSI", {}, { raw: true });
|
||||
const pubResp = await rpc("util.crypto.getRSAPub", {}, { raw: true });
|
||||
if (!pubResp.pub && !(pubResp.data && pubResp.data.pub)) {
|
||||
throw new Error("getRSAPub 响应异常: " + JSON.stringify(pubResp).slice(0, 200));
|
||||
}
|
||||
// user.login must be sent as a raw frame with its own reqid (validated flow)
|
||||
const loginId = String(reqId++);
|
||||
const loginResp = await new Promise((resolve, reject) => {
|
||||
pending.set(loginId, { resolve, reject });
|
||||
send({
|
||||
req: "user.login", reqid: loginId,
|
||||
user: USER, password: PASS, stay: 0,
|
||||
deviceType: "Browser", deviceName: "Linux-Chrome", ver: 2,
|
||||
did: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 15)}`,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (pending.has(loginId)) { pending.delete(loginId); reject(new Error("login timeout")); }
|
||||
}, 20000);
|
||||
});
|
||||
ws.close();
|
||||
const d = loginResp.data || loginResp;
|
||||
// the login frame carries both `secret` and `ticket`; /app/ticket needs `ticket`
|
||||
const ticket = d.ticket || d.secret || d.token;
|
||||
if (!ticket) {
|
||||
if (loginResp.errno === 131072 || loginResp.result === "fail") {
|
||||
throw new Error("用户名或密码错误(请检查 FNOS_USER / FNOS_PASS)");
|
||||
}
|
||||
throw new Error("登录失败: " + JSON.stringify(loginResp).slice(0, 300));
|
||||
}
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 2. ticket -> ost cookie */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function ticketToCookie(ticket) {
|
||||
const r = await fetch(`${BASE}/app/ticket`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Origin: BASE },
|
||||
body: JSON.stringify({ ticket }),
|
||||
});
|
||||
const setCookie = r.headers.getSetCookie
|
||||
? r.headers.getSetCookie().join("; ")
|
||||
: (r.headers.get("set-cookie") || "");
|
||||
const m = /ost=([^;]+)/.exec(setCookie);
|
||||
if (!m) throw new Error("未能从 /app/ticket 获取 ost cookie: " + setCookie.slice(0, 200));
|
||||
return `ost=${m[1]}`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 3-8. upload + install */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function uploadFpk(cookie) {
|
||||
const taskId = `app-upload-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const buf = fs.readFileSync(FPK);
|
||||
const fd = new FormData();
|
||||
fd.append("taskID", taskId);
|
||||
fd.append("file", new Blob([buf]), path.basename(FPK));
|
||||
fd.append("volumeID", "1");
|
||||
const r = await fetch(`${BASE}/app-center/v1/download/upload`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: cookie, Origin: BASE },
|
||||
body: fd,
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (j.code !== 0 && j.code !== 200) throw new Error("上传被拒绝: " + JSON.stringify(j).slice(0, 300));
|
||||
log("upload accepted, taskId =", taskId);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
async function poll(cookie, url, opts, isDone, isFail, label, timeoutMs = 300000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const r = await fetch(url, {
|
||||
...opts,
|
||||
headers: { Cookie: cookie, "Content-Type": "application/json", Origin: BASE },
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const st = j.data && j.data.status;
|
||||
log(` [${label}] status=${st}`);
|
||||
if (isDone(j)) return j;
|
||||
if (isFail(j)) throw new Error(`${label} 失败: ` + JSON.stringify(j).slice(0, 400));
|
||||
await new Promise((res) => setTimeout(res, 2000));
|
||||
}
|
||||
throw new Error(`${label} 超时`);
|
||||
}
|
||||
|
||||
async function uninstallIfInstalled(cookie) {
|
||||
const r = await fetch(`${BASE}/app-center/v1/app/installed?language=zh-CN`,
|
||||
{ headers: { Cookie: cookie, Origin: BASE } });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const apps = (j.data || {}).list || [];
|
||||
const existing = apps.find((a) => a.appName === APPNAME);
|
||||
if (!existing) {
|
||||
log(`== ${APPNAME} not installed yet, skipping uninstall ==`);
|
||||
return;
|
||||
}
|
||||
log(`== ${APPNAME} v${existing.version} already installed -> uninstalling first (data in @appdata is kept) ==`);
|
||||
const ur = await fetch(`${BASE}/app-center/v1/uninstall/start`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: cookie, "Content-Type": "application/json", Origin: BASE },
|
||||
body: JSON.stringify({ appName: APPNAME, language: "zh-CN", customParameters: [] }),
|
||||
});
|
||||
const uj = await ur.json().catch(() => ({}));
|
||||
if (uj.code !== 0) throw new Error("卸载启动失败: " + JSON.stringify(uj).slice(0, 300));
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 120000) {
|
||||
await new Promise((res) => setTimeout(res, 2500));
|
||||
const r2 = await fetch(`${BASE}/app-center/v1/app/installed?language=zh-CN`,
|
||||
{ headers: { Cookie: cookie, Origin: BASE } });
|
||||
const j2 = await r2.json().catch(() => ({}));
|
||||
const still = ((j2.data || {}).list || []).find((a) => a.appName === APPNAME);
|
||||
if (!still) { log(" uninstall done (data preserved)"); return; }
|
||||
}
|
||||
throw new Error("卸载超时,应用仍在列表中");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
log("== 1. login (%s:%s)", HOST, PORT);
|
||||
const ticket = await wsLogin();
|
||||
log(" ticket ok");
|
||||
const cookie = await ticketToCookie(ticket);
|
||||
log(" ost cookie ok");
|
||||
|
||||
await uninstallIfInstalled(cookie);
|
||||
|
||||
log("== 2. upload fpk:", FPK, `(${(fs.statSync(FPK).size / 1024).toFixed(1)} KB)`);
|
||||
const taskId = await uploadFpk(cookie);
|
||||
await poll(cookie,
|
||||
`${BASE}/app-center/v1/download/status?downloadTaskId=${taskId}&language=zh-CN`,
|
||||
{ method: "GET" },
|
||||
(j) => j.data && j.data.status === 2,
|
||||
(j) => j.data && (j.data.status === 3 || j.data.status === 4),
|
||||
"upload", 300000);
|
||||
log(" upload done");
|
||||
|
||||
log("== 3. query install info (registers the package) ==");
|
||||
const infoResp = await fetch(
|
||||
`${BASE}/app-center/v1/install/info?appName=${APPNAME}&version=${VERSION}&packageType=local&language=zh-CN`,
|
||||
{ headers: { Cookie: cookie, Origin: BASE } });
|
||||
const info = await infoResp.json().catch(() => ({}));
|
||||
log(" install info code:", info.code,
|
||||
"| deps:", JSON.stringify(((info.data || {}).relation || {}).dependencyApps || []));
|
||||
|
||||
log("== 4. create install task");
|
||||
const r = await fetch(`${BASE}/app-center/v1/install/task`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: cookie, "Content-Type": "application/json", Origin: BASE },
|
||||
body: JSON.stringify({
|
||||
appName: APPNAME, version: VERSION, packageType: "local",
|
||||
systemParameters: { agreedToProtocol: true, installVolumeID: 1, dataVolumeId: 1, immediateStart: true },
|
||||
customParameters: [], language: "zh-CN",
|
||||
}),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const installTaskId = j.data && j.data.taskId;
|
||||
if (!installTaskId) throw new Error("创建安装任务失败: " + JSON.stringify(j).slice(0, 300));
|
||||
log(" install task =", installTaskId);
|
||||
|
||||
log("== 5. poll install status");
|
||||
await poll(cookie, `${BASE}/app-center/v1/install/status`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ taskId: installTaskId, language: "zh-CN" }),
|
||||
},
|
||||
(jj) => jj.data && jj.data.status === 2,
|
||||
(jj) => jj.data && (jj.data.status === 3 || jj.data.status === 4),
|
||||
"install", 600000);
|
||||
|
||||
log("");
|
||||
log(`✅ ${APPNAME} 安装完成!`);
|
||||
log(` 桌面/启动台会出现「Agent 签到助手」图标`);
|
||||
log(` 或访问 https://${HOST}:5667/cgi/ThirdParty/AgentCheckin/index.cgi/`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error("FAILED:", e.message); process.exit(1); });
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate AgentCheckin app icons as PNG files (pure stdlib, no PIL).
|
||||
|
||||
Design: rounded gradient tile + calendar outline + check mark (daily check-in).
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
BASE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "AgentCheckin")
|
||||
|
||||
|
||||
class Canvas:
|
||||
def __init__(self, w, h, scale=4):
|
||||
self.w, self.h, self.s = w, h, scale
|
||||
self.W, self.H = w * scale, h * scale
|
||||
self.px = [(0, 0, 0, 0)] * (self.W * self.H)
|
||||
|
||||
def _set(self, x, y, rgba):
|
||||
if 0 <= x < self.W and 0 <= y < self.H:
|
||||
i = y * self.W + x
|
||||
r, g, b, a = rgba
|
||||
orr, og, ob, oa = self.px[i]
|
||||
na = a / 255.0 + oa / 255.0 * (1 - a / 255.0)
|
||||
if na == 0:
|
||||
self.px[i] = (0, 0, 0, 0)
|
||||
else:
|
||||
nr = (r * a / 255.0 + orr * oa / 255.0 * (1 - a / 255.0)) / na
|
||||
ng = (g * a / 255.0 + og * g / 255.0 * (1 - a / 255.0)) / na
|
||||
nb = (b * a / 255.0 + ob * oa / 255.0 * (1 - a / 255.0)) / na
|
||||
self.px[i] = (int(nr), int(ng), int(nb), int(na * 255))
|
||||
|
||||
def rect(self, x, y, w, h, rgba):
|
||||
for yy in range(int(y), int(y + h)):
|
||||
for xx in range(int(x), int(x + w)):
|
||||
self._set(xx, yy, rgba)
|
||||
|
||||
def rounded(self, x, y, w, h, rad, rgba):
|
||||
for yy in range(y, y + h):
|
||||
for xx in range(x, x + w):
|
||||
dx = max(x + rad - xx, xx - (x + w - 1 - rad), 0)
|
||||
dy = max(y + rad - yy, yy - (y + h - 1 - rad), 0)
|
||||
if dx * dx + dy * dy <= rad * rad:
|
||||
self._set(xx, yy, rgba)
|
||||
|
||||
def ring(self, x, y, w, h, rad, thick, rgba):
|
||||
for yy in range(y, y + h):
|
||||
for xx in range(x, x + w):
|
||||
def outside(px, py, shrink):
|
||||
ddx = max(x + rad + shrink - px, px - (x + w - 1 - rad - shrink), 0)
|
||||
ddy = max(y + rad + shrink - py, py - (y + h - 1 - rad - shrink), 0)
|
||||
return ddx * ddx + ddy * ddy > (rad + shrink) * (rad + shrink)
|
||||
if not outside(xx, yy, 0) and outside(xx, yy, thick):
|
||||
self._set(xx, yy, rgba)
|
||||
|
||||
def line(self, x1, y1, x2, y2, thick, rgba):
|
||||
steps = int(max(abs(x2 - x1), abs(y2 - y1)) * 2) + 1
|
||||
for i in range(steps + 1):
|
||||
t = i / steps
|
||||
cx = x1 + (x2 - x1) * t
|
||||
cy = y1 + (y2 - y1) * t
|
||||
r = thick / 2.0
|
||||
for yy in range(int(cy - r) - 1, int(cy + r) + 2):
|
||||
for xx in range(int(cx - r) - 1, int(cx + r) + 2):
|
||||
if (xx - cx) ** 2 + (yy - cy) ** 2 <= r * r:
|
||||
self._set(xx, yy, rgba)
|
||||
|
||||
def gradient_fill(self, x, y, w, h, rad, c1, c2):
|
||||
for yy in range(y, y + h):
|
||||
t = (yy - y) / max(h - 1, 1)
|
||||
r = int(c1[0] + (c2[0] - c1[0]) * t)
|
||||
g = int(c1[1] + (c2[1] - c1[1]) * t)
|
||||
b = int(c1[2] + (c2[2] - c1[2]) * t)
|
||||
for xx in range(x, x + w):
|
||||
dx = max(x + rad - xx, xx - (x + w - 1 - rad), 0)
|
||||
dy = max(y + rad - yy, yy - (y + h - 1 - rad), 0)
|
||||
if dx * dx + dy * dy <= rad * rad:
|
||||
self._set(xx, yy, (r, g, b, 255))
|
||||
|
||||
def save(self, path):
|
||||
s = self.s
|
||||
rows = []
|
||||
for y in range(self.h):
|
||||
row = bytearray([0])
|
||||
for x in range(self.w):
|
||||
rs = gs = bs = as_ = 0
|
||||
cnt = 0
|
||||
for dy in range(s):
|
||||
for dx in range(s):
|
||||
p = self.px[(y * s + dy) * self.W + (x * s + dx)]
|
||||
rs += p[0]; gs += p[1]; bs += p[2]; as_ += p[3]
|
||||
cnt += 1
|
||||
row += bytes((rs // cnt, gs // cnt, bs // cnt, as_ // cnt))
|
||||
rows.append(bytes(row))
|
||||
raw = b"".join(rows)
|
||||
|
||||
def chunk(tag, data):
|
||||
c = struct.pack(">I", len(data)) + tag + data
|
||||
return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
||||
|
||||
png = b"\x89PNG\r\n\x1a\n"
|
||||
png += chunk(b"IHDR", struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0))
|
||||
png += chunk(b"IDAT", zlib.compress(raw, 9))
|
||||
png += chunk(b"IEND", b"")
|
||||
with open(path, "wb") as f:
|
||||
f.write(png)
|
||||
|
||||
|
||||
def draw_icon(size):
|
||||
c = Canvas(size, size)
|
||||
S = c.s
|
||||
u = S
|
||||
m = int(size * 0.05) * S
|
||||
|
||||
# gradient tile
|
||||
c.gradient_fill(m, m, c.W - 2 * m, c.H - 2 * m, int(size * 0.22) * S,
|
||||
(59, 108, 255), (122, 90, 248))
|
||||
|
||||
# calendar body
|
||||
bx, by = int(size * 0.22) * S, int(size * 0.24) * S
|
||||
bw, bh = int(size * 0.56) * S, int(size * 0.52) * S
|
||||
rad = int(size * 0.09) * S
|
||||
thick = max(int(size * 0.05) * S, 2 * S)
|
||||
c.ring(bx, by, bw, bh, rad, thick, (255, 255, 255, 255))
|
||||
|
||||
# hangers
|
||||
hw, hh = int(size * 0.07) * S, int(size * 0.12) * S
|
||||
c.rounded(bx + bw // 2 - int(size * 0.16) * S, by - hh // 2, hw, hh, hw // 2, (255, 255, 255, 255))
|
||||
c.rounded(bx + bw // 2 + int(size * 0.09) * S, by - hh // 2, hw, hh, hw // 2, (255, 255, 255, 255))
|
||||
|
||||
# header line
|
||||
ly = by + int(size * 0.13) * S
|
||||
c.line(bx + thick // 2, ly, bx + bw - thick // 2, ly, thick, (255, 255, 255, 255))
|
||||
|
||||
# check mark
|
||||
cx1 = bx + int(size * 0.13) * S
|
||||
cy1 = by + int(size * 0.30) * S
|
||||
t2 = max(int(size * 0.065) * S, 2 * S)
|
||||
c.line(cx1, cy1, cx1 + int(size * 0.09) * S, cy1 + int(size * 0.09) * S, t2, (126, 240, 178, 255))
|
||||
c.line(cx1 + int(size * 0.09) * S, cy1 + int(size * 0.09) * S,
|
||||
cx1 + int(size * 0.26) * S, cy1 - int(size * 0.11) * S, t2, (126, 240, 178, 255))
|
||||
return c
|
||||
|
||||
|
||||
def main():
|
||||
paths = [
|
||||
(64, os.path.join(BASE, "ICON.PNG")),
|
||||
(256, os.path.join(BASE, "ICON_256.PNG")),
|
||||
(64, os.path.join(BASE, "app/ui/images/icon_64.png")),
|
||||
(256, os.path.join(BASE, "app/ui/images/icon_256.png")),
|
||||
]
|
||||
for size, path in paths:
|
||||
c = draw_icon(size)
|
||||
c.save(path)
|
||||
print("wrote", path, os.path.getsize(path), "bytes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local dev tests for the AgentCheckin backend (no fnOS required).
|
||||
|
||||
Runs with AGENTCHECKIN_DEV=1 so all data lands in a throwaway devdata dir.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
BACKEND = os.path.join(os.path.dirname(os.path.abspath(__file__)), "AgentCheckin", "app", "backend")
|
||||
DEVROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "AgentCheckin", "devdata")
|
||||
|
||||
ENV = dict(os.environ)
|
||||
ENV["AGENTCHECKIN_DEV"] = "1"
|
||||
ENV["PYTHONPATH"] = BACKEND
|
||||
|
||||
PASS = []
|
||||
FAIL = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
if cond:
|
||||
PASS.append(name)
|
||||
print(" PASS %s" % name)
|
||||
else:
|
||||
FAIL.append(name)
|
||||
print(" FAIL %s %s" % (name, extra))
|
||||
|
||||
|
||||
def run_py(code, env=None):
|
||||
e = dict(ENV)
|
||||
if env:
|
||||
e.update(env)
|
||||
r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
|
||||
env=e, cwd=os.path.dirname(os.path.abspath(__file__)))
|
||||
return r.returncode, r.stdout, r.stderr
|
||||
|
||||
|
||||
def cgi(method, uri, body=None, query=""):
|
||||
"""Invoke api.py the way the CGI bridge does."""
|
||||
env = dict(ENV)
|
||||
env["REQUEST_METHOD"] = method
|
||||
env["REQUEST_URI"] = uri
|
||||
env["QUERY_STRING"] = query
|
||||
payload = b""
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode()
|
||||
env["CONTENT_LENGTH"] = str(len(payload))
|
||||
r = subprocess.run([sys.executable, os.path.join(BACKEND, "api.py")],
|
||||
input=payload, capture_output=True, env=env)
|
||||
out = r.stdout.decode("utf-8", "replace")
|
||||
# strip CGI headers
|
||||
if "\r\n\r\n" in out:
|
||||
out = out.split("\r\n\r\n", 1)[1]
|
||||
try:
|
||||
return json.loads(out)
|
||||
except ValueError:
|
||||
return {"_raw": out, "_stderr": r.stderr.decode("utf-8", "replace")[-500:]}
|
||||
|
||||
|
||||
def main():
|
||||
shutil.rmtree(DEVROOT, ignore_errors=True)
|
||||
print("== 1. common / storage ==")
|
||||
rc, out, err = run_py(
|
||||
"import common; print(common.DATA_DIR); print(common.today_str()); print(common.mask_token('abcdefghijklmnopqrstuvwxyz'))")
|
||||
check("common imports", rc == 0, err[-300:])
|
||||
if rc == 0:
|
||||
lines = out.strip().splitlines()
|
||||
check("dev data dir", lines[0].endswith("devdata"), lines)
|
||||
check("mask token", lines[2].startswith("abcdef") and lines[2].endswith("wxyz"), lines)
|
||||
|
||||
print("== 2. engine account management ==")
|
||||
rc, out, err = run_py(
|
||||
"import engine, common;"
|
||||
"a = engine.add_trae('测试 TRAE', 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyUmVnaW9uIjp7InJlZ2lvbiI6IkNOIn19.sig');"
|
||||
"b = engine.add_workbuddy_manual('测试 WB', 'fake-access-token-123', 'fake-refresh', 'codebuddy.cn');"
|
||||
"print(a['id'], b['id']);"
|
||||
"print(len(engine.load_accounts()))")
|
||||
check("add accounts", rc == 0 and "2" in out, err[-300:])
|
||||
ids = out.split() if rc == 0 else []
|
||||
trae_id, wb_id = (ids[0], ids[1]) if len(ids) >= 2 else ("", "")
|
||||
|
||||
print("== 3. dashboard masks tokens ==")
|
||||
rc, out, err = run_py(
|
||||
"import engine, json;"
|
||||
"d = engine.dashboard();"
|
||||
"s = json.dumps(d, ensure_ascii=False);"
|
||||
"print('fake-access-token-123' in s, 'fake-refresh' in s);"
|
||||
"print(len(d['accounts']))")
|
||||
check("dashboard no raw tokens", rc == 0 and out.startswith("False False"), out + err[-200:])
|
||||
check("dashboard account count", "2" in out, out)
|
||||
|
||||
print("== 4. CGI API routing ==")
|
||||
r = cgi("GET", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/state")
|
||||
check("GET /api/state", r.get("today") is not None and len(r.get("accounts", [])) == 2, r)
|
||||
r = cgi("GET", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/accounts")
|
||||
check("GET /api/accounts", len(r.get("accounts", [])) == 2, r)
|
||||
r = cgi("POST", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/accounts/add_trae",
|
||||
{"name": "API 添加", "access_token": "tok2"})
|
||||
check("POST add_trae", r.get("ok") is True, r)
|
||||
r = cgi("POST", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/accounts/update",
|
||||
{"id": r["account"]["id"], "enabled": False})
|
||||
check("POST update account", r.get("ok") is True and r["account"]["enabled"] is False, r)
|
||||
r = cgi("POST", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/accounts/delete",
|
||||
{"id": r["account"]["id"]})
|
||||
check("POST delete account", r.get("ok") is True, r)
|
||||
r = cgi("POST", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/settings",
|
||||
{"schedule_times": ["08:00", "20:30"], "webhook_url": "", "notify_on": ["fail", "success"]})
|
||||
check("POST settings", r.get("ok") is True and r["settings"]["schedule_times"] == ["08:00", "20:30"], r)
|
||||
r = cgi("POST", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/settings",
|
||||
{"schedule_times": ["bad"]})
|
||||
check("POST settings rejects bad time", "error" in r, r)
|
||||
r = cgi("GET", "/cgi/ThirdParty/AgentCheckin/index.cgi/api/nope")
|
||||
check("unknown route 404", "error" in r, r)
|
||||
|
||||
print("== 5. TRAE jwt region decode ==")
|
||||
rc, out, err = run_py(
|
||||
"import trae;"
|
||||
"tok='eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyUmVnaW9uIjp7InJlZ2lvbiI6IkNOIn19.sig';"
|
||||
"p=trae.jwt_payload(tok); print(p.get('userRegion'))")
|
||||
check("jwt decode", rc == 0 and "CN" in out, out + err[-200:])
|
||||
|
||||
print("== 6. workbuddy token expiry logic ==")
|
||||
rc, out, err = run_py(
|
||||
"import workbuddy, time;"
|
||||
"print(workbuddy.token_expiring({'expiresAt': int(time.time()) + 3600}));"
|
||||
"print(workbuddy.token_expiring({'expiresAt': int(time.time()) + 30*86400}));"
|
||||
"print(workbuddy.token_expiring({'expiresAt': 0}));"
|
||||
"print(workbuddy.is_global({'domain':'workbuddy.ai'}), workbuddy.is_global({'domain':'codebuddy.cn'}))")
|
||||
check("token expiry", rc == 0 and out.split() == ["True", "False", "False", "True", "False"], out + err[-200:])
|
||||
|
||||
print("== 7. live network: workbuddy oauth state endpoint ==")
|
||||
rc, out, err = run_py(
|
||||
"import workbuddy;"
|
||||
"state, url, err = workbuddy.begin_oauth();"
|
||||
"print(bool(state), bool(url), err)")
|
||||
if rc == 0 and out.startswith("True True None"):
|
||||
check("oauth begin (live)", True)
|
||||
else:
|
||||
check("oauth begin (live)", False, out + err[-300:])
|
||||
|
||||
print("== 8. live network: trae status with bogus token -> no_session ==")
|
||||
rc, out, err = run_py(
|
||||
"import trae;"
|
||||
"acc={'id':'t1','auth':{'accessToken':'bogus.token.value'}};"
|
||||
"r=trae.checkin_once(acc); print(r['status'], r['message'][:40])")
|
||||
check("trae bogus token handled", rc == 0 and ("no_session" in out or "fail" in out or "retry_later" in out),
|
||||
out + err[-300:])
|
||||
|
||||
print("== 9. live network: workbuddy status with bogus token ==")
|
||||
rc, out, err = run_py(
|
||||
"import engine;"
|
||||
"acc=engine.add_workbuddy_manual('bogus','bogus-token');"
|
||||
"r=engine._run_workbuddy(acc); print(r['status'], r['message'][:40])")
|
||||
check("wb bogus token handled", rc == 0 and r_status_ok(out), out + err[-300:])
|
||||
|
||||
print("== 10. daemon tick (engine mocked, empty results) ==")
|
||||
mock = (
|
||||
"import daemon, engine, common\n"
|
||||
"calls = []\n"
|
||||
"def fake_run(account_id=None, reason='x'):\n"
|
||||
" calls.append(reason)\n"
|
||||
" st = common.load_state()\n"
|
||||
" st.setdefault('today', {})[common.today_str()] = {}\n" # empty results (no accounts)
|
||||
" common.save_state(st)\n"
|
||||
" return {}\n"
|
||||
"engine.run_all = fake_run\n"
|
||||
"daemon.tick()\n"
|
||||
"daemon.tick()\n"
|
||||
"daemon.tick()\n"
|
||||
"print(calls)\n"
|
||||
)
|
||||
rc, out, err = run_py(mock)
|
||||
# first tick = catch-up (marks passed slots); later ticks must be silent
|
||||
check("daemon catch-up runs once even with empty results",
|
||||
rc == 0 and out.strip() == "['startup-catchup']", out + err[-300:])
|
||||
|
||||
print("== 11. webhook payload builders ==")
|
||||
rc, out, err = run_py(
|
||||
"import engine, json\n"
|
||||
"cases = [\n"
|
||||
" ('auto', 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=x'),\n"
|
||||
" ('auto', 'https://oapi.dingtalk.com/robot/send?access_token=x'),\n"
|
||||
" ('auto', 'https://open.feishu.cn/open-apis/bot/v2/hook/x'),\n"
|
||||
" ('auto', 'https://api.day.app/ABCKEY/'),\n"
|
||||
" ('auto', 'https://example.com/hook'),\n"
|
||||
" ('wecom', 'https://example.com/hook'),\n"
|
||||
" ('bark', 'https://api.day.app/ABCKEY'),\n"
|
||||
"]\n"
|
||||
"for wt, u in cases:\n"
|
||||
" url, body = engine.notify_payload(wt, u, 'TITLE', 'CONTENT')\n"
|
||||
" print(url + '||' + (body.decode() if body else 'GET'))\n"
|
||||
)
|
||||
lines = out.strip().splitlines() if rc == 0 else []
|
||||
check("webhook: wecom payload", len(lines) > 0 and json.loads(lines[0].split("||")[1]) == {"msgtype": "text", "text": {"content": "TITLE\nCONTENT"}}, out + err[-200:])
|
||||
check("webhook: dingtalk payload", len(lines) > 1 and json.loads(lines[1].split("||")[1]) == {"msgtype": "text", "text": {"content": "TITLE\nCONTENT"}}, out)
|
||||
check("webhook: feishu payload", len(lines) > 2 and json.loads(lines[2].split("||")[1]) == {"msg_type": "text", "content": {"text": "TITLE\nCONTENT"}}, out)
|
||||
check("webhook: bark url-path GET", len(lines) > 3 and lines[3] == "https://api.day.app/ABCKEY/TITLE/CONTENT||GET", out)
|
||||
check("webhook: generic payload", len(lines) > 4 and json.loads(lines[4].split("||")[1]).get("title") == "TITLE", out)
|
||||
check("webhook: manual type override", len(lines) > 5 and json.loads(lines[5].split("||")[1]).get("msgtype") == "text", out)
|
||||
check("webhook: bark without trailing slash", len(lines) > 6 and lines[6] == "https://api.day.app/ABCKEY/TITLE/CONTENT||GET", out)
|
||||
|
||||
print("== 12. qoder module (offline logic) ==")
|
||||
rc, out, err = run_py(
|
||||
"import qoder, json, base64, time\n"
|
||||
"# device headers with defaults + overrides\n"
|
||||
"acc = {'auth': {'accessToken': 'x'}, 'device': {'machineId': 'MID', 'version': '1.2.3'}}\n"
|
||||
"h = qoder.device_headers(acc)\n"
|
||||
"print('clientType=%s machineId=%s version=%s emptyToken=%r' % (h['Cosy-ClientType'], h['Cosy-MachineId'], h['Cosy-Version'], h['Cosy-MachineToken']))\n"
|
||||
"# jwt helpers\n"
|
||||
"payload = base64.urlsafe_b64encode(json.dumps({'exp': 9999999999, 'sub': 'u123'}).encode()).decode().rstrip('=')\n"
|
||||
"tok = 'h.' + payload + '.s'\n"
|
||||
"print('exp:', qoder.jwt_exp(tok), 'uid:', qoder.jwt_uid(tok))\n"
|
||||
"# global detection\n"
|
||||
"print('global:', qoder.is_global({'domain': 'openapi.qoder.sh'}), qoder.is_global({'domain': 'openapi.qoder.com.cn'}))\n"
|
||||
"# campaign parsing: claimable -> claim flow mocked\n"
|
||||
"campaigns = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMABLE', 'campaignId': 'c1', 'benefit': {'amount': 100}}]\n"
|
||||
"b = qoder._benefits(campaigns)\n"
|
||||
"print('benefits:', len(b), 'claimable:', len([c for c in b if c.get('claimStatus') == 'CLAIMABLE']))\n"
|
||||
"# already-claimed detection\n"
|
||||
"now = int(time.time())\n"
|
||||
"c2 = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMED', 'startAt': now - 100, 'endAt': now + 100}]\n"
|
||||
"bb = qoder._benefits(c2)\n"
|
||||
"print('claimed_today:', len([c for c in bb if c.get('claimStatus') == 'CLAIMED' and (c.get('startAt') or 0) <= now < (c.get('endAt') or 0)]))\n"
|
||||
)
|
||||
lines = out.splitlines() if rc == 0 else []
|
||||
check("qoder device headers", lines and lines[0] == "clientType=10 machineId=MID version=1.2.3 emptyToken=''", out + err[-200:])
|
||||
check("qoder jwt helpers", len(lines) > 1 and lines[1] == "exp: 9999999999 uid: u123", out)
|
||||
check("qoder global detection", len(lines) > 2 and lines[2] == "global: True False", out)
|
||||
check("qoder campaign parsing", len(lines) > 3 and lines[3] == "benefits: 1 claimable: 1", out)
|
||||
check("qoder already-claimed detection", len(lines) > 4 and lines[4] == "claimed_today: 1", out)
|
||||
|
||||
print("== 13. engine: add qoder account + token masking ==")
|
||||
rc, out, err = run_py(
|
||||
"import engine, json\n"
|
||||
"a = engine.add_qoder('Qoder 测试', 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1MSJ9.sig', 'rt123', {'machineId': 'MID', 'machineToken': 'MTOK'})\n"
|
||||
"print(a['id'], a['platform'], a['device']['machineId'])\n"
|
||||
"d = engine.dashboard()\n"
|
||||
"s = json.dumps(d, ensure_ascii=False)\n"
|
||||
"print('masked:', 'eyJhbGciOiJSUzI1NiJ9' not in s and 'rt123' not in s)\n"
|
||||
)
|
||||
check("engine add qoder", rc == 0 and "qoder_" in out and "MID" in out, out + err[-200:])
|
||||
check("qoder token masked in dashboard", "masked: True" in out, out)
|
||||
|
||||
print()
|
||||
print("PASSED: %d FAILED: %d" % (len(PASS), len(FAIL)))
|
||||
if FAIL:
|
||||
print("failures:", FAIL)
|
||||
shutil.rmtree(DEVROOT, ignore_errors=True)
|
||||
return 1 if FAIL else 0
|
||||
|
||||
|
||||
def r_status_ok(out):
|
||||
return any(s in out for s in ("no_session", "fail", "ok", "already"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end test: simulate the fnOS install layout and exercise the whole
|
||||
stack (cmd/main lifecycle, CGI entry, API, daemon scheduling) locally.
|
||||
|
||||
Simulated layout (mirrors /var/apps/{appname} on a real device):
|
||||
/tmp/fnos-sim/var/apps/AgentCheckin/
|
||||
target/ <- app.tgz contents (+ cmd, config, manifest, icons)
|
||||
var/ <- @appdata equivalent (persistent data)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
ROOT = "/vol1/@appshare/fn-deepseek-harness/fnos-agent-checkin"
|
||||
FPK = os.path.join(ROOT, "AgentCheckin", "AgentCheckin.fpk")
|
||||
SIM = "/tmp/fnos-sim"
|
||||
APPDEST = os.path.join(SIM, "var/apps/AgentCheckin")
|
||||
PKGVAR = os.path.join(SIM, "appdata/AgentCheckin")
|
||||
TARGET = os.path.join(APPDEST, "target")
|
||||
CGI_PATH_PREFIX = "/cgi/ThirdParty/AgentCheckin/index.cgi"
|
||||
|
||||
PASS, FAIL = [], []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
(PASS if cond else FAIL).append(name)
|
||||
print((" PASS " if cond else " FAIL ") + name + ("" if cond else " " + str(extra)[:400]))
|
||||
|
||||
|
||||
def sh(cmd, env=None, **kw):
|
||||
e = dict(os.environ)
|
||||
if env:
|
||||
e.update(env)
|
||||
return subprocess.run(cmd, shell=True, capture_output=True, text=True, env=e, **kw)
|
||||
|
||||
|
||||
def unpack_fpk():
|
||||
shutil.rmtree(SIM, ignore_errors=True)
|
||||
os.makedirs(APPDEST)
|
||||
os.makedirs(PKGVAR)
|
||||
tmp = os.path.join(SIM, "pkg")
|
||||
os.makedirs(tmp)
|
||||
sh("tar xzf %s -C %s" % (FPK, tmp))
|
||||
# fnOS installs app.tgz contents into target/, plus cmd/config/manifest/icons
|
||||
with tarfile.open(os.path.join(tmp, "app.tgz")) as t:
|
||||
t.extractall(TARGET)
|
||||
for item in ("cmd", "config", "manifest", "ICON.PNG", "ICON_256.PNG", "wizard"):
|
||||
src = os.path.join(tmp, item)
|
||||
dst = os.path.join(APPDEST, item)
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
shutil.copy(src, dst)
|
||||
# make scripts executable as fnOS would
|
||||
sh("chmod +x %s/target/ui/index.cgi %s/cmd/*" % (APPDEST, APPDEST))
|
||||
# simulate install_callback
|
||||
sh("TRIM_PKGVAR=%s bash %s/cmd/install_callback" % (PKGVAR, APPDEST))
|
||||
|
||||
|
||||
ENV_BASE = {
|
||||
"TRIM_APPDEST": APPDEST,
|
||||
"TRIM_PKGVAR": PKGVAR,
|
||||
"TRIM_PKGETC": os.path.join(APPDEST, "etc"),
|
||||
"AGENTCHECKIN_DEV": "", # ensure TRIM_PKGVAR is used
|
||||
}
|
||||
|
||||
|
||||
class CGIHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _run_cgi(self):
|
||||
uri = self.path
|
||||
env = dict(os.environ)
|
||||
env.update(ENV_BASE)
|
||||
env["REQUEST_METHOD"] = self.command
|
||||
env["REQUEST_URI"] = uri
|
||||
env["QUERY_STRING"] = uri.split("?", 1)[1] if "?" in uri else ""
|
||||
env["GATEWAY_INTERFACE"] = "CGI/1.1"
|
||||
env["SERVER_PROTOCOL"] = "HTTP/1.1"
|
||||
env["REMOTE_ADDR"] = "127.0.0.1"
|
||||
body = b""
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
if length:
|
||||
body = self.rfile.read(length)
|
||||
env["CONTENT_LENGTH"] = str(length)
|
||||
env["CONTENT_TYPE"] = self.headers.get("Content-Type", "")
|
||||
cgi_file = os.path.join(TARGET, "ui", "index.cgi")
|
||||
r = subprocess.run(["/bin/bash", cgi_file], input=body,
|
||||
capture_output=True, env=env)
|
||||
out = r.stdout
|
||||
# split CGI headers from body
|
||||
sep = out.find(b"\r\n\r\n")
|
||||
if sep < 0:
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
self.wfile.write(out)
|
||||
return
|
||||
head = out[:sep].decode("utf-8", "replace")
|
||||
payload = out[sep + 4:]
|
||||
status = "200 OK"
|
||||
headers = {}
|
||||
for line in head.split("\r\n"):
|
||||
if not line:
|
||||
continue
|
||||
if line.lower().startswith("status:"):
|
||||
status = line.split(":", 1)[1].strip()
|
||||
elif ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
headers[k.strip()] = v.strip()
|
||||
self.send_response(int(status.split()[0]), status.split(" ", 1)[1] if " " in status else "")
|
||||
for k, v in headers.items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(payload)
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith(CGI_PATH_PREFIX):
|
||||
self._run_cgi()
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
self.do_GET()
|
||||
|
||||
|
||||
def http(method, path, body=None):
|
||||
url = "http://127.0.0.1:18899" + path
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if data:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return r.status, r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read()
|
||||
|
||||
|
||||
def jget(path):
|
||||
st, raw = http("GET", path)
|
||||
try:
|
||||
return st, json.loads(raw.decode("utf-8"))
|
||||
except ValueError:
|
||||
return st, {"_raw": raw.decode("utf-8", "replace")[:300]}
|
||||
|
||||
|
||||
def jpost(path, body=None):
|
||||
st, raw = http("POST", path, body if body is not None else {})
|
||||
try:
|
||||
return st, json.loads(raw.decode("utf-8"))
|
||||
except ValueError:
|
||||
return st, {"_raw": raw.decode("utf-8", "replace")[:300]}
|
||||
|
||||
|
||||
def main():
|
||||
print("== unpack fpk into simulated fnOS layout ==")
|
||||
unpack_fpk()
|
||||
check("target/index.cgi exists", os.path.isfile(os.path.join(TARGET, "ui", "index.cgi")))
|
||||
check("target/backend exists", os.path.isdir(os.path.join(TARGET, "backend")))
|
||||
check("data dir initialized", os.path.isfile(os.path.join(PKGVAR, "accounts.json")))
|
||||
check("settings initialized", os.path.isfile(os.path.join(PKGVAR, "settings.json")))
|
||||
|
||||
print("== cmd/main lifecycle ==")
|
||||
r = sh("%s/cmd/main status" % APPDEST, env=ENV_BASE)
|
||||
check("status: not running (exit 3)", r.returncode == 3, r.stdout + r.stderr)
|
||||
r = sh("%s/cmd/main start" % APPDEST, env=ENV_BASE)
|
||||
check("start: exit 0", r.returncode == 0, r.stdout + r.stderr)
|
||||
check("daemon pidfile written", os.path.isfile(os.path.join(PKGVAR, "daemon.pid")))
|
||||
r = sh("%s/cmd/main status" % APPDEST, env=ENV_BASE)
|
||||
check("status: running (exit 0)", r.returncode == 0, r.stdout + r.stderr)
|
||||
r = sh("%s/cmd/main start" % APPDEST, env=ENV_BASE)
|
||||
check("start: idempotent", r.returncode == 0 and "already running" in r.stdout, r.stdout)
|
||||
|
||||
print("== CGI server ==")
|
||||
server = HTTPServer(("127.0.0.1", 18899), CGIHandler)
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.3)
|
||||
|
||||
st, d = jget(CGI_PATH_PREFIX + "/")
|
||||
check("GET / serves index.html", st == 200 and b"Agent" in d.get("_raw", "").encode() or st == 200,
|
||||
(st, str(d)[:120]))
|
||||
|
||||
st, raw = http("GET", CGI_PATH_PREFIX + "/assets/style.css")
|
||||
check("GET /assets/style.css", st == 200 and b"--primary" in raw, (st, raw[:80]))
|
||||
|
||||
st, raw = http("GET", CGI_PATH_PREFIX + "/assets/app.js")
|
||||
check("GET /assets/app.js", st == 200 and b"AgentCheckin" in raw, (st, raw[:80]))
|
||||
|
||||
st, raw = http("GET", CGI_PATH_PREFIX + "/assets/logo.svg")
|
||||
check("GET /assets/logo.svg", st == 200 and b"<svg" in raw, (st, raw[:80]))
|
||||
|
||||
st, raw = http("GET", CGI_PATH_PREFIX + "/../manifest")
|
||||
check("path traversal blocked", st == 400, (st, raw[:80]))
|
||||
|
||||
st, d = jget(CGI_PATH_PREFIX + "/api/state")
|
||||
check("GET /api/state", st == 200 and d.get("today") is not None, (st, str(d)[:200]))
|
||||
check("daemon_alive reported true", d.get("daemon_alive") is True, str(d)[:200])
|
||||
|
||||
st, d = jpost(CGI_PATH_PREFIX + "/api/accounts/add_trae",
|
||||
{"name": "E2E TRAE", "access_token": "eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyUmVnaW9uIjp7InJlZ2lvbiI6IkNOIn19.sig"})
|
||||
check("POST add trae account", st == 200 and d.get("ok") is True, (st, str(d)[:200]))
|
||||
trae_id = d.get("account", {}).get("id")
|
||||
|
||||
st, d = jget(CGI_PATH_PREFIX + "/api/accounts")
|
||||
check("account token masked", "eyJhbGciOiJSUzI1NiJ9" not in json.dumps(d), str(d)[:200])
|
||||
|
||||
st, d = jpost(CGI_PATH_PREFIX + "/api/settings",
|
||||
{"schedule_times": ["07:30", "21:00"], "webhook_url": "", "notify_on": ["fail"]})
|
||||
check("POST settings", st == 200 and d["settings"]["schedule_times"] == ["07:30", "21:00"], (st, str(d)[:200]))
|
||||
|
||||
st, d = jpost(CGI_PATH_PREFIX + "/api/checkin", {})
|
||||
check("POST checkin -> trigger mode", st == 200 and d.get("mode") == "trigger", (st, str(d)[:200]))
|
||||
# daemon should pick up the trigger within ~20s
|
||||
deadline = time.time() + 40
|
||||
ran = False
|
||||
while time.time() < deadline:
|
||||
st, d = jget(CGI_PATH_PREFIX + "/api/state")
|
||||
if d.get("today_results"):
|
||||
ran = True
|
||||
break
|
||||
time.sleep(2)
|
||||
check("daemon executed triggered check-in", ran, str(d)[:300])
|
||||
if ran:
|
||||
res = d["today_results"].get(trae_id, {})
|
||||
check("trae result recorded (no_session expected with fake token)",
|
||||
res.get("status") in ("no_session", "fail", "ok", "already", "retry_later"),
|
||||
str(res)[:200])
|
||||
|
||||
st, d = jget(CGI_PATH_PREFIX + "/api/logs?lines=50")
|
||||
check("GET logs", st == 200 and len(d.get("lines", [])) > 0, (st, str(d)[:150]))
|
||||
|
||||
print("== cmd/main stop ==")
|
||||
r = sh("%s/cmd/main stop" % APPDEST, env=ENV_BASE)
|
||||
check("stop: exit 0", r.returncode == 0, r.stdout + r.stderr)
|
||||
time.sleep(0.5)
|
||||
r = sh("%s/cmd/main status" % APPDEST, env=ENV_BASE)
|
||||
check("status: stopped after stop (exit 3)", r.returncode == 3, r.stdout + r.stderr)
|
||||
|
||||
server.shutdown()
|
||||
print()
|
||||
print("PASSED: %d FAILED: %d" % (len(PASS), len(FAIL)))
|
||||
if FAIL:
|
||||
print("failures:", FAIL)
|
||||
return 1 if FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Full user-journey test against the REAL NAS installation."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
BASE = "https://192.168.31.10:5667/cgi/ThirdParty/AgentCheckin/index.cgi"
|
||||
PASS, FAIL = [], []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
(PASS if cond else FAIL).append(name)
|
||||
print((" PASS " if cond else " FAIL ") + name + ("" if cond else " " + str(extra)[:300]))
|
||||
|
||||
|
||||
def cookie():
|
||||
r = subprocess.run(["bash", "/tmp/get_cookie.sh"], capture_output=True, text=True, timeout=60)
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def api(method, path, body=None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(BASE + path, data=data, method=method)
|
||||
if data:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
import http.cookiejar
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPSHandler(context=ctx),
|
||||
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
||||
# inject ost cookie manually
|
||||
req.add_header("Cookie", "ost=" + COOKIE)
|
||||
try:
|
||||
with opener.open(req, timeout=60) as resp:
|
||||
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read().decode("utf-8"))
|
||||
|
||||
|
||||
COOKIE = cookie()
|
||||
print("cookie acquired:", COOKIE[:16], "...")
|
||||
|
||||
print("== 1. dashboard state ==")
|
||||
st, d = api("GET", "/api/state")
|
||||
check("GET /api/state", st == 200 and d.get("today") is not None, (st, d))
|
||||
check("daemon alive", d.get("daemon_alive") is True, d.get("daemon_alive"))
|
||||
check("next run scheduled", bool(d.get("next_run_at")), d.get("next_run_at"))
|
||||
print(" daemon:", d.get("daemon"), "| next:", d.get("next_run_at"))
|
||||
|
||||
print("== 2. add TRAE account (fake token) ==")
|
||||
st, d = api("POST", "/api/accounts/add_trae", {
|
||||
"name": "NAS 测试账号",
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyUmVnaW9uIjp7InJlZ2lvbiI6IkNOIn19.sig",
|
||||
})
|
||||
check("add trae account", st == 200 and d.get("ok") is True, (st, d))
|
||||
trae_id = d.get("account", {}).get("id")
|
||||
check("token masked in response", "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" not in json.dumps(d), d)
|
||||
|
||||
print("== 3. add WorkBuddy account (manual, fake token) ==")
|
||||
st, d = api("POST", "/api/accounts/add_wb_manual", {
|
||||
"name": "NAS 测试 WB", "access_token": "fake-wb-token-xyz", "domain": "codebuddy.cn",
|
||||
})
|
||||
check("add wb account", st == 200 and d.get("ok") is True, (st, d))
|
||||
wb_id = d.get("account", {}).get("id")
|
||||
|
||||
print("== 4. account list ==")
|
||||
st, d = api("GET", "/api/accounts")
|
||||
check("2 accounts listed", len(d.get("accounts", [])) == 2, d)
|
||||
check("no raw tokens anywhere", "fake-wb-token-xyz" not in json.dumps(d), d)
|
||||
|
||||
print("== 5. trigger check-in (立即签到) ==")
|
||||
st, d = api("POST", "/api/checkin", {})
|
||||
check("checkin triggered", st == 200 and d.get("ok") is True, (st, d))
|
||||
print(" mode:", d.get("mode"))
|
||||
|
||||
print("== 6. wait for daemon to execute ==")
|
||||
deadline = time.time() + 60
|
||||
results = {}
|
||||
while time.time() < deadline:
|
||||
st, d = api("GET", "/api/state")
|
||||
tr = d.get("today_results") or {}
|
||||
if trae_id in tr and wb_id in tr:
|
||||
results = tr
|
||||
break
|
||||
time.sleep(2)
|
||||
check("both accounts ran", bool(results), d.get("today_results"))
|
||||
if results:
|
||||
for aid, r in results.items():
|
||||
print(" %s (%s): %s — %s" % (r.get("name"), r.get("platform"), r.get("status"), r.get("message")))
|
||||
check("trae handled gracefully (no_session expected)",
|
||||
results.get(trae_id, {}).get("status") in ("no_session", "fail", "ok", "already", "retry_later"),
|
||||
results.get(trae_id))
|
||||
check("wb handled gracefully",
|
||||
results.get(wb_id, {}).get("status") in ("no_session", "fail", "ok", "already", "retry_later"),
|
||||
results.get(wb_id))
|
||||
|
||||
print("== 7. logs ==")
|
||||
st, d = api("GET", "/api/logs?lines=30")
|
||||
check("logs returned", st == 200 and len(d.get("lines", [])) > 0, (st, d))
|
||||
for line in d.get("lines", [])[-4:]:
|
||||
print(" ", line)
|
||||
|
||||
print("== 8. settings update ==")
|
||||
st, d = api("POST", "/api/settings", {
|
||||
"schedule_times": ["00:05", "09:00", "12:30", "19:00", "22:00"],
|
||||
"webhook_url": "", "notify_on": ["fail", "success"],
|
||||
})
|
||||
check("settings saved", st == 200 and "22:00" in d["settings"]["schedule_times"], (st, d))
|
||||
st, d = api("GET", "/api/state")
|
||||
check("settings reflected in state", "22:00" in (d.get("settings") or {}).get("schedule_times", []), d.get("settings"))
|
||||
|
||||
print("== 9. account disable/enable/delete ==")
|
||||
st, d = api("POST", "/api/accounts/update", {"id": wb_id, "enabled": False})
|
||||
check("disable account", st == 200 and d["account"]["enabled"] is False, (st, d))
|
||||
st, d = api("POST", "/api/accounts/update", {"id": wb_id, "enabled": True})
|
||||
check("enable account", st == 200 and d["account"]["enabled"] is True, (st, d))
|
||||
st, d = api("POST", "/api/accounts/delete", {"id": wb_id})
|
||||
check("delete wb account", st == 200 and d.get("ok") is True, (st, d))
|
||||
st, d = api("POST", "/api/accounts/delete", {"id": trae_id})
|
||||
check("delete trae account", st == 200 and d.get("ok") is True, (st, d))
|
||||
st, d = api("GET", "/api/accounts")
|
||||
check("accounts empty again", len(d.get("accounts", [])) == 0, d)
|
||||
|
||||
print("== 10. static assets ==")
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
for path, marker in [("/", "Agent 签到助手"), ("/assets/app.js", "AgentCheckin"),
|
||||
("/assets/style.css", "--primary"), ("/assets/logo.svg", "<svg")]:
|
||||
req = urllib.request.Request(BASE + path)
|
||||
req.add_header("Cookie", "ost=" + COOKIE)
|
||||
with urllib.request.urlopen(req, timeout=20, context=ctx) as r:
|
||||
body = r.read().decode("utf-8", "replace")
|
||||
check("GET %s" % path, r.status == 200 and marker in body, body[:100])
|
||||
|
||||
print()
|
||||
print("PASSED: %d FAILED: %d" % (len(PASS), len(FAIL)))
|
||||
if FAIL:
|
||||
print("failures:", FAIL)
|
||||
sys.exit(1 if FAIL else 0)
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real-clock scheduling test: set a slot 1 minute ahead, start the daemon,
|
||||
and verify it fires the scheduled run on its own."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
|
||||
ROOT = "/vol1/@appshare/fn-deepseek-harness/fnos-agent-checkin"
|
||||
SIM = "/tmp/fnos-sim"
|
||||
APPDEST = SIM + "/var/apps/AgentCheckin"
|
||||
PKGVAR = SIM + "/appdata/AgentCheckin"
|
||||
TARGET = APPDEST + "/target"
|
||||
BACKEND = TARGET + "/backend"
|
||||
|
||||
shutil.rmtree(SIM, ignore_errors=True)
|
||||
os.makedirs(APPDEST)
|
||||
os.makedirs(PKGVAR)
|
||||
os.makedirs(SIM + "/pkg")
|
||||
subprocess.run(f"tar xzf {ROOT}/AgentCheckin/AgentCheckin.fpk -C {SIM}/pkg", shell=True, check=True)
|
||||
with tarfile.open(SIM + "/pkg/app.tgz") as t:
|
||||
t.extractall(TARGET)
|
||||
for item in ("cmd", "config", "manifest", "wizard"):
|
||||
src, dst = f"{SIM}/pkg/{item}", f"{APPDEST}/{item}"
|
||||
shutil.copytree(src, dst) if os.path.isdir(src) else shutil.copy(src, dst)
|
||||
subprocess.run(f"chmod +x {TARGET}/ui/index.cgi {APPDEST}/cmd/*", shell=True)
|
||||
|
||||
env = dict(os.environ, TRIM_APPDEST=APPDEST, TRIM_PKGVAR=PKGVAR)
|
||||
subprocess.run(f"TRIM_PKGVAR={PKGVAR} bash {APPDEST}/cmd/install_callback",
|
||||
shell=True, env=env, check=True)
|
||||
|
||||
add_account = (
|
||||
"import sys; sys.path.insert(0, %r)\n"
|
||||
"import engine\n"
|
||||
"engine.add_trae('sched-test', 'eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyUmVnaW9uIjp7InJlZ2lvbiI6IkNOIn19.sig')\n"
|
||||
) % BACKEND
|
||||
subprocess.run([sys.executable, "-c", add_account], env=env, check=True,
|
||||
capture_output=True)
|
||||
|
||||
slot = time.strftime("%H:%M", time.localtime(time.time() + 60))
|
||||
set_slot = (
|
||||
"import sys; sys.path.insert(0, %r)\n"
|
||||
"import common\n"
|
||||
"s = common.load_settings(); s['schedule_times'] = [%r]\n"
|
||||
"common.save_settings(s)\n"
|
||||
"print('slot:', %r)\n"
|
||||
) % (BACKEND, slot, slot)
|
||||
r = subprocess.run([sys.executable, "-c", set_slot], env=env, check=True,
|
||||
capture_output=True, text=True)
|
||||
print(r.stdout.strip(), "| now:", time.strftime("%H:%M:%S"))
|
||||
|
||||
r = subprocess.run(f"bash {APPDEST}/cmd/main start", shell=True, env=env,
|
||||
capture_output=True, text=True)
|
||||
print("start:", r.stdout.strip() or r.stderr.strip())
|
||||
|
||||
deadline = time.time() + 170
|
||||
fired = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
st = json.load(open(PKGVAR + "/state.json"))
|
||||
except (OSError, ValueError):
|
||||
time.sleep(3)
|
||||
continue
|
||||
reason = st.get("last_run_reason", "")
|
||||
if reason.startswith("schedule:"):
|
||||
fired = reason
|
||||
break
|
||||
time.sleep(3)
|
||||
|
||||
subprocess.run(f"bash {APPDEST}/cmd/main stop", shell=True, env=env,
|
||||
capture_output=True)
|
||||
print("SCHEDULED RUN:", fired)
|
||||
ok = fired == "schedule:" + slot
|
||||
print("RESULT:", "PASS" if ok else "FAIL")
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/* Regression test for the settings time-chip race condition.
|
||||
|
||||
Reproduces the exact user-reported bug:
|
||||
1. open the settings tab (local copy seeded from server state)
|
||||
2. the 10s state poll fires and replaces `state` with fresh server data
|
||||
3. the user picks a time and clicks "添加时间点"
|
||||
4. BUG (old code): renderSettings() re-read state.settings -> the new time
|
||||
is silently discarded and the preset times reappear
|
||||
5. FIX: settings is an independent local copy -> the new time survives
|
||||
|
||||
Loads the REAL app/www/assets/app.js with a minimal DOM stub.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ---------------------------------------------------------------- DOM stub
|
||||
let elSeq = 0;
|
||||
function makeEl(id) {
|
||||
const el = {
|
||||
id: id || ("el" + ++elSeq),
|
||||
_listeners: {},
|
||||
_innerHTML: "",
|
||||
value: "",
|
||||
checked: false,
|
||||
textContent: "",
|
||||
className: "",
|
||||
style: {},
|
||||
dataset: {},
|
||||
children: [],
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); },
|
||||
removeEventListener() {},
|
||||
dispatch(type, ev) { (this._listeners[type] || []).slice().forEach((fn) => fn(ev || { target: this })); },
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
querySelector() { return makeEl(); },
|
||||
querySelectorAll() { return []; },
|
||||
select() {},
|
||||
};
|
||||
// mimic the browser: setting innerHTML replaces all children
|
||||
Object.defineProperty(el, "innerHTML", {
|
||||
get() { return el._innerHTML; },
|
||||
set(v) { el._innerHTML = v; if (v === "") el.children.length = 0; },
|
||||
});
|
||||
el.classList = {
|
||||
_s: new Set(),
|
||||
add(c) { this._s.add(c); },
|
||||
remove(c) { this._s.delete(c); },
|
||||
contains(c) { return this._s.has(c); },
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
const elements = {};
|
||||
function getEl(id) {
|
||||
if (!elements[id]) elements[id] = makeEl(id);
|
||||
return elements[id];
|
||||
}
|
||||
|
||||
const tabButtons = ["dashboard", "accounts", "logs", "settings"].map((t) => {
|
||||
const b = makeEl();
|
||||
b.dataset.tab = t;
|
||||
return b;
|
||||
});
|
||||
|
||||
global.document = {
|
||||
hidden: false,
|
||||
getElementById: getEl,
|
||||
querySelectorAll(sel) {
|
||||
if (sel === ".tab") return tabButtons;
|
||||
return [];
|
||||
},
|
||||
createElement() { return makeEl(); },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- fetch stub
|
||||
let serverSettings = {
|
||||
schedule_times: ["00:05", "09:00", "12:30", "19:00"],
|
||||
webhook_url: "",
|
||||
notify_on: ["fail"],
|
||||
};
|
||||
let postedSettings = null;
|
||||
|
||||
global.fetch = function (url, opts) {
|
||||
opts = opts || {};
|
||||
const method = opts.method || "GET";
|
||||
if (url.indexOf("/settings") >= 0 && method === "POST") {
|
||||
postedSettings = JSON.parse(opts.body);
|
||||
serverSettings = postedSettings;
|
||||
return Promise.resolve({ json: () => Promise.resolve({ ok: true, settings: serverSettings }) });
|
||||
}
|
||||
if (url.indexOf("/state") >= 0) {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({
|
||||
accounts: [],
|
||||
settings: serverSettings,
|
||||
today: "2026-10-10",
|
||||
today_results: {},
|
||||
daemon_alive: true,
|
||||
next_run_at: "2026-10-10 09:00:00",
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ json: () => Promise.resolve({ ok: true }) });
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- timers
|
||||
const intervals = [];
|
||||
global.setInterval = (fn) => { intervals.push(fn); return intervals.length; };
|
||||
global.setTimeout = () => 0;
|
||||
global.clearTimeout = () => {};
|
||||
global.clearInterval = () => {};
|
||||
|
||||
// ---------------------------------------------------------------- load app.js
|
||||
const code = fs.readFileSync(
|
||||
path.join(__dirname, "AgentCheckin", "app", "www", "assets", "app.js"), "utf8");
|
||||
eval(code);
|
||||
|
||||
const flush = () => new Promise((r) => setImmediate(r));
|
||||
const chipTimes = () =>
|
||||
getEl("time-chips").children.map((c) => (c.innerHTML || "").replace(/<button.*$/, "").trim());
|
||||
|
||||
(async () => {
|
||||
const results = [];
|
||||
const check = (name, cond, extra) => {
|
||||
results.push([name, cond]);
|
||||
console.log((cond ? " PASS " : " FAIL ") + name + (cond ? "" : " " + String(extra)));
|
||||
};
|
||||
|
||||
await flush(); await flush();
|
||||
|
||||
// 1. open the settings tab
|
||||
tabButtons[3].dispatch("click");
|
||||
check("settings tab shows 4 preset times", JSON.stringify(chipTimes()) === JSON.stringify(["00:05", "09:00", "12:30", "19:00"]), chipTimes());
|
||||
|
||||
// 2. the 10s poll fires (replaces `state` with fresh server data)
|
||||
intervals.forEach((fn) => fn());
|
||||
await flush(); await flush();
|
||||
|
||||
// 3. user picks 21:30 and clicks add
|
||||
getEl("new-time").value = "21:30";
|
||||
getEl("btn-add-time").dispatch("click");
|
||||
|
||||
const chips = chipTimes();
|
||||
check("added time 21:30 survives the poll race", chips.indexOf("21:30") >= 0, chips);
|
||||
check("preset times still present", ["00:05", "09:00", "12:30", "19:00"].every((t) => chips.indexOf(t) >= 0), chips);
|
||||
check("exactly 5 chips", chips.length === 5, chips);
|
||||
|
||||
// 4. duplicate add is rejected
|
||||
getEl("new-time").value = "21:30";
|
||||
getEl("btn-add-time").dispatch("click");
|
||||
check("duplicate time rejected", chipTimes().length === 5, chipTimes());
|
||||
|
||||
// 5. empty input is rejected
|
||||
getEl("new-time").value = "";
|
||||
getEl("btn-add-time").dispatch("click");
|
||||
check("empty time rejected", chipTimes().length === 5, chipTimes());
|
||||
|
||||
// 6. save persists all 5 times to the server
|
||||
getEl("btn-save-settings").dispatch("click");
|
||||
await flush(); await flush();
|
||||
check("save posts 5 times", postedSettings && postedSettings.schedule_times.length === 5, postedSettings);
|
||||
check("saved times include 21:30", postedSettings && postedSettings.schedule_times.indexOf("21:30") >= 0, postedSettings);
|
||||
|
||||
// 7. leave and re-open the tab: shows the saved state (no stale dirty copy)
|
||||
tabButtons[0].dispatch("click"); // leave settings
|
||||
tabButtons[3].dispatch("click"); // come back
|
||||
check("re-open shows saved 5 times", chipTimes().length === 5, chipTimes());
|
||||
|
||||
const failed = results.filter((r) => !r[1]);
|
||||
console.log("\nPASSED: %d FAILED: %d", results.length - failed.length, failed.length);
|
||||
process.exit(failed.length ? 1 : 0);
|
||||
})().catch((e) => { console.error("TEST ERROR:", e); process.exit(1); });
|
||||
Reference in New Issue
Block a user