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:
cbb
2026-10-13 19:09:48 +08:00
commit a3b84d5ce2
39 changed files with 5164 additions and 0 deletions
+19
View File
@@ -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

+386
View File
@@ -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()
+362
View File
@@ -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))
+165
View File
@@ -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())
+472
View File
@@ -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"),
}
+370
View File
@@ -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 账号"
+196
View File
@@ -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 账号"
+326
View File
@@ -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 账号"
+11
View File
@@ -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

+130
View File
@@ -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
+512
View File
@@ -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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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();
})();
+14
View File
@@ -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

+241
View File
@@ -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; }
}
+214
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+11
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+99
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+9
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+8
View File
@@ -0,0 +1,8 @@
{
"defaults":
{
"run-as": "package"
},
"username": "agentcheckin",
"groupname": "agentcheckin"
}
+1
View File
@@ -0,0 +1 @@
{}
+16
View File
@@ -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
+234
View File
@@ -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 地址则不发送任何通知。测试按钮会先保存设置再触发一次真实签到,通知随结果一起送达。
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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())
+147
View File
@@ -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)
+78
View File
@@ -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)
+176
View File
@@ -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); });