commit a3b84d5ce27bc5dc5b8e3b5cfbc29fe649d64e96 Author: cbb Date: Tue Oct 13 19:09:48 2026 +0800 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 前端回归 + 真实时钟调度测试 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78f24d1 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AgentCheckin/ICON.PNG b/AgentCheckin/ICON.PNG new file mode 100644 index 0000000..1fc1cf9 Binary files /dev/null and b/AgentCheckin/ICON.PNG differ diff --git a/AgentCheckin/ICON_256.PNG b/AgentCheckin/ICON_256.PNG new file mode 100644 index 0000000..4c617f5 Binary files /dev/null and b/AgentCheckin/ICON_256.PNG differ diff --git a/AgentCheckin/app/backend/api.py b/AgentCheckin/app/backend/api.py new file mode 100644 index 0000000..d7db8e6 --- /dev/null +++ b/AgentCheckin/app/backend/api.py @@ -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", ""), + "TRIM_APPDEST": os.environ.get("TRIM_APPDEST", ""), + "AGENTCHECKIN_DEV": os.environ.get("AGENTCHECKIN_DEV", ""), + }, + "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() diff --git a/AgentCheckin/app/backend/common.py b/AgentCheckin/app/backend/common.py new file mode 100644 index 0000000..e1d1406 --- /dev/null +++ b/AgentCheckin/app/backend/common.py @@ -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)) diff --git a/AgentCheckin/app/backend/daemon.py b/AgentCheckin/app/backend/daemon.py new file mode 100644 index 0000000..9cb54c7 --- /dev/null +++ b/AgentCheckin/app/backend/daemon.py @@ -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()) diff --git a/AgentCheckin/app/backend/engine.py b/AgentCheckin/app/backend/engine.py new file mode 100644 index 0000000..c582e00 --- /dev/null +++ b/AgentCheckin/app/backend/engine.py @@ -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: //<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"), + } diff --git a/AgentCheckin/app/backend/qoder.py b/AgentCheckin/app/backend/qoder.py new file mode 100644 index 0000000..9e4e7b4 --- /dev/null +++ b/AgentCheckin/app/backend/qoder.py @@ -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 账号" diff --git a/AgentCheckin/app/backend/trae.py b/AgentCheckin/app/backend/trae.py new file mode 100644 index 0000000..b59d4a7 --- /dev/null +++ b/AgentCheckin/app/backend/trae.py @@ -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 账号" diff --git a/AgentCheckin/app/backend/workbuddy.py b/AgentCheckin/app/backend/workbuddy.py new file mode 100644 index 0000000..9e21c67 --- /dev/null +++ b/AgentCheckin/app/backend/workbuddy.py @@ -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 账号" diff --git a/AgentCheckin/app/ui/config b/AgentCheckin/app/ui/config new file mode 100644 index 0000000..4952be8 --- /dev/null +++ b/AgentCheckin/app/ui/config @@ -0,0 +1,11 @@ +{ + ".url": { + "AgentCheckin.Application": { + "title": "Agent 签到助手", + "icon": "images/icon_{0}.png", + "type": "iframe", + "url": "/cgi/ThirdParty/AgentCheckin/index.cgi/", + "allUsers": true + } + } +} diff --git a/AgentCheckin/app/ui/images/icon_256.png b/AgentCheckin/app/ui/images/icon_256.png new file mode 100644 index 0000000..4c617f5 Binary files /dev/null and b/AgentCheckin/app/ui/images/icon_256.png differ diff --git a/AgentCheckin/app/ui/images/icon_64.png b/AgentCheckin/app/ui/images/icon_64.png new file mode 100644 index 0000000..1fc1cf9 Binary files /dev/null and b/AgentCheckin/app/ui/images/icon_64.png differ diff --git a/AgentCheckin/app/ui/index.cgi b/AgentCheckin/app/ui/index.cgi new file mode 100644 index 0000000..eb8ad6b --- /dev/null +++ b/AgentCheckin/app/ui/index.cgi @@ -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 diff --git a/AgentCheckin/app/www/assets/app.js b/AgentCheckin/app/www/assets/app.js new file mode 100644 index 0000000..b8c5abf --- /dev/null +++ b/AgentCheckin/app/www/assets/app.js @@ -0,0 +1,512 @@ +/* AgentCheckin frontend — vanilla JS, no dependencies */ +(function () { + "use strict"; + + var state = null; + var pollTimer = null; + var oauthState = null; + var oauthTimer = null; + var logTimer = null; + + var $ = function (id) { return document.getElementById(id); }; + + // ---------------------------------------------------------------- helpers + function toast(msg, kind) { + var t = $("toast"); + t.textContent = msg; + t.className = "toast" + (kind ? " " + kind : ""); + clearTimeout(t._timer); + t._timer = setTimeout(function () { t.classList.add("hidden"); }, 3200); + } + + function api(method, path, body) { + var opts = { method: method, headers: {} }; + if (body !== undefined && body !== null) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + return fetch("api" + path, opts).then(function (r) { + return r.json().catch(function () { return {}; }); + }).then(function (d) { + if (d && d.error) throw new Error(d.error); + return d; + }); + } + + function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + var STATUS = { + ok: { label: "签到成功", cls: "ok", icon: "✅" }, + ok_unverified: { label: "已领取·待确认", cls: "ok_unverified", icon: "✅" }, + already: { label: "今日已签", cls: "already", icon: "☑️" }, + inactive: { label: "未开放签到", cls: "inactive", icon: "➖" }, + fail: { label: "签到失败", cls: "fail", icon: "❌" }, + no_session: { label: "需重新授权", cls: "no_session", icon: "🔒" }, + no_auth: { label: "缺少凭据", cls: "no_auth", icon: "🔒" }, + retry_later: { label: "风控冷却", cls: "retry_later", icon: "⏳" } + }; + + function platformName(p) { return p === "trae" ? "TRAE" : (p === "qoder" ? "Qoder" : "WorkBuddy"); } + + // ---------------------------------------------------------------- tabs + document.querySelectorAll(".tab").forEach(function (btn) { + btn.addEventListener("click", function () { + document.querySelectorAll(".tab").forEach(function (b) { b.classList.remove("active"); }); + document.querySelectorAll(".tabpage").forEach(function (p) { p.classList.remove("active"); }); + btn.classList.add("active"); + $("tab-" + btn.dataset.tab).classList.add("active"); + if (btn.dataset.tab === "logs") loadLogs(); + if (btn.dataset.tab === "accounts") renderAccounts(); + if (btn.dataset.tab === "settings") openSettingsTab(); + }); + }); + + // ---------------------------------------------------------------- dashboard + function renderTop() { + var alive = state && state.daemon_alive; + var chip = $("daemon-chip"); + chip.innerHTML = '<span class="dot"></span>守护进程 ' + (alive ? "运行中" : "已停止"); + chip.className = "chip " + (alive ? "chip-green" : "chip-red"); + var nr = $("nextrun-chip"); + nr.textContent = "下次签到 " + ((state && state.next_run_at) || "—"); + nr.className = "chip " + (state && state.next_run_at ? "chip-blue" : "chip-gray"); + } + + function renderCards() { + var wrap = $("dash-cards"); + var accounts = (state && state.accounts) || []; + if (!accounts.length) { + wrap.innerHTML = '<div class="panel" style="grid-column:1/-1;text-align:center;color:var(--muted)">' + + "暂无账号,请先到「账号管理」添加 TRAE / WorkBuddy 账号</div>"; + return; + } + wrap.innerHTML = accounts.map(function (a) { + var st = a.today && a.today.status ? STATUS[a.today.status] : null; + var statusHtml = st + ? '<span class="status-chip ' + st.cls + '">' + st.icon + " " + st.label + "</span>" + : '<span class="status-chip pending">⏳ 今日未签</span>'; + var credits = ""; + if (a.last_result && typeof a.last_result.credits === "number") { + credits = "剩余积分 <b>" + a.last_result.credits + "</b>"; + } else if (a.last_result && a.last_result.points) { + credits = "本次 +<b>" + a.last_result.points + "</b>"; + } + var msg = a.today && a.today.message ? esc(a.today.message) : ""; + return ( + '<div class="acct-card' + (a.enabled ? "" : " disabled") + '">' + + '<div class="acct-head"><span class="platform-badge ' + esc(a.platform) + '">' + platformName(a.platform) + "</span>" + + '<span class="acct-name">' + esc(a.name) + "</span></div>" + + '<div class="acct-status">' + statusHtml + (msg ? '<span class="muted">' + msg + "</span>" : "") + "</div>" + + '<div class="acct-meta">' + (credits ? "<span>" + credits + "</span>" : "") + + (a.last_result ? "<span>最近运行 " + esc(a.last_result.time || "") + "</span>" : "") + + (!a.enabled ? "<span>已禁用</span>" : "") + "</div>" + + '<div class="acct-actions">' + + '<button class="btn btn-sm" data-act="run" data-id="' + esc(a.id) + '">立即签到</button>' + + '<button class="btn btn-sm" data-act="test" data-id="' + esc(a.id) + '">测连接</button>' + + "</div></div>" + ); + }).join(""); + + wrap.querySelectorAll("button[data-act]").forEach(function (btn) { + btn.addEventListener("click", function () { + if (btn.dataset.act === "run") runCheckin(btn.dataset.id, btn); + else testAccount(btn.dataset.id, btn); + }); + }); + } + + var lastStateJson = ""; + function refreshState(force) { + return api("GET", "/state").then(function (d) { + var json = JSON.stringify(d); + if (!force && json === lastStateJson) return; // 数据没变,跳过重渲染 + lastStateJson = json; + state = d; + renderTop(); + renderCards(); + }).catch(function (e) { /* keep old state on transient errors */ }); + } + + // ---------------------------------------------------------------- run / test + var runningBtn = null; + function runCheckin(accountId, btn) { + if (btn) { btn.disabled = true; btn.textContent = "执行中…"; runningBtn = btn; } + api("POST", "/checkin", accountId ? { account_id: accountId } : {}) + .then(function (d) { + if (d.mode === "trigger") { + toast("已触发签到,守护进程正在执行…"); + waitForRun(accountId); + } else { + toast("签到完成", "ok"); + refreshState(); + } + }) + .catch(function (e) { toast(e.message, "err"); }) + .finally(function () { + if (runningBtn) { runningBtn.disabled = false; runningBtn.textContent = "立即签到"; runningBtn = null; } + }); + } + + function waitForRun(accountId) { + // poll until today_results contains a fresh entry for the target + var started = Date.now(); + var before = JSON.stringify((state && state.today_results) || {}); + (function poll() { + if (Date.now() - started > 90000) { refreshState(); return; } + refreshState().then(function () { + var after = JSON.stringify((state && state.today_results) || {}); + var done = after !== before && (!accountId || (state.today_results || {})[accountId]); + if (done) { + var r = accountId ? state.today_results[accountId] : null; + if (r) toast(platformName(r.platform) + " " + r.name + ":" + (r.message || r.status), r.status === "fail" || r.status === "no_session" ? "err" : "ok"); + else toast("签到完成", "ok"); + } else { + setTimeout(poll, 3000); + } + }); + })(); + } + + function testAccount(id, btn) { + btn.disabled = true; btn.textContent = "检测中…"; + api("POST", "/accounts/test", { id: id }) + .then(function (d) { + var parts = []; + if (d.refresh) parts.push("续期:" + d.refresh); + if (typeof d.checked_in === "boolean") parts.push("今日" + (d.checked_in ? "已签" : "未签")); + if (d.enable === false) parts.push("活动未开放"); + if (d.credits && typeof d.credits.remain === "number") parts.push("剩余积分 " + d.credits.remain); + else if (typeof d.credits === "number") parts.push("积分 " + d.credits); + if (d.hint) parts.push(d.hint); + if (d.error) parts.push("错误:" + d.error); + toast(parts.join(";") || "检测完成", d.error ? "err" : "ok"); + }) + .catch(function (e) { toast(e.message, "err"); }) + .finally(function () { btn.disabled = false; btn.textContent = "测连接"; }); + } + + // ---------------------------------------------------------------- accounts tab + function renderAccounts() { + var wrap = $("account-table-wrap"); + var accounts = (state && state.accounts) || []; + if (!accounts.length) { + wrap.innerHTML = '<p class="muted">暂无账号,点击右上角「添加账号」。</p>'; + return; + } + var rows = accounts.map(function (a) { + var st = a.today && a.today.status ? STATUS[a.today.status] : null; + return "<tr>" + + "<td><span class='platform-badge " + esc(a.platform) + "'>" + platformName(a.platform) + "</span></td>" + + "<td>" + esc(a.name) + (a.need_reauth ? ' <span class="status-chip no_session">需重新授权</span>' : "") + + (a.platform === "qoder" ? '<div class="muted" style="font-size:11px">' + esc((a.auth && a.auth.domain) || "") + "</div>" : "") + "</td>" + + "<td>" + esc(a.auth.accessToken || "") + "</td>" + + "<td>" + (st ? st.icon + " " + st.label : "⏳ 今日未签") + "</td>" + + "<td>" + (a.last_result && typeof a.last_result.credits === "number" ? a.last_result.credits : "—") + "</td>" + + "<td>" + (a.enabled ? "启用" : "禁用") + "</td>" + + '<td class="actions">' + + '<button class="btn btn-sm" data-a2="run" data-id="' + esc(a.id) + '">签到</button>' + + (a.platform === "qoder" ? '<button class="btn btn-sm" data-a2="fixdomain" data-id="' + esc(a.id) + '" title="自动探测 Token 所属站点并切换">修复站点</button>' : "") + + '<button class="btn btn-sm" data-a2="toggle" data-id="' + esc(a.id) + '">' + (a.enabled ? "禁用" : "启用") + "</button>" + + '<button class="btn btn-sm btn-danger" data-a2="del" data-id="' + esc(a.id) + '">删除</button>' + + "</td></tr>"; + }).join(""); + wrap.innerHTML = '<table class="tbl"><thead><tr><th>平台</th><th>名称</th><th>Token</th><th>今日</th><th>积分</th><th>状态</th><th>操作</th></tr></thead><tbody>' + rows + "</tbody></table>"; + wrap.querySelectorAll("button[data-a2]").forEach(function (btn) { + btn.addEventListener("click", function () { + var id = btn.dataset.id, act = btn.dataset.a2; + if (act === "run") runCheckin(id, btn); + else if (act === "fixdomain") { + btn.disabled = true; btn.textContent = "探测中…"; + api("POST", "/accounts/fix_domain", { id: id }) + .then(function (d) { + if (d.ok) { toast("已切换到 " + d.domain, "ok"); refreshState(); } + else { toast(d.error || "探测失败", "err"); } + }) + .catch(function (e) { toast(e.message, "err"); }) + .finally(function () { btn.disabled = false; btn.textContent = "修复站点"; }); + } + else if (act === "toggle") { + var acc = state.accounts.find(function (x) { return x.id === id; }); + api("POST", "/accounts/update", { id: id, enabled: !acc.enabled }) + .then(function () { refreshState(); }).catch(function (e) { toast(e.message, "err"); }); + } else if (act === "del") { + if (!confirm("确定删除该账号?")) return; + api("POST", "/accounts/delete", { id: id }) + .then(function () { toast("已删除", "ok"); refreshState(); }) + .catch(function (e) { toast(e.message, "err"); }); + } + }); + }); + } + + // ---------------------------------------------------------------- add modal + function openModal() { $("modal-add").classList.remove("hidden"); } + function closeModal(id) { $(id).classList.add("hidden"); stopOauth(); $("add-msg").textContent = ""; } + + document.querySelectorAll("[data-close]").forEach(function (b) { + b.addEventListener("click", function () { closeModal(b.dataset.close); }); + }); + $("modal-add").addEventListener("click", function (e) { + if (e.target === this) closeModal("modal-add"); + }); + $("btn-add-account").addEventListener("click", openModal); + + document.querySelectorAll(".mtab").forEach(function (btn) { + btn.addEventListener("click", function () { + document.querySelectorAll(".mtab").forEach(function (b) { b.classList.remove("active"); }); + document.querySelectorAll(".mtabpage").forEach(function (p) { p.classList.remove("active"); }); + btn.classList.add("active"); + $("mtab-" + btn.dataset.mtab).classList.add("active"); + stopOauth(); + }); + }); + + function addMsg(msg, ok) { + var m = $("add-msg"); + m.textContent = msg; + m.className = "msg " + (ok ? "ok" : "err"); + } + + $("btn-wb-manual-add").addEventListener("click", function () { + var token = $("wb-token").value.trim(); + if (!token) return addMsg("请填写 Access Token", false); + api("POST", "/accounts/add_wb_manual", { + name: $("wb-name").value.trim(), + access_token: token, + refresh_token: $("wb-refresh").value.trim(), + domain: $("wb-domain").value + }).then(function () { + toast("WorkBuddy 账号已添加", "ok"); + closeModal("modal-add"); refreshState(); + }).catch(function (e) { addMsg(e.message, false); }); + }); + + $("btn-trae-add").addEventListener("click", function () { + var token = $("trae-token").value.trim(); + if (!token) return addMsg("请填写 Access Token", false); + api("POST", "/accounts/add_trae", { + name: $("trae-name").value.trim(), + access_token: token, + refresh_token: $("trae-refresh").value.trim() + }).then(function () { + toast("TRAE 账号已添加", "ok"); + closeModal("modal-add"); refreshState(); + }).catch(function (e) { addMsg(e.message, false); }); + }); + + + $("btn-qoder-add").addEventListener("click", function () { + var token = $("qoder-token").value.trim(); + if (!token) return addMsg("请填写 Access Token", false); + if (token.length < 10) return addMsg("Access Token 仅 " + token.length + " 字符,明显不完整,请重新复制", false); + var rt0 = $("qoder-refresh").value.trim(); + if (rt0 && rt0.length < 10) return addMsg("Refresh Token 仅 " + rt0.length + " 字符,明显不完整,请重新复制", false); + var dev = {}; + var map = { + "qoder-machine-id": "machineId", + "qoder-machine-token": "machineToken", + "qoder-machine-code": "machineCode", + "qoder-machine-type": "machineType", + "qoder-version": "version", + }; + Object.keys(map).forEach(function (id) { + var v = $(id).value.trim(); + if (v) dev[map[id]] = v; + }); + api("POST", "/accounts/add_qoder", { + name: $("qoder-name").value.trim(), + access_token: token, + refresh_token: $("qoder-refresh").value.trim(), + domain: $("qoder-domain").value, + device: dev + }).then(function () { + toast("Qoder 账号已添加", "ok"); + closeModal("modal-add"); refreshState(); + }).catch(function (e) { addMsg(e.message, false); }); + }); + + // ---- workbuddy oauth flow + function stopOauth() { + if (oauthTimer) { clearInterval(oauthTimer); oauthTimer = null; } + oauthState = null; + $("oauth-box").classList.add("hidden"); + } + + $("btn-oauth-begin").addEventListener("click", function () { + this.disabled = true; + api("POST", "/wb_oauth_begin", {}) + .then(function (d) { + oauthState = d.state; + $("oauth-url").value = d.login_url; + $("oauth-box").classList.remove("hidden"); + $("oauth-status").textContent = "等待登录…(链接 " + Math.round(d.ttl / 60) + " 分钟内有效)"; + oauthTimer = setInterval(pollOauth, 2000); + }) + .catch(function (e) { addMsg(e.message, false); }) + .finally(function () { this.disabled = false; }.bind(this)); + }); + + $("btn-oauth-copy").addEventListener("click", function () { + var input = $("oauth-url"); + input.select(); + var done = function () { toast("链接已复制", "ok"); }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(input.value).then(done, function () { document.execCommand("copy"); done(); }); + } else { document.execCommand("copy"); done(); } + }); + + function pollOauth() { + if (!oauthState) return; + api("GET", "/wb_oauth_poll?state=" + encodeURIComponent(oauthState)) + .then(function (d) { + if (d.status === "done") { + stopOauth(); + toast("WorkBuddy 账号已绑定", "ok"); + closeModal("modal-add"); + refreshState(); + } + }) + .catch(function (e) { + $("oauth-status").textContent = e.message; + stopOauth(); + }); + } + + // ---------------------------------------------------------------- logs + function loadLogs() { + api("GET", "/logs?lines=300").then(function (d) { + $("log-view").textContent = (d.lines || []).join("\n") || "暂无日志"; + $("log-view").scrollTop = $("log-view").scrollHeight; + }).catch(function () { + $("log-view").textContent = "日志加载失败"; + }); + } + $("btn-refresh-logs").addEventListener("click", loadLogs); + setInterval(function () { + if (document.hidden) return; + if ($("log-autorefresh").checked && $("tab-logs").classList.contains("active")) loadLogs(); + }, 15000); + + // ---------------------------------------------------------------- settings + // Local editing copy: initialised from server state ONLY when the tab is + // opened (and there are no unsaved edits). The 10s state poll must never + // overwrite it, otherwise in-progress edits are silently discarded. + var settings = null; + var settingsDirty = false; + + function openSettingsTab() { + if (!settingsDirty) { + settings = JSON.parse(JSON.stringify( + (state && state.settings) || { schedule_times: [], webhook_url: "", notify_on: [] })); + } + renderSettings(); + } + + function renderSettings() { + // renders ONLY from the local copy — never re-reads from `state` here + settings = settings || { schedule_times: [], webhook_url: "", notify_on: [] }; + var chips = $("time-chips"); + chips.innerHTML = ""; + (settings.schedule_times || []).forEach(function (t) { + var el = document.createElement("span"); + el.className = "time-chip"; + el.innerHTML = esc(t) + '<button title="删除">×</button>'; + el.querySelector("button").addEventListener("click", function () { + settings.schedule_times = settings.schedule_times.filter(function (x) { return x !== t; }); + settingsDirty = true; + renderSettings(); + }); + chips.appendChild(el); + }); + $("webhook-url").value = settings.webhook_url || ""; + $("webhook-type").value = settings.webhook_type || "auto"; + $("notify-fail").checked = (settings.notify_on || []).indexOf("fail") >= 0; + $("notify-success").checked = (settings.notify_on || []).indexOf("success") >= 0; + } + + $("btn-add-time").addEventListener("click", function () { + var v = $("new-time").value; + if (!v) { toast("请先选择要添加的时间", "err"); return; } + settings.schedule_times = settings.schedule_times || []; + if (settings.schedule_times.indexOf(v) >= 0) { + toast("该时间已存在", "err"); + return; + } + settings.schedule_times.push(v); + settings.schedule_times.sort(); + settingsDirty = true; + renderSettings(); + $("new-time").value = ""; + }); + + $("btn-save-settings").addEventListener("click", function () { + var times = (settings.schedule_times || []).slice().sort(); + if (!times.length) { $("settings-msg").textContent = "至少保留一个签到时间点"; $("settings-msg").className = "msg err"; return; } + var notify = []; + if ($("notify-fail").checked) notify.push("fail"); + if ($("notify-success").checked) notify.push("success"); + api("POST", "/settings", { + schedule_times: times, + webhook_url: $("webhook-url").value.trim(), + webhook_type: $("webhook-type").value, + notify_on: notify + }).then(function () { + settingsDirty = false; + $("settings-msg").textContent = "已保存"; + $("settings-msg").className = "msg ok"; + toast("设置已保存", "ok"); + refreshState(); + }).catch(function (e) { + $("settings-msg").textContent = e.message; + $("settings-msg").className = "msg err"; + }); + }); + + $("btn-test-notify").addEventListener("click", function () { + var url = $("webhook-url").value.trim(); + if (!url) { $("settings-msg").textContent = "请先填写 Webhook 地址"; $("settings-msg").className = "msg err"; return; } + // save first so the daemon/notify uses the new url, then fire a real run + var notify = []; + if ($("notify-fail").checked) notify.push("fail"); + if ($("notify-success").checked) notify.push("success"); + api("POST", "/settings", { webhook_url: url, webhook_type: $("webhook-type").value, notify_on: notify }) + .then(function () { + settingsDirty = false; + return api("POST", "/checkin", {}); + }) + .then(function () { + $("settings-msg").textContent = "已触发一次签到,通知将随结果发送"; + $("settings-msg").className = "msg ok"; + waitForRun(null); + }) + .catch(function (e) { + $("settings-msg").textContent = e.message; + $("settings-msg").className = "msg err"; + }); + }); + + // ---------------------------------------------------------------- run all + $("btn-run-all").addEventListener("click", function () { runCheckin(null, null); }); + + // ---------------------------------------------------------------- boot + var STATE_POLL_MS = 30000; + function boot() { + refreshState(true); + renderAccounts(); + pollTimer = setInterval(function () { + if (document.hidden) return; // 页面/iframe 不可见时暂停轮询 + refreshState().then(renderAccountsIfVisible); + }, STATE_POLL_MS); + document.addEventListener("visibilitychange", function () { + if (!document.hidden) refreshState(true).then(renderAccountsIfVisible); + }); + } + function renderAccountsIfVisible() { + if ($("tab-accounts").classList.contains("active")) renderAccounts(); + } + boot(); +})(); diff --git a/AgentCheckin/app/www/assets/logo.svg b/AgentCheckin/app/www/assets/logo.svg new file mode 100644 index 0000000..8c5ac4f --- /dev/null +++ b/AgentCheckin/app/www/assets/logo.svg @@ -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> diff --git a/AgentCheckin/app/www/assets/style.css b/AgentCheckin/app/www/assets/style.css new file mode 100644 index 0000000..f257230 --- /dev/null +++ b/AgentCheckin/app/www/assets/style.css @@ -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; } +} diff --git a/AgentCheckin/app/www/index.html b/AgentCheckin/app/www/index.html new file mode 100644 index 0000000..cbe3846 --- /dev/null +++ b/AgentCheckin/app/www/index.html @@ -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 签到助手 + + + +
+
+ +
+

Agent 签到助手

+

TRAE · WorkBuddy 每日积分自动签到

+
+
+
+ 守护进程 — + 下次签到 — + +
+
+ + + +
+ +
+
+
+

使用说明

+
    +
  • TRAE(字节跳动·国内版):每日自动领取 200 Work 专属积分。
  • +
  • WorkBuddy / CodeBuddy(腾讯):7 天循环签到,第 1-6 天每天 100 分,第 7 天 1000 分。
  • +
  • Qoder(国内版):每日自动领取 100 Credits(活动非零点准时下发,多时段自动补领)。
  • +
  • 签到由 NAS 本地守护进程自动执行,失败会在下一个时段自动补签; Token 过期会标记需重新授权。
  • +
  • 所有凭据仅保存在本机 @appdata/AgentCheckin,不会上传到任何第三方。
  • +
+
+
+ + +
+
+
+

账号列表

+ +
+
+
+
+ + +
+
+
+

运行日志

+
+ + +
+
+
加载中…
+
+
+ + +
+
+

签到时间

+

每天在这些时间点自动检查并签到(已签会自动跳过)。错过时段后在守护进程启动时会自动补跑。

+
+
+ + +
+
+
+

消息通知(可选)

+

签到后向 Webhook 发送 POST JSON({"title","content","text","msgtype"}),兼容企业微信/钉钉/飞书/Bark 等机器人。留空则不通知。

+ + +
+ + +
+
+ + +
+
+
+
+
+ + + + + + + + + diff --git a/AgentCheckin/cmd/config_callback b/AgentCheckin/cmd/config_callback new file mode 100644 index 0000000..8c3cbfc --- /dev/null +++ b/AgentCheckin/cmd/config_callback @@ -0,0 +1,3 @@ +#!/bin/bash + +exit 0 diff --git a/AgentCheckin/cmd/config_init b/AgentCheckin/cmd/config_init new file mode 100644 index 0000000..8c3cbfc --- /dev/null +++ b/AgentCheckin/cmd/config_init @@ -0,0 +1,3 @@ +#!/bin/bash + +exit 0 diff --git a/AgentCheckin/cmd/install_callback b/AgentCheckin/cmd/install_callback new file mode 100644 index 0000000..71f6296 --- /dev/null +++ b/AgentCheckin/cmd/install_callback @@ -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 diff --git a/AgentCheckin/cmd/install_init b/AgentCheckin/cmd/install_init new file mode 100644 index 0000000..8c3cbfc --- /dev/null +++ b/AgentCheckin/cmd/install_init @@ -0,0 +1,3 @@ +#!/bin/bash + +exit 0 diff --git a/AgentCheckin/cmd/main b/AgentCheckin/cmd/main new file mode 100644 index 0000000..5da6c6f --- /dev/null +++ b/AgentCheckin/cmd/main @@ -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 diff --git a/AgentCheckin/cmd/uninstall_callback b/AgentCheckin/cmd/uninstall_callback new file mode 100644 index 0000000..8c3cbfc --- /dev/null +++ b/AgentCheckin/cmd/uninstall_callback @@ -0,0 +1,3 @@ +#!/bin/bash + +exit 0 diff --git a/AgentCheckin/cmd/uninstall_init b/AgentCheckin/cmd/uninstall_init new file mode 100644 index 0000000..a05e2c2 --- /dev/null +++ b/AgentCheckin/cmd/uninstall_init @@ -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 diff --git a/AgentCheckin/cmd/upgrade_callback b/AgentCheckin/cmd/upgrade_callback new file mode 100644 index 0000000..8c3cbfc --- /dev/null +++ b/AgentCheckin/cmd/upgrade_callback @@ -0,0 +1,3 @@ +#!/bin/bash + +exit 0 diff --git a/AgentCheckin/cmd/upgrade_init b/AgentCheckin/cmd/upgrade_init new file mode 100644 index 0000000..8c3cbfc --- /dev/null +++ b/AgentCheckin/cmd/upgrade_init @@ -0,0 +1,3 @@ +#!/bin/bash + +exit 0 diff --git a/AgentCheckin/config/privilege b/AgentCheckin/config/privilege new file mode 100644 index 0000000..ccf9ce6 --- /dev/null +++ b/AgentCheckin/config/privilege @@ -0,0 +1,8 @@ +{ + "defaults": + { + "run-as": "package" + }, + "username": "agentcheckin", + "groupname": "agentcheckin" +} diff --git a/AgentCheckin/config/resource b/AgentCheckin/config/resource new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/AgentCheckin/config/resource @@ -0,0 +1 @@ +{} diff --git a/AgentCheckin/manifest b/AgentCheckin/manifest new file mode 100644 index 0000000..0369c33 --- /dev/null +++ b/AgentCheckin/manifest @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..75b4f27 --- /dev/null +++ b/README.md @@ -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://: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 ` 与 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/.send` +- **PushPlus**:`pushplus.plus` 登录 → 复制 token → 地址填 `http://www.pushplus.plus/send?token=` + +> 留空 Webhook 地址则不发送任何通知。测试按钮会先保存设置再触发一次真实签到,通知随结果一起送达。 diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..a0130b1 --- /dev/null +++ b/build.sh @@ -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" diff --git a/fnos-install.js b/fnos-install.js new file mode 100644 index 0000000..82002fe --- /dev/null +++ b/fnos-install.js @@ -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://: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); }); diff --git a/genicon.py b/genicon.py new file mode 100644 index 0000000..600a288 --- /dev/null +++ b/genicon.py @@ -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() diff --git a/test_backend.py b/test_backend.py new file mode 100644 index 0000000..411b7a5 --- /dev/null +++ b/test_backend.py @@ -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()) diff --git a/test_e2e.py b/test_e2e.py new file mode 100644 index 0000000..857549f --- /dev/null +++ b/test_e2e.py @@ -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" 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()) diff --git a/test_live_nas.py b/test_live_nas.py new file mode 100644 index 0000000..f10f483 --- /dev/null +++ b/test_live_nas.py @@ -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", " 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(/ { + 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); });