feat: AgentCheckin v1.2.2 — fnOS 应用:TRAE/WorkBuddy/Qoder 三平台每日积分自动签到

- 原生 fnOS 应用(CGI 入口 + Python 守护进程),依赖 python312 运行时
- 三平台签到:TRAE 每日 200 积分、WorkBuddy 7 天循环、Qoder 每日 100 Credits
- WorkBuddy 网页授权(OAuth 设备流)、TRAE icubeAuth 解密 Token、Qoder Cosy 设备头
- 多时段调度(默认 00:05/09:00/12:30/19:00)、启动补跑、幂等签到+回查确认
- 多平台 Webhook 通知(企业微信/钉钉/飞书/Bark/Server酱/PushPlus 自动识别)
- Qoder 双站点 Token 隔离自动探测与修复
- 前端:仪表盘/账号管理/签到记录/设置四个页签,30s 轮询+变化检测+隐藏暂停
- 测试:34 后端 + 25 E2E + 9 前端回归 + 真实时钟调度测试
This commit is contained in:
cbb
2026-10-13 19:09:48 +08:00
commit a3b84d5ce2
39 changed files with 5164 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+386
View File
@@ -0,0 +1,386 @@
#!/usr/bin/env python3
"""AgentCheckin CGI API server (invoked by app/ui/index.cgi).
All endpoints are JSON. The fnOS web server validates the NAS login session
before calling the CGI, so no extra auth is implemented here.
"""
import json
import os
import sys
import time
import urllib.parse
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import (load_accounts, load_settings, save_settings, load_state,
save_state, log, fmt_now, now_ts, PID_FILE, TRIGGER_FILE,
ensure_dirs, mask_token, public_account)
import engine
import workbuddy
MAX_BODY = 256 * 1024
OAUTH_TTL = 10 * 60 # seconds a login link stays valid
# ---------------------------------------------------------------------------
# CGI plumbing
# ---------------------------------------------------------------------------
def cgi_env():
return {
"method": os.environ.get("REQUEST_METHOD", "GET").upper(),
"uri": os.environ.get("REQUEST_URI", ""),
"query": os.environ.get("QUERY_STRING", ""),
}
def read_body():
try:
length = int(os.environ.get("CONTENT_LENGTH") or 0)
except ValueError:
length = 0
if length <= 0:
return {}
if length > MAX_BODY:
return None
raw = sys.stdin.buffer.read(length)
try:
data = json.loads(raw.decode("utf-8"))
return data if isinstance(data, dict) else {}
except (ValueError, json.JSONDecodeError):
return {}
def respond(obj, status="200 OK"):
body = json.dumps(obj, ensure_ascii=False)
out = sys.stdout
out.write("Status: %s\r\n" % status)
out.write("Content-Type: application/json; charset=utf-8\r\n")
out.write("Cache-Control: no-store\r\n")
out.write("Content-Length: %d\r\n" % len(body.encode("utf-8")))
out.write("\r\n")
out.write(body)
def error(msg, status="400 Bad Request"):
respond({"error": msg}, status)
def route_path(uri):
path = uri.split("?", 1)[0]
idx = path.find("index.cgi")
if idx >= 0:
path = path[idx + len("index.cgi"):]
return path.rstrip("/") or "/"
def query_params(qs):
return {k: v[0] for k, v in urllib.parse.parse_qs(qs, keep_blank_values=True).items()}
def daemon_alive():
try:
with open(PID_FILE) as f:
pid = int(f.read().strip())
os.kill(pid, 0)
return True
except (OSError, ValueError):
return False
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
def h_state(_params, _body):
data = engine.dashboard()
data["daemon_alive"] = daemon_alive()
return data
def h_checkin(_params, body):
account_id = body.get("account_id")
accounts = load_accounts()
if account_id and not any(a.get("id") == account_id for a in accounts):
return ("error", "账号不存在")
if daemon_alive():
ensure_dirs()
with open(TRIGGER_FILE, "w") as f:
f.write(json.dumps({"account_id": account_id, "ts": now_ts()}))
return {"ok": True, "mode": "trigger", "message": "已触发签到,守护进程正在执行"}
# daemon not running: run synchronously as a fallback
results = engine.run_all(account_id=account_id, reason="manual-cgi")
return {"ok": True, "mode": "sync", "results": results}
def h_accounts(_params, _body):
return {"accounts": [public_account(a) for a in load_accounts()]}
def h_add_wb_manual(_params, body):
token = (body.get("access_token") or "").strip()
if not token:
return ("error", "access_token 不能为空")
if len(token) > 8192:
return ("error", "access_token 过长")
acc = engine.add_workbuddy_manual(
name=(body.get("name") or "").strip()[:60],
access_token=token,
refresh_token=(body.get("refresh_token") or "").strip(),
domain=(body.get("domain") or "codebuddy.cn").strip(),
)
return {"ok": True, "account": public_account(acc)}
def h_add_trae(_params, body):
token = (body.get("access_token") or "").strip()
if not token:
return ("error", "access_token 不能为空")
if len(token) > 8192:
return ("error", "access_token 过长")
acc = engine.add_trae(
name=(body.get("name") or "").strip()[:60],
access_token=token,
refresh_token=(body.get("refresh_token") or "").strip(),
device_id_str=(body.get("device_id") or "").strip(),
)
return {"ok": True, "account": public_account(acc)}
def h_add_qoder(_params, body):
token = (body.get("access_token") or "").strip()
if not token:
return ("error", "access_token 不能为空")
if len(token) > 8192:
return ("error", "access_token 过长")
device = body.get("device") or {}
if not isinstance(device, dict):
return ("error", "device 必须是对象")
acc = engine.add_qoder(
name=(body.get("name") or "").strip()[:60],
access_token=token,
refresh_token=(body.get("refresh_token") or "").strip(),
device=device,
domain=(body.get("domain") or "openapi.qoder.com.cn").strip(),
)
return {"ok": True, "account": public_account(acc)}
def h_update_account(_params, body):
acc_id = body.get("id")
if not acc_id:
return ("error", "缺少 id")
fields = {}
if "name" in body:
fields["name"] = str(body["name"])[:60]
if "enabled" in body:
fields["enabled"] = bool(body["enabled"])
acc = engine.update_account(acc_id, **fields)
if not acc:
return ("error", "账号不存在")
return {"ok": True, "account": public_account(acc)}
def h_fix_qoder_domain(_params, body):
"""Probe both Qoder sites with the account token and switch to the one
that authenticates (the two sites' tokens are not interchangeable)."""
acc_id = body.get("id")
if not acc_id:
return ("error", "缺少 id")
accounts = load_accounts()
acc = engine.find_account(accounts, acc_id)
if not acc or acc.get("platform") != "qoder":
return ("error", "账号不存在或不是 Qoder 账号")
import qoder as _q
probe = _q.probe_domains(acc)
for dom, r in probe.items():
if r.get("auth"):
acc.setdefault("auth", {})["domain"] = dom
engine.save_accounts(accounts)
log("qoder account %s domain switched to %s" % (acc_id, dom))
return {"ok": True, "domain": dom, "probe": probe}
return {"ok": False, "probe": probe,
"error": "两个站点均无法认证(Token 可能已过期),请重新获取 Token"}
def h_delete_account(_params, body):
acc_id = body.get("id")
if not acc_id:
return ("error", "缺少 id")
ok = engine.delete_account(acc_id)
if not ok:
return ("error", "账号不存在")
return {"ok": True}
def h_test_account(_params, body):
acc_id = body.get("id")
if not acc_id:
return ("error", "缺少 id")
return engine.test_account(acc_id)
def h_wb_oauth_begin(_params, _body):
state, login_url, err = workbuddy.begin_oauth()
if err:
return ("error", err)
st = load_state()
sessions = st.setdefault("oauth_sessions", {})
# drop expired sessions
now = now_ts()
for k in list(sessions.keys()):
if now - sessions[k].get("created", 0) > OAUTH_TTL:
del sessions[k]
sessions[state] = {"created": now}
save_state(st)
log("workbuddy oauth session started")
return {"ok": True, "state": state, "login_url": login_url,
"ttl": OAUTH_TTL}
def h_wb_oauth_poll(params, _body):
state = params.get("state", "")
if not state:
return ("error", "缺少 state")
st = load_state()
sessions = st.get("oauth_sessions") or {}
sess = sessions.get(state)
if not sess:
return ("error", "授权会话不存在或已过期,请重新生成链接")
if now_ts() - sess.get("created", 0) > OAUTH_TTL:
del sessions[state]
save_state(st)
return ("error", "授权链接已过期,请重新生成")
status, payload, err = workbuddy.poll_oauth(state)
if status == "done":
acc = engine.add_workbuddy_oauth(payload)
del sessions[state]
save_state(st)
return {"ok": True, "status": "done", "account": public_account(acc)}
return {"ok": True, "status": "waiting"}
def h_settings_get(_params, _body):
return {"settings": load_settings()}
def h_settings_post(_params, body):
settings = load_settings()
if "schedule_times" in body:
times = body["schedule_times"]
if not isinstance(times, list):
return ("error", "schedule_times 必须是数组")
clean = []
for t in times:
t = str(t)
if len(t) == 5 and t[2] == ":" and t[:2].isdigit() and t[3:].isdigit():
hh, mm = int(t[:2]), int(t[3:])
if 0 <= hh < 24 and 0 <= mm < 60:
clean.append(t)
if not clean:
return ("error", "至少需要一个有效的签到时间(HH:MM)")
settings["schedule_times"] = sorted(set(clean))
if "webhook_url" in body:
url = str(body["webhook_url"]).strip()
if url and not url.startswith(("http://", "https://")):
return ("error", "webhook_url 必须以 http(s):// 开头")
settings["webhook_url"] = url[:500]
if "webhook_type" in body:
wt = str(body["webhook_type"])
if wt in engine.WEBHOOK_TYPES:
settings["webhook_type"] = wt
else:
return ("error", "不支持的 webhook_type")
if "notify_on" in body:
nf = body["notify_on"]
if isinstance(nf, list):
settings["notify_on"] = [n for n in nf if n in ("success", "fail")]
save_settings(settings)
return {"ok": True, "settings": settings}
def h_logs(params, _body):
try:
lines = int(params.get("lines", "200"))
except ValueError:
lines = 200
lines = max(10, min(lines, 2000))
from common import LOG_FILE
if not os.path.exists(LOG_FILE):
return {"lines": []}
try:
with open(LOG_FILE, encoding="utf-8") as f:
content = f.read().splitlines()
return {"lines": content[-lines:]}
except OSError as e:
return {"lines": [], "error": str(e)}
def h_debug(_params, _body):
"""Diagnostics: resolved paths, env, daemon liveness (no secrets)."""
import common as _c
info = {
"data_dir": _c.DATA_DIR,
"accounts_file": _c.ACCOUNTS_FILE,
"state_file": _c.STATE_FILE,
"pid_file": _c.PID_FILE,
"log_file": _c.LOG_FILE,
"env": {
"TRIM_PKGVAR": os.environ.get("TRIM_PKGVAR", "<unset>"),
"TRIM_APPDEST": os.environ.get("TRIM_APPDEST", "<unset>"),
"AGENTCHECKIN_DEV": os.environ.get("AGENTCHECKIN_DEV", "<unset>"),
},
"file": __file__,
}
info["state_file_exists"] = os.path.exists(_c.STATE_FILE)
if info["state_file_exists"]:
st = _c.load_state()
info["state_daemon"] = st.get("daemon")
info["state_last_run"] = st.get("last_run")
info["pid_file_exists"] = os.path.exists(_c.PID_FILE)
info["daemon_alive"] = daemon_alive()
return info
ROUTES = {
("GET", "/api/state"): h_state,
("GET", "/api/debug"): h_debug,
("POST", "/api/checkin"): h_checkin,
("GET", "/api/accounts"): h_accounts,
("POST", "/api/accounts/add_wb_manual"): h_add_wb_manual,
("POST", "/api/accounts/add_trae"): h_add_trae,
("POST", "/api/accounts/add_qoder"): h_add_qoder,
("POST", "/api/accounts/update"): h_update_account,
("POST", "/api/accounts/delete"): h_delete_account,
("POST", "/api/accounts/fix_domain"): h_fix_qoder_domain,
("POST", "/api/accounts/test"): h_test_account,
("POST", "/api/wb_oauth_begin"): h_wb_oauth_begin,
("GET", "/api/wb_oauth_poll"): h_wb_oauth_poll,
("GET", "/api/settings"): h_settings_get,
("POST", "/api/settings"): h_settings_post,
("GET", "/api/logs"): h_logs,
}
def main():
env = cgi_env()
path = route_path(env["uri"])
params = query_params(env["query"])
handler = ROUTES.get((env["method"], path))
if not handler:
return error("not found: %s %s" % (env["method"], path), "404 Not Found")
body = read_body() if env["method"] == "POST" else {}
if body is None:
return error("payload too large")
try:
result = handler(params, body)
except Exception as e:
log("api error on %s %s: %s" % (env["method"], path, e), "ERROR")
return error("internal error: %s" % e, "500 Internal Server Error")
if isinstance(result, tuple) and len(result) == 2 and result[0] == "error":
return error(result[1])
respond(result if isinstance(result, dict) else {"data": result})
if __name__ == "__main__":
main()
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""AgentCheckin shared utilities: paths, HTTP, atomic JSON storage, logging.
All persistent data lives under TRIM_PKGVAR (/vol{n}/@appdata/AgentCheckin).
Falls back to a local ./data directory when running outside fnOS (dev/test).
"""
import json
import os
import sys
import time
import fcntl
import urllib.request
import urllib.error
import ssl
APP_NAME = "AgentCheckin"
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
APP_NAME = "AgentCheckin"
def _volume_root():
"""Volume root of the install layout.
@appcenter layout: /vol{n}/@appcenter/{app}/backend -> /vol{n}
(three levels up from the backend directory)
"""
p = os.path.dirname(os.path.abspath(__file__))
for _ in range(3):
p = os.path.dirname(p)
return p
def _candidate_dirs():
"""All plausible persistent-data locations across fnOS install layouts."""
here = os.path.dirname(os.path.abspath(__file__)) # .../{app}/backend
vol = _volume_root()
cands = [
# documented layout: /var/apps/{app}/var -> /vol{n}/@appdata/{app}
"/var/apps/%s/var" % APP_NAME,
# @appcenter layout: {volume}/@appcenter/{app} -> {volume}/@appdata/{app}
os.path.join(vol, "@appdata", APP_NAME),
# well-known volume data dirs (in case the app dir layout differs)
"/vol1/@appdata/%s" % APP_NAME,
"/vol2/@appdata/%s" % APP_NAME,
# relative fallbacks for manual unpacking
os.path.join(here, "..", "..", "var"),
os.path.join(here, "..", "var"),
]
return [os.path.abspath(c) for c in cands]
def _data_dir():
# 1. explicit override (set by cmd/main and index.cgi so the daemon and
# the CGI always agree on one location)
explicit = os.environ.get("AGENTCHECKIN_DATA_DIR", "")
if explicit:
return os.path.abspath(explicit)
if os.environ.get("AGENTCHECKIN_DEV"):
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "devdata")
return os.path.abspath(base)
# 2. fnOS lifecycle env (set for cmd/main; often absent for CGI processes)
env = os.environ.get("TRIM_PKGVAR", "")
if env and os.path.isdir(env):
return env
# 3. first existing candidate
for cand in _candidate_dirs():
if os.path.isdir(cand):
return cand
# 4. nothing exists yet: use the @appdata convention on the same volume
return os.path.abspath(os.path.join(_volume_root(), "@appdata", APP_NAME))
DATA_DIR = _data_dir()
ACCOUNTS_FILE = os.path.join(DATA_DIR, "accounts.json")
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
STATE_FILE = os.path.join(DATA_DIR, "state.json")
TRIGGER_FILE = os.path.join(DATA_DIR, "trigger")
PID_FILE = os.path.join(DATA_DIR, "daemon.pid")
LOG_DIR = os.path.join(DATA_DIR, "logs")
LOG_FILE = os.path.join(LOG_DIR, "checkin.log")
USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
_SSL_CTX = ssl.create_default_context()
_SSL_CTX.check_hostname = False
_SSL_CTX.verify_mode = ssl.CERT_NONE
def ensure_dirs():
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(LOG_DIR, exist_ok=True)
# ---------------------------------------------------------------------------
# Time helpers
# ---------------------------------------------------------------------------
def now_ts():
return int(time.time())
def today_str():
return time.strftime("%Y-%m-%d", time.localtime())
def fmt_ts(ts):
if not ts:
return ""
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
def fmt_now():
return fmt_ts(now_ts())
# ---------------------------------------------------------------------------
# Logging (append to checkin.log; never log tokens)
# ---------------------------------------------------------------------------
def log(msg, level="INFO"):
line = "[%s] [%s] %s\n" % (fmt_now(), level, msg)
try:
ensure_dirs()
# simple rotation: keep one previous generation, cap at ~2 MiB
try:
if os.path.exists(LOG_FILE) and os.path.getsize(LOG_FILE) > 2 * 1024 * 1024:
os.replace(LOG_FILE, LOG_FILE + ".1")
except OSError:
pass
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(line)
except OSError:
pass
return line
# ---------------------------------------------------------------------------
# HTTP
# ---------------------------------------------------------------------------
def http_post_json(url, body=None, headers=None, timeout=20):
"""POST JSON. Returns (data, http_status, error).
On success data is the parsed JSON (envelope or raw). On failure data is
None and error describes the problem. HTTP error bodies that carry a
{code,msg} envelope are surfaced through error as 'code=.. msg=..'.
"""
payload = json.dumps(body if body is not None else {}).encode("utf-8")
req = urllib.request.Request(url, data=payload, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
req.add_header("User-Agent", USER_AGENT)
if headers:
for k, v in headers.items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp:
status = resp.status
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
raw = ""
try:
raw = e.read().decode("utf-8", errors="replace")
except Exception:
pass
try:
env = json.loads(raw)
if isinstance(env, dict) and env.get("code", 0) != 0:
return None, e.code, "code=%s msg=%s" % (env.get("code"), env.get("msg", ""))
if isinstance(env, dict):
return env, e.code, None
except (ValueError, json.JSONDecodeError):
pass
return None, e.code, "HTTP %s: %s" % (e.code, raw[:200])
except urllib.error.URLError as e:
return None, 0, "network error: %s" % (getattr(e, "reason", e))
except Exception as e: # timeout, reset, ...
return None, 0, "request failed: %s" % e
try:
env = json.loads(raw)
except (ValueError, json.JSONDecodeError):
return None, status, "non-JSON response: %s" % raw[:200]
return env, status, None
def http_get(url, headers=None, timeout=20):
"""GET. Returns (parsed_json_or_None, http_status, error)."""
req = urllib.request.Request(url, method="GET")
req.add_header("Accept", "application/json")
req.add_header("User-Agent", USER_AGENT)
if headers:
for k, v in headers.items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp:
status = resp.status
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
raw = ""
try:
raw = e.read().decode("utf-8", errors="replace")
except Exception:
pass
try:
env = json.loads(raw)
if isinstance(env, dict):
return env, e.code, None
except (ValueError, json.JSONDecodeError):
pass
return None, e.code, "HTTP %s: %s" % (e.code, raw[:200])
except urllib.error.URLError as e:
return None, 0, "network error: %s" % (getattr(e, "reason", e))
except Exception as e:
return None, 0, "request failed: %s" % e
try:
return json.loads(raw), status, None
except (ValueError, json.JSONDecodeError):
return None, status, "non-JSON response: %s" % raw[:200]
# ---------------------------------------------------------------------------
# Atomic JSON storage with flock
# ---------------------------------------------------------------------------
def _locked(path, mode="r"):
"""Open path with an exclusive flock held for the lifetime of the handle.
Returns (fileobj, lock_path). Caller must close the fileobj to release.
"""
lock_path = path + ".lock"
lf = open(lock_path, "a+")
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
if mode == "r" and not os.path.exists(path):
return None, lf
f = open(path, mode, encoding="utf-8")
return f, lf
def read_json(path, default):
try:
f, lf = _locked(path, "r")
except OSError:
return default
try:
if f is None:
return default
try:
return json.load(f)
except (ValueError, json.JSONDecodeError):
return default
finally:
if f is not None:
f.close()
lf.close()
def write_json(path, obj, private=True):
ensure_dirs()
tmp = "%s.tmp.%d" % (path, os.getpid())
data = json.dumps(obj, ensure_ascii=False, indent=2)
f, lf = _locked(path, "a")
try:
with open(tmp, "w", encoding="utf-8") as t:
t.write(data)
t.flush()
os.fsync(t.fileno())
os.replace(tmp, path)
if private:
try:
os.chmod(path, 0o600)
except OSError:
pass
finally:
try:
os.unlink(tmp)
except OSError:
pass
f.close()
lf.close()
# ---------------------------------------------------------------------------
# Settings / accounts / state accessors
# ---------------------------------------------------------------------------
DEFAULT_SETTINGS = {
"schedule_times": ["00:05", "09:00", "12:30", "19:00"],
"webhook_url": "",
"webhook_type": "auto", # auto | wecom | dingtalk | feishu | bark | generic
"notify_on": ["fail"], # subset of ["success", "fail"]
}
def load_settings():
s = read_json(SETTINGS_FILE, None)
if not isinstance(s, dict):
s = {}
out = dict(DEFAULT_SETTINGS)
out.update({k: v for k, v in s.items() if k in DEFAULT_SETTINGS})
times = [t for t in out.get("schedule_times", []) if isinstance(t, str) and len(t) == 5 and t[2] == ":"]
out["schedule_times"] = times or list(DEFAULT_SETTINGS["schedule_times"])
notify = [n for n in out.get("notify_on", []) if n in ("success", "fail")]
out["notify_on"] = notify
return out
def save_settings(settings):
write_json(SETTINGS_FILE, settings)
def load_accounts():
data = read_json(ACCOUNTS_FILE, {"accounts": []})
if isinstance(data, dict) and isinstance(data.get("accounts"), list):
return data["accounts"]
if isinstance(data, list):
return data
return []
def save_accounts(accounts):
write_json(ACCOUNTS_FILE, {"accounts": accounts})
def load_state():
s = read_json(STATE_FILE, {})
return s if isinstance(s, dict) else {}
def save_state(state):
write_json(STATE_FILE, state, private=False)
def mask_token(tok):
if not tok or not isinstance(tok, str):
return ""
if len(tok) <= 10:
return "***"
return tok[:6] + "***" + tok[-4:]
def public_account(acc):
"""Account view for the UI: never expose raw tokens."""
a = dict(acc)
auth = dict(acc.get("auth", {}))
if "accessToken" in auth:
auth["accessToken"] = mask_token(auth.get("accessToken"))
if "refreshToken" in auth:
auth["refreshToken"] = mask_token(auth.get("refreshToken"))
a["auth"] = auth
return a
if __name__ == "__main__":
# tiny self-check helper
print("data dir:", DATA_DIR)
print("exists:", os.path.isdir(DATA_DIR))
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""AgentCheckin scheduler daemon.
Responsibilities:
* run check-ins at the configured daily times (default 00:05/09:00/12:30/19:00),
each slot at most once per day; idempotent so missed slots self-heal
* honour manual trigger files written by the CGI API ("run now")
* keep state.json fresh (daemon liveness, next run time)
* survive reboots: on start, if today has no run yet, run once (catch-up)
The daemon writes its own pidfile so cmd/main and the web UI can check it.
"""
import os
import signal
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import (DATA_DIR, PID_FILE, TRIGGER_FILE, load_settings, load_state,
save_state, log, today_str, fmt_now, now_ts, fmt_ts,
ensure_dirs)
import engine
TICK_SECONDS = 15
_running = True
def _handle_signal(signum, frame):
global _running
_running = False
def write_pidfile():
ensure_dirs()
with open(PID_FILE, "w") as f:
f.write(str(os.getpid()))
def remove_pidfile():
try:
if os.path.exists(PID_FILE):
with open(PID_FILE) as f:
if f.read().strip() == str(os.getpid()):
os.unlink(PID_FILE)
except OSError:
pass
def slot_today_ts(hhmm):
"""Unix ts of today's HH:MM local time."""
now = time.localtime()
hh, mm = int(hhmm[:2]), int(hhmm[3:5])
return int(time.mktime((now.tm_year, now.tm_mon, now.tm_mday, hh, mm, 0, 0, 0, -1)))
def compute_next_run(settings):
"""Next scheduled slot ts (today or tomorrow), or None."""
times = settings.get("schedule_times") or []
now = now_ts()
candidates = []
for hhmm in times:
ts = slot_today_ts(hhmm)
candidates.append(ts if ts > now else ts + 86400)
return min(candidates) if candidates else None
def tick():
settings = load_settings()
state = load_state()
today = today_str()
times = settings.get("schedule_times") or []
slot_runs = state.get("slot_runs") or {}
now = now_ts()
# 1) manual trigger
if os.path.exists(TRIGGER_FILE):
try:
os.unlink(TRIGGER_FILE)
except OSError:
pass
log("trigger file detected -> running check-in")
engine.run_all(reason="manual")
return
# 2) catch-up: today has no run recorded at all (fresh boot / first install).
# Mark every slot whose time has already passed as covered by this run.
# NOTE: check key existence — an empty result dict is still "a run".
if today not in (state.get("today") or {}):
if times:
log("no run recorded today -> startup catch-up")
engine.run_all(reason="startup-catchup")
state = load_state()
slot_runs = state.setdefault("slot_runs", {})
changed = False
for hhmm in times:
if now >= slot_today_ts(hhmm) and slot_runs.get(hhmm) != today:
slot_runs[hhmm] = today
changed = True
if changed:
save_state(state)
return
# 3) scheduled slots (each at most once per day)
for hhmm in times:
ts = slot_today_ts(hhmm)
if now >= ts and slot_runs.get(hhmm) != today:
log("schedule slot %s due" % hhmm)
engine.run_all(reason="schedule:%s" % hhmm)
mark_slot_ran(hhmm)
return
def mark_slot_ran(hhmm):
state = load_state()
state.setdefault("slot_runs", {})[hhmm] = today_str()
save_state(state)
def update_liveness():
state = load_state()
settings = load_settings()
prev = state.get("daemon") or {}
# reset started_at when the pid changed (fresh daemon process)
started_at = prev.get("started_at") if prev.get("pid") == os.getpid() else fmt_now()
state["daemon"] = {
"pid": os.getpid(),
"started_at": started_at,
"last_tick": fmt_now(),
}
nxt = compute_next_run(settings)
state["next_run_at"] = fmt_ts(nxt) if nxt else None
state["next_run_ts"] = nxt
save_state(state)
def main():
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
ensure_dirs()
write_pidfile()
log("daemon started (pid %d), data dir %s" % (os.getpid(), DATA_DIR))
try:
while _running:
try:
tick()
except Exception as e:
log("tick error: %s" % e, "ERROR")
try:
update_liveness()
except Exception as e:
log("liveness update failed: %s" % e, "ERROR")
# sleep in small slices so SIGTERM is responsive
for _ in range(TICK_SECONDS):
if not _running:
break
time.sleep(1)
finally:
remove_pidfile()
log("daemon stopped")
return 0
if __name__ == "__main__":
sys.exit(main())
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""AgentCheckin engine: account orchestration, check-in runs, notifications."""
import json
import secrets
import time
import urllib.request
from common import (load_accounts, save_accounts, load_settings, load_state,
save_state, log, now_ts, today_str, fmt_ts, fmt_now,
public_account, USER_AGENT, _SSL_CTX)
import workbuddy
import trae
import qoder
# result statuses considered "handled" (no retry needed)
DONE_STATUSES = {"ok", "ok_unverified", "already", "inactive"}
def new_account_id(platform):
return "%s_%s" % (platform, secrets.token_hex(4))
def find_account(accounts, account_id):
for acc in accounts:
if acc.get("id") == account_id:
return acc
return None
# ---------------------------------------------------------------------------
# Account creation
# ---------------------------------------------------------------------------
def add_workbuddy_manual(name, access_token, refresh_token="", domain="codebuddy.cn"):
accounts = load_accounts()
acc = {
"id": new_account_id("wb"),
"platform": "workbuddy",
"name": name or "WorkBuddy 账号",
"enabled": True,
"created_at": fmt_now(),
"auth": {
"accessToken": access_token,
"refreshToken": refresh_token,
"expiresAt": 0,
"domain": domain,
},
"account": {},
"last_result": None,
}
accounts.append(acc)
save_accounts(accounts)
log("added workbuddy account %s (%s)" % (acc["id"], acc["name"]))
return acc
def add_workbuddy_oauth(payload):
"""payload from workbuddy.poll_oauth(): {accessToken, refreshToken, ...}."""
accounts = load_accounts()
acct_info = payload.get("account") or {}
domain = payload.get("domain") or "codebuddy.cn"
acc = {
"id": new_account_id("wb"),
"platform": "workbuddy",
"name": acct_info.get("nickname") or "WorkBuddy 账号",
"enabled": True,
"created_at": fmt_now(),
"auth": {
"accessToken": payload.get("accessToken", ""),
"refreshToken": payload.get("refreshToken", ""),
"expiresAt": payload.get("expiresAt") or 0,
"domain": domain,
},
"account": {
"uid": acct_info.get("uid", ""),
"nickname": acct_info.get("nickname", ""),
"enterpriseId": acct_info.get("enterpriseId", ""),
},
"last_result": None,
}
accounts.append(acc)
save_accounts(accounts)
log("added workbuddy account via oauth: %s (%s)" % (acc["id"], acc["name"]))
return acc
def add_trae(name, access_token, refresh_token="", device_id_str=""):
accounts = load_accounts()
acc = {
"id": new_account_id("trae"),
"platform": "trae",
"name": name or "TRAE 账号",
"enabled": True,
"created_at": fmt_now(),
"auth": {
"accessToken": access_token,
"refreshToken": refresh_token,
},
"device_id": device_id_str or trae.stable_device_id(new_account_id("trae")),
"last_result": None,
}
accounts.append(acc)
save_accounts(accounts)
log("added trae account %s (%s)" % (acc["id"], acc["name"]))
return acc
def add_qoder(name, access_token, refresh_token="", device=None, domain="openapi.qoder.com.cn"):
accounts = load_accounts()
acc = {
"id": new_account_id("qoder"),
"platform": "qoder",
"name": name or "Qoder 账号",
"enabled": True,
"created_at": fmt_now(),
"auth": {
"accessToken": access_token,
"refreshToken": refresh_token,
"expiresAt": 0,
"domain": domain,
},
"device": {k: str(v) for k, v in (device or {}).items()
if k in qoder.DEVICE_DEFAULTS},
"last_result": None,
}
accounts.append(acc)
save_accounts(accounts)
log("added qoder account %s (%s)" % (acc["id"], acc["name"]))
return acc
def delete_account(account_id):
accounts = load_accounts()
left = [a for a in accounts if a.get("id") != account_id]
save_accounts(left)
return len(left) != len(accounts)
def update_account(account_id, **fields):
accounts = load_accounts()
acc = find_account(accounts, account_id)
if not acc:
return None
for k, v in fields.items():
if k in ("name", "enabled"):
acc[k] = v
elif k == "accessToken" and v:
acc.setdefault("auth", {})["accessToken"] = v
elif k == "refreshToken" and v:
acc.setdefault("auth", {})["refreshToken"] = v
elif k == "domain" and v:
acc.setdefault("auth", {})["domain"] = v
save_accounts(accounts)
return acc
# ---------------------------------------------------------------------------
# Check-in runs
# ---------------------------------------------------------------------------
def run_account(acc, force=False):
"""Run check-in for one account. Returns result dict."""
platform = acc.get("platform")
started = fmt_now()
if platform == "workbuddy":
result = _run_workbuddy(acc)
elif platform == "trae":
result = trae.checkin_once(acc)
elif platform == "qoder":
result = qoder.checkin_once(acc)
else:
result = {"status": "fail", "message": "未知平台 %s" % platform, "points": 0}
result["time"] = started
result["finished_at"] = fmt_now()
acc["last_result"] = result
acc["last_run"] = now_ts()
if result.get("status") == "no_session":
acc["need_reauth"] = True
elif result.get("status") in DONE_STATUSES:
acc["need_reauth"] = False
_reload(acc)
return result
def _reload(acc):
"""Persist the mutated account back into the accounts file."""
accounts = load_accounts()
for i, a in enumerate(accounts):
if a.get("id") == acc.get("id"):
accounts[i] = acc
break
else:
accounts.append(acc)
save_accounts(accounts)
return accounts
def _run_workbuddy(acc):
auth = acc.get("auth", {})
if not auth.get("accessToken"):
return {"status": "no_auth", "message": "缺少 accessToken,请重新授权", "points": 0}
if workbuddy.is_global(auth):
return {"status": "inactive", "message": "Global(workbuddy.ai)账号不支持每日签到", "points": 0}
# refresh token when close to expiry
if workbuddy.token_expiring(auth) and auth.get("refreshToken"):
ok, msg = workbuddy.refresh_token(acc)
if not ok:
log("workbuddy %s token refresh: %s" % (acc.get("id"), msg), "WARN")
if "失效" in msg or "过期" in msg:
return {"status": "no_session", "message": msg, "points": 0}
else:
_reload(acc)
# status first (idempotent)
checked, detail, err = workbuddy.query_status(acc)
if err == "no_session":
return {"status": "no_session", "message": "登录态已失效,请重新授权", "points": 0}
if checked is True:
credits = None
if detail:
credits = detail.get("total_credits")
if credits is None:
summary, _ = workbuddy.fetch_credits(acc)
credits = (summary or {}).get("remain")
return {"status": "already", "message": "今日已签到", "points": 0,
"credits": credits, "detail": detail or {}}
# claim
status, message, points = workbuddy.perform_checkin(acc)
if status == "no_session":
return {"status": "no_session", "message": message, "points": 0}
if status == "ok":
# verify
for _ in range(3):
time.sleep(2)
v_checked, v_detail, v_err = workbuddy.query_status(acc)
if v_err == "no_session":
break
if v_checked is True:
credits = None
if v_detail:
credits = v_detail.get("total_credits")
if credits is None:
summary, _ = workbuddy.fetch_credits(acc)
credits = (summary or {}).get("remain")
return {"status": "ok", "message": "签到成功(已回查确认)",
"points": points, "credits": credits, "detail": v_detail or {}}
if v_checked is False:
break
return {"status": "ok_unverified", "message": "领取成功(+%s)但回查未确认" % points,
"points": points}
return {"status": status, "message": message, "points": points}
def run_all(account_id=None, reason="manual"):
"""Run check-in for all enabled accounts (or one). Returns summary dict."""
accounts = load_accounts()
targets = [a for a in accounts if a.get("enabled")]
if account_id:
targets = [a for a in targets if a.get("id") == account_id]
results = {}
for acc in targets:
try:
res = run_account(acc)
except Exception as e: # never let one account kill the run
res = {"status": "fail", "message": "内部错误:%s" % e, "points": 0,
"time": fmt_now(), "finished_at": fmt_now()}
acc["last_result"] = res
_reload(acc)
results[acc["id"]] = {
"platform": acc.get("platform"),
"name": acc.get("name"),
"status": res.get("status"),
"message": res.get("message"),
"points": res.get("points", 0),
"credits": res.get("credits"),
}
log("checkin[%s] %s (%s): %s %s" % (reason, acc.get("id"), acc.get("platform"),
res.get("status"), res.get("message")))
# record today's summary in state (always, even with zero accounts, so the
# daemon's startup catch-up does not repeat when no accounts exist)
state = load_state()
today = today_str()
state.setdefault("today", {})[today] = results
state["last_run"] = fmt_now()
state["last_run_reason"] = reason
# prune history: keep the last 30 days
days = sorted(state.get("today", {}).keys())
for old in days[:-30]:
state["today"].pop(old, None)
save_state(state)
if results:
notify(results, reason)
return results
def test_account(account_id):
"""Query status + credits without claiming."""
accounts = load_accounts()
acc = find_account(accounts, account_id)
if not acc:
return {"error": "账号不存在"}
out = {"id": account_id, "platform": acc.get("platform")}
try:
if acc.get("platform") == "workbuddy":
if workbuddy.token_expiring(acc.get("auth", {})) and acc["auth"].get("refreshToken"):
ok, msg = workbuddy.refresh_token(acc)
_reload(acc)
out["refresh"] = msg
checked, detail, err = workbuddy.query_status(acc)
out["checked_in"] = checked
out["error"] = err
summary, cerr = workbuddy.fetch_credits(acc)
if summary:
out["credits"] = summary
if cerr and cerr != "no_session":
out["credits_error"] = cerr
elif acc.get("platform") == "trae":
checked, enable, credits, err = trae.query_status(acc)
out["checked_in"] = checked
out["enable"] = enable
out["credits"] = credits
out["error"] = err
elif acc.get("platform") == "qoder":
ok, msg = qoder.ensure_token(acc)
_reload(acc)
if ok:
out["refresh"] = msg
# 探测两个站点,判断 token 属于哪个域(两站 Token 不互通)
probe = qoder.probe_domains(acc)
out["domain_probe"] = probe
current = (acc.get("auth") or {}).get("domain")
ok_domain = None
for dom, r in probe.items():
if r.get("auth"):
ok_domain = dom
out["token_domain"] = ok_domain
out["domain_match"] = (ok_domain == current) if ok_domain else None
if ok_domain and ok_domain != current:
out["hint"] = "Token 属于 %s,但当前域名是 %s,请切换" % (ok_domain, current)
campaigns, err = qoder.query_campaigns(acc)
if err:
out["error"] = err
else:
benefits = qoder._benefits(campaigns)
claimable = [c for c in benefits if c.get("claimStatus") == "CLAIMABLE"]
out["claimable"] = len(claimable)
out["campaigns"] = len(benefits)
amounts = []
for c in claimable:
b = c.get("benefit") or {}
if isinstance(b, dict) and b.get("amount"):
amounts.append(b["amount"])
if amounts:
out["credits"] = sum(amounts)
except Exception as e:
out["error"] = str(e)
return out
# ---------------------------------------------------------------------------
# Notifications (multi-platform webhook)
# ---------------------------------------------------------------------------
WEBHOOK_TYPES = ("auto", "wecom", "dingtalk", "feishu", "bark", "generic")
def _detect_webhook_type(url):
u = url.lower()
if "qyapi.weixin.qq.com" in u:
return "wecom"
if "oapi.dingtalk.com" in u:
return "dingtalk"
if "open.feishu.cn" in u or "open.larkoffice.com" in u:
return "feishu"
if "api.day.app" in u or "bark" in u:
return "bark"
return "generic"
def notify_payload(wtype, url, title, content):
"""Build the platform-correct request. Returns (final_url, body_or_None).
body=None means a plain GET (Bark URL-path API).
"""
if wtype == "auto" or wtype not in WEBHOOK_TYPES:
wtype = _detect_webhook_type(url)
text = title + "\n" + content
if wtype in ("wecom", "dingtalk"):
# 企业微信 / 钉钉自定义机器人: {"msgtype":"text","text":{"content":...}}
return url, json.dumps({"msgtype": "text", "text": {"content": text}},
ensure_ascii=False).encode("utf-8")
if wtype == "feishu":
# 飞书自定义机器人: {"msg_type":"text","content":{"text":...}}
return url, json.dumps({"msg_type": "text", "content": {"text": text}},
ensure_ascii=False).encode("utf-8")
if wtype == "bark":
# Bark: <base>/<title>/<body>(URL 路径传参,GET 即可)
import urllib.parse
base = url.rstrip("/")
return "%s/%s/%s" % (base, urllib.parse.quote(title), urllib.parse.quote(content)), None
# Server酱 / PushPlus / 通用: 尽量多带常见字段
return url, json.dumps({"title": title, "content": content, "text": text,
"msgtype": "text", "desp": content},
ensure_ascii=False).encode("utf-8")
def notify(results, reason):
settings = load_settings()
url = (settings.get("webhook_url") or "").strip()
if not url:
return
notify_on = settings.get("notify_on") or []
has_fail = any(r["status"] in ("fail", "no_session", "no_auth", "retry_later")
for r in results.values())
has_success = any(r["status"] in ("ok", "ok_unverified") for r in results.values())
if has_fail and "fail" not in notify_on:
if not (has_success and "success" in notify_on):
return
if not has_fail and not has_success:
return
if not has_fail and has_success and "success" not in notify_on:
return
lines = []
for r in results.values():
mark = {"ok": "✅", "ok_unverified": "✅", "already": "☑️",
"inactive": "➖", "fail": "❌", "no_session": "🔒",
"no_auth": "🔒", "retry_later": "⏳"}.get(r["status"], "•")
lines.append("%s %s %s:%s" % (mark, r.get("platform", ""), r.get("name", ""), r.get("message", "")))
content = "\n".join(lines)
title = "Agent 签到结果(%s)" % today_str()
wtype = settings.get("webhook_type") or "auto"
final_url, body = notify_payload(wtype, url, title, content)
try:
req = urllib.request.Request(final_url, data=body, method="POST" if body else "GET")
if body:
req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", USER_AGENT)
with urllib.request.urlopen(req, timeout=15, context=_SSL_CTX) as resp:
resp.read()
log("webhook notification sent via %s (%d bytes)" % (wtype, len(body or 0)))
except Exception as e:
log("webhook notification failed (%s): %s" % (wtype, e), "WARN")
# ---------------------------------------------------------------------------
# Dashboard state
# ---------------------------------------------------------------------------
def dashboard():
accounts = load_accounts()
settings = load_settings()
state = load_state()
today = today_str()
today_results = (state.get("today") or {}).get(today, {})
items = []
for acc in accounts:
item = public_account(acc)
item["today"] = today_results.get(acc.get("id"))
item["need_reauth"] = bool(acc.get("need_reauth"))
item["disabled"] = bool(acc.get("disabled"))
items.append(item)
return {
"accounts": items,
"settings": settings,
"today": today,
"today_results": today_results,
"last_run": state.get("last_run"),
"last_run_reason": state.get("last_run_reason"),
"next_run_at": state.get("next_run_at"),
"daemon": state.get("daemon"),
}
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env python3
"""Qoder daily check-in client (CN edition).
Endpoints (reverse-engineered from the Qoder desktop client, see community
projects wallechfox/qoder-checkin and L0NE-6/Qoder-Daily):
CN base https://openapi.qoder.com.cn
Global base https://openapi.qoder.sh
campaigns GET /sash/api/v1/me/campaigns
claim POST /sash/api/v1/me/campaigns/{cid}/claim
refresh POST /api/v1/deviceToken/refresh body {"refresh_token": ...}
Auth: Authorization: Bearer <accessToken> plus Cosy-* device headers
(machineId / machineToken / machineCode / machineType / clientType ...).
Campaign shape: campaigns[] entries with actionType == "CLAIM_BENEFIT" and
claimStatus in ("CLAIMABLE", "CLAIMED"); benefit.amount is the credit grant.
Result codes used across the app: ok / already / fail / no_session /
retry_later (activity not released yet — Qoder activities are not published
exactly at midnight, so multi-slot retries matter).
"""
import base64
import json
import time
from common import http_post_json, http_get, log, now_ts
CN_BASE = "https://openapi.qoder.com.cn"
GLOBAL_BASE = "https://openapi.qoder.sh"
PATH_CAMPAIGNS = "/sash/api/v1/me/campaigns"
PATH_CLAIM = "/sash/api/v1/me/campaigns/%s/claim"
PATH_REFRESH = "/api/v1/deviceToken/refresh"
REFRESH_MARGIN = 72 * 3600 # refresh 72h before expiry (client behaviour)
DEVICE_DEFAULTS = {
"clientType": "10",
"version": "", # 不默认发送,用户可在设备标识里按客户端实际版本填
"machineOS": "",
"machineHostname": "",
"machineId": "",
"machineToken": "",
"machineCode": "",
"machineType": "",
"version": "",
}
def is_global(auth):
return "qoder.sh" in (auth.get("domain") or "").lower()
def billing_base(auth):
return GLOBAL_BASE if is_global(auth) else CN_BASE
def device_headers(account):
"""Cosy-* device headers from the account's device block."""
dev = dict(DEVICE_DEFAULTS)
dev.update({k: v for k, v in (account.get("device") or {}).items() if k in DEVICE_DEFAULTS})
return {
"Cosy-ClientType": str(dev["clientType"] or "10"),
"Cosy-MachineOS": str(dev["machineOS"] or ""),
"Cosy-MachineHostname": str(dev["machineHostname"] or ""),
"Cosy-MachineId": str(dev["machineId"] or ""),
"Cosy-MachineToken": str(dev["machineToken"] or ""),
"Cosy-MachineCode": str(dev["machineCode"] or ""),
"Cosy-MachineType": str(dev["machineType"] or ""),
"Cosy-Version": str(dev["version"] or ""),
}
def _headers(account):
h = {"Accept": "application/json", "Accept-Language": "zh-CN", "User-Agent": "Qoder"}
# only send device headers that actually have values (empty values can
# trip server-side device validation)
for k, v in device_headers(account).items():
if v:
h[k] = v
token = (account.get("auth") or {}).get("accessToken", "")
if token:
h["Authorization"] = "Bearer %s" % token
return h
# ---------------------------------------------------------------------------
# JWT helpers
# ---------------------------------------------------------------------------
def _b64_json(seg):
seg += "=" * (-len(seg) % 4)
try:
return json.loads(base64.urlsafe_b64decode(seg).decode("utf-8"))
except Exception:
return {}
def jwt_exp(token):
parts = str(token or "").split(".")
if len(parts) != 3:
return 0
try:
return int(_b64_json(parts[1]).get("exp") or 0)
except (TypeError, ValueError):
return 0
def jwt_uid(token):
parts = str(token or "").split(".")
if len(parts) != 3:
return ""
data = _b64_json(parts[1])
for k in ("sub", "uid", "user_id", "userId", "id"):
if data.get(k):
return str(data[k])
return ""
# ---------------------------------------------------------------------------
# Token refresh
# ---------------------------------------------------------------------------
def token_expiring(account, margin=REFRESH_MARGIN):
auth = account.get("auth") or {}
at = auth.get("accessToken") or ""
if not at:
return True
exp = auth.get("expiresAt") or jwt_exp(at)
if not exp:
return False
return now_ts() + margin > exp
def refresh_token(account):
"""Refresh access token in place. Returns (ok, message)."""
rt = (account.get("auth") or {}).get("refreshToken", "")
if not rt:
return False, "未配置 refreshToken,无法自动续期"
# jrt- 前缀的刷新串走 jobToken 端点(社区实现验证)
path = "/api/v1/jobToken/refresh" if rt.startswith("jrt-") else PATH_REFRESH
data, status, err = http_post_json(
billing_base(account.get("auth") or {}) + path,
body={"refresh_token": rt},
headers={"User-Agent": "qoder-local-proxy", "Accept": "application/json"})
if status != 200 or not isinstance(data, dict):
detail = ""
if isinstance(data, dict):
detail = data.get("errorMessage") or data.get("message") or data.get("msg") or ""
return False, "续期失败(HTTP %s)%s%s" % (status, (":" + str(detail)[:80]) if detail else "", (" " + str(err)[:80]) if err and not detail else "")
# 响应可能是 {"data": {...}} 嵌套,也可能是平铺
payload = data.get("data") if isinstance(data.get("data"), dict) else data
tok = None
for k in ("device_token", "deviceToken", "token", "accessToken", "access_token"):
if payload.get(k):
tok = payload[k]
break
if not tok:
return False, "续期响应缺少 token(HTTP %s)" % status
auth = account.setdefault("auth", {})
auth["accessToken"] = tok
new_rt = None
for k in ("device_refresh_token", "refresh_token", "refreshToken"):
if payload.get(k):
new_rt = payload[k]
break
if new_rt:
auth["refreshToken"] = new_rt
exp = jwt_exp(tok)
if exp:
auth["expiresAt"] = exp
if not account.get("uid"):
account["uid"] = jwt_uid(tok)
return True, "已续期"
def ensure_token(account, force=False):
if not force and not token_expiring(account):
return True, ""
return refresh_token(account)
# ---------------------------------------------------------------------------
# Campaigns / claim
# ---------------------------------------------------------------------------
def _api(account, path, method="GET", body=None):
return http_post_json(billing_base(account.get("auth") or {}) + path,
body=body, headers=_headers(account)) if method == "POST" \
else http_get(billing_base(account.get("auth") or {}) + path, headers=_headers(account))
def query_campaigns(account):
"""Returns (campaigns, error). campaigns is a list (possibly empty)."""
data, status, err = _api(account, PATH_CAMPAIGNS + "?forceRefresh=true", "GET")
if status == 401:
return None, "no_session"
if not isinstance(data, dict) or status != 200:
detail = ""
if isinstance(data, dict):
detail = data.get("message") or data.get("errorMessage") or ""
return None, "查询失败(HTTP %s)%s" % (status, (":" + str(detail)[:80]) if detail else "")
campaigns = data.get("campaigns")
return (campaigns if isinstance(campaigns, list) else []), None
def _benefits(campaigns):
"""CLAIM_BENEFIT campaigns; prefer kind == CREDITS when the field exists."""
out = []
for c in campaigns:
if not (isinstance(c, dict) and c.get("actionType") == "CLAIM_BENEFIT"):
continue
benefit = c.get("benefit")
if isinstance(benefit, dict) and benefit.get("kind") and benefit["kind"] != "CREDITS":
continue
out.append(c)
return out
USAGE_PATH = "/sash/api/v2/me/usage"
def query_balance(account):
"""Remaining credits (userQuota + addOnQuota). Returns (total, error)."""
data, status, err = _api(account, USAGE_PATH, "GET")
if status == 401:
return None, "no_session"
if not isinstance(data, dict) or status != 200:
return None, err
usage = data.get("qoderUsage")
if not isinstance(usage, dict):
return None, None
total = 0
for key in ("userQuota", "addOnQuota"):
q = usage.get(key)
if isinstance(q, dict) and isinstance(q.get("remaining"), (int, float)):
total += q["remaining"]
return total, None
def _claim_one(account, cid):
data, status, err = _api(account, PATH_CLAIM % cid, "POST", body={})
if status == 401:
return None, "no_session"
d = data.get("data") if isinstance(data, dict) and isinstance(data.get("data"), dict) else data
if status == 200 and isinstance(d, dict) and d.get("status") == "CLAIMED":
amt = 0
if isinstance(d.get("benefit"), dict):
amt = d["benefit"].get("amount") or 0
return amt, None
return 0, "领取失败(HTTP %s)" % status
def _token_diag(account):
auth = account.get("auth") or {}
at = auth.get("accessToken") or ""
rt = auth.get("refreshToken") or ""
return "(accessToken %d 字符 / refreshToken %d 字符)" % (len(at), len(rt))
def checkin_once(account):
"""Full single-account check-in. Returns a result dict."""
auth = account.get("auth") or {}
if not auth.get("accessToken") and not auth.get("refreshToken"):
return {"status": "no_auth", "message": "缺少 accessToken / refreshToken,请重新添加账号",
"points": 0}
# 注意:Qoder 的 dt-/drt- Token 原生就只有 27/28 字符(社区实测确认),
# 这里只拦截明显异常短的值。
if auth.get("accessToken") and len(auth["accessToken"]) < 10:
return {"status": "no_auth",
"message": "accessToken 仅 %d 字符,明显不完整,请重新复制" % len(auth["accessToken"]),
"points": 0}
if auth.get("refreshToken") and len(auth["refreshToken"]) < 10:
return {"status": "no_auth",
"message": "refreshToken 仅 %d 字符,明显不完整,请重新复制" % len(auth["refreshToken"]),
"points": 0}
ok, msg = ensure_token(account)
if not ok:
# maybe the access token is still valid even though refresh failed
at = auth.get("accessToken") or ""
exp = auth.get("expiresAt") or jwt_exp(at)
if not (at and (not exp or exp > now_ts())):
return {"status": "no_session", "message": msg, "points": 0}
campaigns, err = query_campaigns(account)
if err == "no_session":
ok2, msg2 = ensure_token(account, force=True)
if ok2:
campaigns, err = query_campaigns(account)
if err == "no_session":
dom = (account.get("auth") or {}).get("domain") or CN_BASE
other = GLOBAL_BASE if "com.cn" in dom else CN_BASE
return {"status": "no_session",
"message": "认证失败(401)。两个站点 Token 不互通——若这是国际版 Token,"
"请把域名改为 %s(当前 %s)%s"
% (other, dom, _token_diag(account)),
"points": 0}
if err:
return {"status": "fail", "message": "续期后查询仍失败:%s" % err, "points": 0}
if err:
return {"status": "fail", "message": err, "points": 0}
balance, berr = query_balance(account)
if berr == "no_session":
return {"status": "no_session",
"message": "Token 已过期,请重新获取 Token" + _token_diag(account), "points": 0}
if berr:
balance = None
benefits = _benefits(campaigns)
claimable = [c for c in benefits if c.get("claimStatus") == "CLAIMABLE"]
if claimable:
got, fail = 0.0, 0
for c in claimable:
cid = str(c.get("campaignId") or "")
amt, cerr = _claim_one(account, cid)
if cerr == "no_session":
return {"status": "no_session",
"message": "登录态已失效,请重新提取 Token", "points": 0}
if cerr:
fail += 1
else:
try:
got += float(amt or 0)
except (TypeError, ValueError):
pass
if fail:
return {"status": "fail",
"message": "%d/%d 个活动领取失败" % (fail, len(claimable)), "points": 0}
return {"status": "ok", "message": "签到成功 +%s Credits" % int(got),
"points": int(got), "credits": balance}
# nothing claimable: already claimed today / activity not released / none
now = now_ts()
claimed_today = [c for c in benefits
if c.get("claimStatus") == "CLAIMED"
and (c.get("startAt") or 0) <= now < (c.get("endAt") or 0)]
if claimed_today:
return {"status": "already", "message": "今日已领取", "points": 0,
"credits": balance}
if not benefits:
return {"status": "already", "message": "活动列表为空(活动结束或未开放)", "points": 0}
return {"status": "retry_later", "message": "活动未下发,下个时段再试", "points": 0}
def probe_domains(account):
"""Probe both CN/Global with the current token.
Returns {domain: {"status": http_status, "auth": bool}} — a 404 means the
token authenticated (path simply doesn't exist), 401 means rejected.
"""
out = {}
for dom, base in ((CN_BASE, CN_BASE), (GLOBAL_BASE, GLOBAL_BASE)):
acc = dict(account)
acc["auth"] = dict(account.get("auth") or {})
acc["auth"]["domain"] = dom
try:
data, status, err = _api(acc, PATH_CAMPAIGNS, "GET")
except Exception as e:
out[dom] = {"status": 0, "auth": False, "error": str(e)}
continue
out[dom] = {"status": status, "auth": status not in (401, 403)}
return out
def account_display(account):
return account.get("name") or account.get("uid") or "Qoder 账号"
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""TRAE (ByteDance, CN edition) daily check-in client.
Endpoints (reverse-engineered from the TRAE desktop client, see community
projects wb-trae-checkin / Trae-AutoCheckin):
base https://api.trae.cn
status POST /trae/api/v2/ug/checkin_credits/status body {"req_source":1}
claim POST /trae/api/v2/ug/checkin_credits/claim body {"req_source":1}
Auth: Authorization: Cloud-IDE-JWT <accessToken> (a JWT issued by the client).
The server also expects desktop-client headers including a 16-digit device id.
Result codes:
0 success (claim returns data.points, usually 200)
9095 already checked in today
9074 device id remembered by the server -> rotate to a fresh device id
1001/1002/401/403 session invalid -> re-login required
"""
import base64
import hashlib
import json
import time
from common import http_post_json, log
API_BASE = "https://api.trae.cn"
TRAE_VERSION = "1.107.1"
EP_STATUS = "/trae/api/v2/ug/checkin_credits/status"
EP_CLAIM = "/trae/api/v2/ug/checkin_credits/claim"
AUTH_FAIL_CODES = {1001, 1002, 401, 403}
def _b64url_decode(seg):
seg += "=" * (-len(seg) % 4)
return base64.urlsafe_b64decode(seg.encode("ascii"))
def jwt_payload(token):
"""Decode a JWT payload without verifying the signature."""
try:
parts = token.split(".")
if len(parts) < 2:
return {}
return json.loads(_b64url_decode(parts[1]).decode("utf-8"))
except Exception:
return {}
def user_region(account):
auth = account.get("auth", {})
if auth.get("region"):
return auth["region"]
payload = jwt_payload(auth.get("accessToken", ""))
region = (payload.get("userRegion") or {}).get("region")
return region or "CN"
# ---------------------------------------------------------------------------
# Device id (16 digits): stable per account, rotated on 9074
# ---------------------------------------------------------------------------
def stable_device_id(account_id):
digest = hashlib.sha256(("agentcheckin:%s" % account_id).encode("utf-8")).hexdigest()
n = int(digest[:13], 16)
return str(n).ljust(16, "0")[:16]
def rotate_device_id():
return (str(int(time.time() * 1000))[-13:] + str(int(time.time() * 1000) % 1000).zfill(3))[:16]
def device_id(account):
did = account.get("device_id")
if not did:
did = stable_device_id(account.get("id", "default"))
account["device_id"] = did
return did
def headers(account):
auth = account.get("auth", {})
return {
"Authorization": "Cloud-IDE-JWT %s" % auth.get("accessToken", ""),
"X-User-Region": user_region(account),
"User-Agent": "Trae/%s" % TRAE_VERSION,
"Content-Type": "application/json",
"x-device-id": device_id(account),
"x-device-type": "Windows",
"x-os-version": "10.0.19045",
"x-app-version": TRAE_VERSION,
}
# ---------------------------------------------------------------------------
# Check-in API
# ---------------------------------------------------------------------------
def query_status(account):
"""Return (checked, enable, credits, error). checked True/False/None."""
data, status, err = http_post_json(API_BASE + EP_STATUS, body={"req_source": 1},
headers=headers(account), timeout=20)
if err or data is None:
if status in (401, 403):
return None, None, None, "no_session"
return None, None, None, err or "status query failed"
if isinstance(data, dict):
code = data.get("code")
if code in AUTH_FAIL_CODES:
return None, None, None, "no_session"
if code == 0:
return (
bool(data.get("checked_in")),
data.get("enable") is not False,
data.get("credits"),
None,
)
return None, None, None, None
def claim(account):
"""Claim today's credits. Returns (status, message, points).
status: ok / already / fail / no_session / retry_later
"""
hdrs = headers(account)
data, status, err = http_post_json(API_BASE + EP_CLAIM, body={"req_source": 1},
headers=hdrs, timeout=20)
if status in (401, 403):
return "no_session", "登录态已失效,请重新获取 Token", 0
if data is None:
return "fail", "请求失败(HTTP %s:%s)" % (status, err), 0
if not isinstance(data, dict):
return "fail", "非 JSON 返回(HTTP %s)" % status, 0
code = data.get("code")
if code == 0:
d = data.get("data") if isinstance(data.get("data"), dict) else {}
return "ok", "签到成功", d.get("points") or 200
if code == 9095:
return "already", "今天已签到", 0
if code in AUTH_FAIL_CODES:
return "no_session", "登录态已失效(code=%s),请重新获取 Token" % code, 0
if code == 9074:
return "retry_later", "触发风控(9074),需更换设备号重试", 0
return "fail", "领取返回 code=%s %s" % (code, str(data.get("message") or data.get("msg") or "")[:80]), 0
def checkin_once(account):
"""Full single-account check-in: status -> claim -> verify.
Returns dict {status, message, points, credits}.
"""
checked, enable, credits, err = query_status(account)
if err == "no_session":
return {"status": "no_session", "message": "登录态已失效,请重新获取 Token", "points": 0}
if checked is True:
return {"status": "already", "message": "今日已签到",
"points": 0, "credits": credits}
if enable is False:
return {"status": "inactive", "message": "当前账号/时段未开放签到活动", "points": 0}
rotated = False
for attempt in range(2):
status, message, points = claim(account)
if status == "ok":
# verify server-side
for _ in range(3):
time.sleep(2)
v_checked, _, v_credits, _ = query_status(account)
if v_checked is True:
return {"status": "ok", "message": "签到成功(已回查确认)",
"points": points, "credits": v_credits}
if v_checked is False:
break
return {"status": "ok_unverified",
"message": "领取成功(+%s)但回查未确认" % points,
"points": points, "credits": credits}
if status == "already":
return {"status": "already", "message": "今天已签到", "points": 0,
"credits": credits}
if status == "no_session":
return {"status": "no_session", "message": message, "points": 0}
if status == "retry_later" and not rotated:
account["device_id"] = rotate_device_id()
rotated = True
log("trae account %s: 9074 -> rotated device id" % account.get("id"))
time.sleep(3)
continue
return {"status": "fail", "message": message, "points": 0}
return {"status": "retry_later", "message": "风控限流(9074),换设备号后仍失败,下个时段自动补签",
"points": 0}
def account_display(account):
return account.get("name") or (account.get("auth") or {}).get("nickname") or "TRAE 账号"
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""WorkBuddy / CodeBuddy (Tencent) daily check-in client.
Endpoints (reverse-engineered from the official desktop client, see
community projects cpa-plugin/workbuddy and wb-trae-checkin):
region CN base https://www.codebuddy.cn (daily check-in supported)
region Global base https://www.workbuddy.ai (no daily check-in)
status POST {base}/v2/billing/meter/checkin-activity-status
claim POST {base}/v2/billing/meter/daily-checkin
credits POST {base}/v2/billing/meter/get-user-resource
refresh POST https://copilot.tencent.com/v2/plugin/auth/token/refresh
(Global: https://www.workbuddy.ai/v2/plugin/auth/token/refresh)
oauth GET https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI
GET https://copilot.tencent.com/v2/plugin/auth/token?state=...
GET https://copilot.tencent.com/v2/plugin/login/account?state=...
Result codes used across the app: ok / already / fail / no_session / inactive.
"""
import json
import time
from common import (http_post_json, http_get, log, now_ts, fmt_now)
CN_BASE = "https://www.codebuddy.cn"
GLOBAL_BASE = "https://www.workbuddy.ai"
REFRESH_URL_CN = "https://copilot.tencent.com/v2/plugin/auth/token/refresh"
REFRESH_URL_GLOBAL = "https://www.workbuddy.ai/v2/plugin/auth/token/refresh"
AUTH_BASE = "https://copilot.tencent.com"
EP_STATUS = "/v2/billing/meter/checkin-activity-status"
EP_DAILY_CHECKIN = "/v2/billing/meter/daily-checkin"
EP_USER_RESOURCE = "/v2/billing/meter/get-user-resource"
REFRESH_MARGIN = 3 * 24 * 3600 # refresh 3 days before expiry
SESSION_DEAD = ("12153", "Offline user session not found")
def is_global(auth):
return (auth.get("domain") or "").lower() in ("workbuddy.ai", "global")
def billing_base(auth):
return GLOBAL_BASE if is_global(auth) else CN_BASE
def refresh_url(auth):
return REFRESH_URL_GLOBAL if is_global(auth) else REFRESH_URL_CN
def billing_headers(auth, account=None):
account = account or {}
headers = {"Authorization": "Bearer %s" % auth.get("accessToken", "")}
if account.get("uid"):
headers["X-User-Id"] = str(account["uid"])
if account.get("enterpriseId"):
headers["X-Enterprise-Id"] = str(account["enterpriseId"])
headers["X-Tenant-Id"] = str(account["enterpriseId"])
if auth.get("domain"):
headers["X-Domain"] = auth["domain"]
base = billing_base(auth)
headers["Referer"] = base + "/"
headers["Origin"] = base
return headers
# ---------------------------------------------------------------------------
# Token lifecycle
# ---------------------------------------------------------------------------
def _expires_at(auth):
exp = auth.get("expiresAt") or 0
try:
exp = int(exp)
except (TypeError, ValueError):
return 0
if exp > 10 ** 12: # milliseconds
exp //= 1000
return exp
def token_expiring(auth, margin=REFRESH_MARGIN):
exp = _expires_at(auth)
if not exp:
return False
return now_ts() + margin > exp
def refresh_token(account):
"""Refresh access token in place. Returns (ok, message)."""
auth = account.setdefault("auth", {})
rt = auth.get("refreshToken", "")
if not rt:
return False, "未配置 refreshToken,无法自动续期"
headers = {
"X-Refresh-Token": rt,
"X-Auth-Refresh-Source": "workbuddy",
}
if (account.get("account") or {}).get("enterpriseId"):
headers["X-Enterprise-Id"] = str(account["account"]["enterpriseId"])
data, status, err = http_post_json(refresh_url(auth), body={}, headers=headers)
if err or not data:
if status == 401 and any(m in (err or "") for m in SESSION_DEAD):
account["disabled"] = True
account["note"] = "会话已失效,需要重新授权"
return False, "登录态已失效,请重新授权"
return False, "续期失败(HTTP %s):%s" % (status, err)
if not isinstance(data, dict) or not data.get("accessToken"):
return False, "续期响应缺少 accessToken"
auth["accessToken"] = data["accessToken"]
if data.get("refreshToken"):
auth["refreshToken"] = data["refreshToken"]
if data.get("domain"):
auth["domain"] = data["domain"]
if data.get("expiresAt") or data.get("expires_at"):
auth["expiresAt"] = data.get("expiresAt") or data.get("expires_at")
account["note"] = ""
return True, "已续期"
# ---------------------------------------------------------------------------
# Billing API
# ---------------------------------------------------------------------------
def query_status(account):
"""Return (checked, detail, error). checked is True/False/None."""
auth = account.get("auth", {})
headers = billing_headers(auth, account.get("account"))
data, status, err = http_post_json(billing_base(auth) + EP_STATUS, body={}, headers=headers)
if err or data is None:
return None, None, err or "empty response"
if status in (401, 403) or any(m in (err or "") for m in SESSION_DEAD):
return None, None, "no_session"
d = data.get("data") if isinstance(data, dict) else None
d = d if isinstance(d, dict) else {}
for key in ("today_checked_in", "checked_in", "checkedIn", "hasCheckin", "isCheckedIn", "checked"):
if isinstance(d.get(key), bool):
return d[key], d, None
if isinstance(data.get(key), bool):
return data[key], d, None
return None, d, None
def perform_checkin(account):
"""Claim today's credits. Returns (status, message, points).
status: ok / already / fail / no_session
"""
auth = account.get("auth", {})
headers = billing_headers(auth, account.get("account"))
data, status, err = http_post_json(billing_base(auth) + EP_DAILY_CHECKIN, body={}, headers=headers)
if status in (401, 403):
return "no_session", "登录态已失效,请重新授权", 0
if data is None:
return "fail", "请求失败(HTTP %s:%s)" % (status, err), 0
code = data.get("code") if isinstance(data, dict) else 0
if code in (10001,):
return "already", "今天已签到", 0
if code in (0, 200) or code is None:
d = data.get("data") if isinstance(data, dict) else {}
d = d if isinstance(d, dict) else {}
points = d.get("today_credit", d.get("daily_credit", d.get("points", 0)))
return "ok", "签到成功", points or 0
if any(m in (err or "") for m in SESSION_DEAD):
return "no_session", "登录态已失效,请重新授权", 0
return "fail", "领取返回 code=%s %s" % (code, (data.get("msg") or "")[:80]), 0
def fetch_credits(account):
"""Aggregate credit packages. Returns (summary, error)."""
auth = account.get("auth", {})
headers = billing_headers(auth, account.get("account"))
body = {
"PageNumber": 1,
"PageSize": 100,
"ProductCode": "p_tcaca",
"Status": [0, 3],
"PackageEndTimeRangeBegin": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"PackageEndTimeRangeEnd": "2126-12-31 23:59:59",
}
data, status, err = http_post_json(billing_base(auth) + EP_USER_RESOURCE, body=body, headers=headers)
if err or data is None:
if status in (401, 403):
return None, "no_session"
return None, err or "查询失败"
resp = data
if isinstance(data, dict) and "Response" in data:
resp = data.get("Response") or {}
resp_data = resp.get("Data", {}) if isinstance(resp, dict) else {}
accounts = resp_data.get("Accounts", []) if isinstance(resp_data, dict) else []
if not isinstance(accounts, list):
accounts = []
total_remain = total_used = total_size = 0
packages = []
for pkg in accounts:
if not isinstance(pkg, dict):
continue
remain, used, size = _package_remain_used(pkg)
total_remain += remain
total_used += used
total_size += size
packages.append({
"name": pkg.get("PackageName", ""),
"remain": remain, "used": used, "size": size,
})
dosage = resp_data.get("TotalDosage", 0)
if isinstance(dosage, (int, float)) and dosage > total_size:
total_size = dosage
derived = total_size - total_remain
if derived > total_used:
total_used = derived
return {
"remain": total_remain,
"used": total_used,
"size": total_size,
"packages": packages,
}, None
def _package_remain_used(pkg):
def num(v):
return v if isinstance(v, (int, float)) else 0
cycle_size = num(pkg.get("CycleCapacitySize"))
if cycle_size > 0:
remain = max(num(pkg.get("CycleCapacityRemain")), 0)
remain = min(remain, cycle_size)
used = cycle_size - remain
explicit = num(pkg.get("CycleCapacityUsed"))
if explicit > used:
used = explicit
if cycle_size >= used:
remain = cycle_size - used
return remain, used, cycle_size
cycle_remain = num(pkg.get("CycleCapacityRemain"))
cycle_used = num(pkg.get("CycleCapacityUsed"))
if cycle_remain > 0 or cycle_used > 0:
remain = max(cycle_remain, 0)
used = max(cycle_used, 0)
size = remain + used
cap = num(pkg.get("CapacitySize"))
if cap > size:
size = cap
if size >= remain:
used = size - remain
return remain, used, size
remain = max(num(pkg.get("CapacityRemain")), 0)
used = max(num(pkg.get("CapacityUsed")), 0)
size = num(pkg.get("CapacitySize"))
if size <= 0:
size = remain + used
if used == 0 and size > remain:
used = size - remain
return remain, used, size
# ---------------------------------------------------------------------------
# OAuth web login (CLI-style device flow, driven from the NAS web UI)
# ---------------------------------------------------------------------------
def begin_oauth():
"""Start a login session. Returns (state, login_url, error)."""
data, status, err = http_post_json(AUTH_BASE + "/v2/plugin/auth/state?platform=CLI",
body={}, timeout=15)
if err or not isinstance(data, dict):
return None, None, "获取授权状态失败(HTTP %s:%s)" % (status, err)
d = data.get("data") if isinstance(data.get("data"), dict) else data
state = d.get("state") or d.get("State")
if not state:
return None, None, "未能获取授权 state"
login_url = "%s/login?state=%s&platform=CLI" % (AUTH_BASE, state)
return state, login_url, None
def poll_oauth(state):
"""Poll the token endpoint. Returns (status, payload, error).
status: waiting / done / failed
"""
data, status_code, err = http_get(
AUTH_BASE + "/v2/plugin/auth/token?state=%s" % state, timeout=15)
if status_code == 200 and isinstance(data, dict):
code = data.get("code", 0)
d = data.get("data") if isinstance(data.get("data"), dict) else {}
if code in (0, 200) and (d.get("accessToken") or d.get("access_token")):
expires_at = d.get("expiresAt") or d.get("expires_at") or 0
if not expires_at and (d.get("expires_in") or d.get("expiresIn")):
from common import now_ts
expires_at = now_ts() + int(d.get("expires_in") or d.get("expiresIn"))
payload = {
"accessToken": d.get("accessToken") or d.get("access_token"),
"refreshToken": d.get("refreshToken") or d.get("refresh_token"),
"expiresAt": expires_at,
"domain": d.get("domain") or "codebuddy.cn",
}
account = fetch_account_info(state, payload["accessToken"])
if account:
payload["account"] = account
return "done", payload, None
# not logged in yet (or state expired) — keep waiting within the UI timeout
return "waiting", None, None
def fetch_account_info(state, access_token):
"""Best-effort account info for the OAuth session."""
headers = {"Authorization": "Bearer %s" % access_token} if access_token else None
data, status, err = http_get(
AUTH_BASE + "/v2/plugin/login/account?state=%s" % state, headers=headers, timeout=15)
if not isinstance(data, dict):
return None
d = data.get("data") if isinstance(data.get("data"), dict) else data
uid = d.get("uid") or d.get("userId") or d.get("Uid")
nickname = d.get("nickname") or d.get("name") or d.get("Nickname")
enterprise = d.get("enterpriseId") or d.get("EnterpriseId") or d.get("enterprise_id")
if not (uid or nickname):
return None
return {
"uid": str(uid) if uid else "",
"nickname": str(nickname) if nickname else "",
"enterpriseId": str(enterprise) if enterprise else "",
}
def account_display(account):
acct = account.get("account") or {}
return acct.get("nickname") or acct.get("uid") or account.get("name") or "WorkBuddy 账号"
+11
View File
@@ -0,0 +1,11 @@
{
".url": {
"AgentCheckin.Application": {
"title": "Agent 签到助手",
"icon": "images/icon_{0}.png",
"type": "iframe",
"url": "/cgi/ThirdParty/AgentCheckin/index.cgi/",
"allUsers": true
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
# AgentCheckin CGI entry for fnOS.
# - serves static files from the app's www directory
# - /api/* requests are dispatched to the Python backend (api.py)
#
# Installed layout (see fnOS app framework docs):
# /var/apps/AgentCheckin/target/ui/index.cgi (this file)
# /var/apps/AgentCheckin/target/app/backend (python backend)
# /var/apps/AgentCheckin/target/app/www (static files)
# /vol{n}/@appdata/AgentCheckin (TRIM_PKGVAR: persistent data)
SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
APP_ROOT="${SCRIPT_DIR}/.."
BASE_PATH="${APP_ROOT}/www"
BACKEND_DIR="${APP_ROOT}/backend"
# fnOS CGI processes carry no TRIM_PKGVAR. Resolve the persistent data dir
# (@appdata/AgentCheckin) and export it explicitly so the Python backend and
# the scheduler daemon always agree on one location.
if [ -n "${TRIM_PKGVAR}" ] && [ -d "${TRIM_PKGVAR}" ]; then
export AGENTCHECKIN_DATA_DIR="${TRIM_PKGVAR}"
else
for _cand in \
"${APP_ROOT}/../../@appdata/AgentCheckin" \
"/var/apps/AgentCheckin/var" \
"/vol1/@appdata/AgentCheckin" \
"/vol2/@appdata/AgentCheckin"; do
if [ -d "$_cand" ]; then
export AGENTCHECKIN_DATA_DIR="$_cand"
break
fi
done
fi
# Resolve a python3 interpreter: prefer the fnOS runtime dependency app.
PYBIN=""
for candidate in \
/var/apps/python312/target/bin/python3 \
/var/apps/python311/target/bin/python3 \
/var/apps/python310/target/bin/python3 \
/usr/bin/python3 \
/usr/local/bin/python3; do
if [ -x "$candidate" ]; then PYBIN="$candidate"; break; fi
done
json_error() {
# $1 = http status body, $2 = message
printf 'Status: %s\r\n' "$1"
printf 'Content-Type: application/json; charset=utf-8\r\n'
printf 'Cache-Control: no-store\r\n'
printf '\r\n'
printf '{"error":"%s"}\n' "$2"
exit 0
}
# ---------------------------------------------------------------------------
# Parse the relative request path (everything after "index.cgi")
# ---------------------------------------------------------------------------
URI_NO_QUERY="${REQUEST_URI%%\?*}"
REL_PATH="/"
case "$URI_NO_QUERY" in
*index.cgi*)
REL_PATH="${URI_NO_QUERY#*index.cgi}"
;;
esac
REL_PATH="${REL_PATH#/}"
REL_PATH="${REL_PATH%/}"
# ---------------------------------------------------------------------------
# API requests -> python backend
# ---------------------------------------------------------------------------
case "$REL_PATH" in
api/*)
if [ -z "$PYBIN" ]; then
json_error "500 Internal Server Error" "python3 runtime unavailable"
fi
if [ ! -f "${BACKEND_DIR}/api.py" ]; then
json_error "500 Internal Server Error" "backend missing"
fi
export AGENTCHECKIN_BACKEND_DIR="$BACKEND_DIR"
exec "$PYBIN" "${BACKEND_DIR}/api.py"
;;
esac
# ---------------------------------------------------------------------------
# Static files (with path-traversal protection)
# ---------------------------------------------------------------------------
if [ -z "$REL_PATH" ] || [ "$REL_PATH" = "/" ]; then
REL_PATH="index.html"
fi
case "$REL_PATH" in
*..*) json_error "400 Bad Request" "bad path" ;;
esac
OLD_IFS="$IFS"
IFS='/'
for SEG in $REL_PATH; do
case "$SEG" in
''|.|*[!A-Za-z0-9._-]*) IFS="$OLD_IFS"; json_error "400 Bad Request" "bad path" ;;
esac
done
IFS="$OLD_IFS"
TARGET_FILE="${BASE_PATH}/${REL_PATH}"
if [ ! -f "$TARGET_FILE" ]; then
json_error "404 Not Found" "not found"
fi
case "${TARGET_FILE##*.}" in
html|htm) mime="text/html; charset=utf-8" ;;
css) mime="text/css; charset=utf-8" ;;
js) mime="application/javascript; charset=utf-8" ;;
json) mime="application/json; charset=utf-8" ;;
png) mime="image/png" ;;
jpg|jpeg) mime="image/jpeg" ;;
gif) mime="image/gif" ;;
svg) mime="image/svg+xml" ;;
ico) mime="image/x-icon" ;;
webmanifest) mime="application/manifest+json" ;;
txt) mime="text/plain; charset=utf-8" ;;
woff) mime="font/woff" ;;
woff2) mime="font/woff2" ;;
*) mime="application/octet-stream" ;;
esac
printf 'Content-Type: %s\r\n' "$mime"
printf 'Cache-Control: no-cache\r\n'
printf '\r\n'
cat "$TARGET_FILE"
exit 0
+512
View File
@@ -0,0 +1,512 @@
/* AgentCheckin frontend — vanilla JS, no dependencies */
(function () {
"use strict";
var state = null;
var pollTimer = null;
var oauthState = null;
var oauthTimer = null;
var logTimer = null;
var $ = function (id) { return document.getElementById(id); };
// ---------------------------------------------------------------- helpers
function toast(msg, kind) {
var t = $("toast");
t.textContent = msg;
t.className = "toast" + (kind ? " " + kind : "");
clearTimeout(t._timer);
t._timer = setTimeout(function () { t.classList.add("hidden"); }, 3200);
}
function api(method, path, body) {
var opts = { method: method, headers: {} };
if (body !== undefined && body !== null) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
return fetch("api" + path, opts).then(function (r) {
return r.json().catch(function () { return {}; });
}).then(function (d) {
if (d && d.error) throw new Error(d.error);
return d;
});
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
var STATUS = {
ok: { label: "签到成功", cls: "ok", icon: "✅" },
ok_unverified: { label: "已领取·待确认", cls: "ok_unverified", icon: "✅" },
already: { label: "今日已签", cls: "already", icon: "☑️" },
inactive: { label: "未开放签到", cls: "inactive", icon: "➖" },
fail: { label: "签到失败", cls: "fail", icon: "❌" },
no_session: { label: "需重新授权", cls: "no_session", icon: "🔒" },
no_auth: { label: "缺少凭据", cls: "no_auth", icon: "🔒" },
retry_later: { label: "风控冷却", cls: "retry_later", icon: "⏳" }
};
function platformName(p) { return p === "trae" ? "TRAE" : (p === "qoder" ? "Qoder" : "WorkBuddy"); }
// ---------------------------------------------------------------- tabs
document.querySelectorAll(".tab").forEach(function (btn) {
btn.addEventListener("click", function () {
document.querySelectorAll(".tab").forEach(function (b) { b.classList.remove("active"); });
document.querySelectorAll(".tabpage").forEach(function (p) { p.classList.remove("active"); });
btn.classList.add("active");
$("tab-" + btn.dataset.tab).classList.add("active");
if (btn.dataset.tab === "logs") loadLogs();
if (btn.dataset.tab === "accounts") renderAccounts();
if (btn.dataset.tab === "settings") openSettingsTab();
});
});
// ---------------------------------------------------------------- dashboard
function renderTop() {
var alive = state && state.daemon_alive;
var chip = $("daemon-chip");
chip.innerHTML = '<span class="dot"></span>守护进程 ' + (alive ? "运行中" : "已停止");
chip.className = "chip " + (alive ? "chip-green" : "chip-red");
var nr = $("nextrun-chip");
nr.textContent = "下次签到 " + ((state && state.next_run_at) || "—");
nr.className = "chip " + (state && state.next_run_at ? "chip-blue" : "chip-gray");
}
function renderCards() {
var wrap = $("dash-cards");
var accounts = (state && state.accounts) || [];
if (!accounts.length) {
wrap.innerHTML = '<div class="panel" style="grid-column:1/-1;text-align:center;color:var(--muted)">' +
"暂无账号,请先到「账号管理」添加 TRAE / WorkBuddy 账号</div>";
return;
}
wrap.innerHTML = accounts.map(function (a) {
var st = a.today && a.today.status ? STATUS[a.today.status] : null;
var statusHtml = st
? '<span class="status-chip ' + st.cls + '">' + st.icon + " " + st.label + "</span>"
: '<span class="status-chip pending">⏳ 今日未签</span>';
var credits = "";
if (a.last_result && typeof a.last_result.credits === "number") {
credits = "剩余积分 <b>" + a.last_result.credits + "</b>";
} else if (a.last_result && a.last_result.points) {
credits = "本次 +<b>" + a.last_result.points + "</b>";
}
var msg = a.today && a.today.message ? esc(a.today.message) : "";
return (
'<div class="acct-card' + (a.enabled ? "" : " disabled") + '">' +
'<div class="acct-head"><span class="platform-badge ' + esc(a.platform) + '">' + platformName(a.platform) + "</span>" +
'<span class="acct-name">' + esc(a.name) + "</span></div>" +
'<div class="acct-status">' + statusHtml + (msg ? '<span class="muted">' + msg + "</span>" : "") + "</div>" +
'<div class="acct-meta">' + (credits ? "<span>" + credits + "</span>" : "") +
(a.last_result ? "<span>最近运行 " + esc(a.last_result.time || "") + "</span>" : "") +
(!a.enabled ? "<span>已禁用</span>" : "") + "</div>" +
'<div class="acct-actions">' +
'<button class="btn btn-sm" data-act="run" data-id="' + esc(a.id) + '">立即签到</button>' +
'<button class="btn btn-sm" data-act="test" data-id="' + esc(a.id) + '">测连接</button>' +
"</div></div>"
);
}).join("");
wrap.querySelectorAll("button[data-act]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (btn.dataset.act === "run") runCheckin(btn.dataset.id, btn);
else testAccount(btn.dataset.id, btn);
});
});
}
var lastStateJson = "";
function refreshState(force) {
return api("GET", "/state").then(function (d) {
var json = JSON.stringify(d);
if (!force && json === lastStateJson) return; // 数据没变,跳过重渲染
lastStateJson = json;
state = d;
renderTop();
renderCards();
}).catch(function (e) { /* keep old state on transient errors */ });
}
// ---------------------------------------------------------------- run / test
var runningBtn = null;
function runCheckin(accountId, btn) {
if (btn) { btn.disabled = true; btn.textContent = "执行中…"; runningBtn = btn; }
api("POST", "/checkin", accountId ? { account_id: accountId } : {})
.then(function (d) {
if (d.mode === "trigger") {
toast("已触发签到,守护进程正在执行…");
waitForRun(accountId);
} else {
toast("签到完成", "ok");
refreshState();
}
})
.catch(function (e) { toast(e.message, "err"); })
.finally(function () {
if (runningBtn) { runningBtn.disabled = false; runningBtn.textContent = "立即签到"; runningBtn = null; }
});
}
function waitForRun(accountId) {
// poll until today_results contains a fresh entry for the target
var started = Date.now();
var before = JSON.stringify((state && state.today_results) || {});
(function poll() {
if (Date.now() - started > 90000) { refreshState(); return; }
refreshState().then(function () {
var after = JSON.stringify((state && state.today_results) || {});
var done = after !== before && (!accountId || (state.today_results || {})[accountId]);
if (done) {
var r = accountId ? state.today_results[accountId] : null;
if (r) toast(platformName(r.platform) + " " + r.name + ":" + (r.message || r.status), r.status === "fail" || r.status === "no_session" ? "err" : "ok");
else toast("签到完成", "ok");
} else {
setTimeout(poll, 3000);
}
});
})();
}
function testAccount(id, btn) {
btn.disabled = true; btn.textContent = "检测中…";
api("POST", "/accounts/test", { id: id })
.then(function (d) {
var parts = [];
if (d.refresh) parts.push("续期:" + d.refresh);
if (typeof d.checked_in === "boolean") parts.push("今日" + (d.checked_in ? "已签" : "未签"));
if (d.enable === false) parts.push("活动未开放");
if (d.credits && typeof d.credits.remain === "number") parts.push("剩余积分 " + d.credits.remain);
else if (typeof d.credits === "number") parts.push("积分 " + d.credits);
if (d.hint) parts.push(d.hint);
if (d.error) parts.push("错误:" + d.error);
toast(parts.join(";") || "检测完成", d.error ? "err" : "ok");
})
.catch(function (e) { toast(e.message, "err"); })
.finally(function () { btn.disabled = false; btn.textContent = "测连接"; });
}
// ---------------------------------------------------------------- accounts tab
function renderAccounts() {
var wrap = $("account-table-wrap");
var accounts = (state && state.accounts) || [];
if (!accounts.length) {
wrap.innerHTML = '<p class="muted">暂无账号,点击右上角「添加账号」。</p>';
return;
}
var rows = accounts.map(function (a) {
var st = a.today && a.today.status ? STATUS[a.today.status] : null;
return "<tr>" +
"<td><span class='platform-badge " + esc(a.platform) + "'>" + platformName(a.platform) + "</span></td>" +
"<td>" + esc(a.name) + (a.need_reauth ? ' <span class="status-chip no_session">需重新授权</span>' : "") +
(a.platform === "qoder" ? '<div class="muted" style="font-size:11px">' + esc((a.auth && a.auth.domain) || "") + "</div>" : "") + "</td>" +
"<td>" + esc(a.auth.accessToken || "") + "</td>" +
"<td>" + (st ? st.icon + " " + st.label : "⏳ 今日未签") + "</td>" +
"<td>" + (a.last_result && typeof a.last_result.credits === "number" ? a.last_result.credits : "—") + "</td>" +
"<td>" + (a.enabled ? "启用" : "禁用") + "</td>" +
'<td class="actions">' +
'<button class="btn btn-sm" data-a2="run" data-id="' + esc(a.id) + '">签到</button>' +
(a.platform === "qoder" ? '<button class="btn btn-sm" data-a2="fixdomain" data-id="' + esc(a.id) + '" title="自动探测 Token 所属站点并切换">修复站点</button>' : "") +
'<button class="btn btn-sm" data-a2="toggle" data-id="' + esc(a.id) + '">' + (a.enabled ? "禁用" : "启用") + "</button>" +
'<button class="btn btn-sm btn-danger" data-a2="del" data-id="' + esc(a.id) + '">删除</button>' +
"</td></tr>";
}).join("");
wrap.innerHTML = '<table class="tbl"><thead><tr><th>平台</th><th>名称</th><th>Token</th><th>今日</th><th>积分</th><th>状态</th><th>操作</th></tr></thead><tbody>' + rows + "</tbody></table>";
wrap.querySelectorAll("button[data-a2]").forEach(function (btn) {
btn.addEventListener("click", function () {
var id = btn.dataset.id, act = btn.dataset.a2;
if (act === "run") runCheckin(id, btn);
else if (act === "fixdomain") {
btn.disabled = true; btn.textContent = "探测中…";
api("POST", "/accounts/fix_domain", { id: id })
.then(function (d) {
if (d.ok) { toast("已切换到 " + d.domain, "ok"); refreshState(); }
else { toast(d.error || "探测失败", "err"); }
})
.catch(function (e) { toast(e.message, "err"); })
.finally(function () { btn.disabled = false; btn.textContent = "修复站点"; });
}
else if (act === "toggle") {
var acc = state.accounts.find(function (x) { return x.id === id; });
api("POST", "/accounts/update", { id: id, enabled: !acc.enabled })
.then(function () { refreshState(); }).catch(function (e) { toast(e.message, "err"); });
} else if (act === "del") {
if (!confirm("确定删除该账号?")) return;
api("POST", "/accounts/delete", { id: id })
.then(function () { toast("已删除", "ok"); refreshState(); })
.catch(function (e) { toast(e.message, "err"); });
}
});
});
}
// ---------------------------------------------------------------- add modal
function openModal() { $("modal-add").classList.remove("hidden"); }
function closeModal(id) { $(id).classList.add("hidden"); stopOauth(); $("add-msg").textContent = ""; }
document.querySelectorAll("[data-close]").forEach(function (b) {
b.addEventListener("click", function () { closeModal(b.dataset.close); });
});
$("modal-add").addEventListener("click", function (e) {
if (e.target === this) closeModal("modal-add");
});
$("btn-add-account").addEventListener("click", openModal);
document.querySelectorAll(".mtab").forEach(function (btn) {
btn.addEventListener("click", function () {
document.querySelectorAll(".mtab").forEach(function (b) { b.classList.remove("active"); });
document.querySelectorAll(".mtabpage").forEach(function (p) { p.classList.remove("active"); });
btn.classList.add("active");
$("mtab-" + btn.dataset.mtab).classList.add("active");
stopOauth();
});
});
function addMsg(msg, ok) {
var m = $("add-msg");
m.textContent = msg;
m.className = "msg " + (ok ? "ok" : "err");
}
$("btn-wb-manual-add").addEventListener("click", function () {
var token = $("wb-token").value.trim();
if (!token) return addMsg("请填写 Access Token", false);
api("POST", "/accounts/add_wb_manual", {
name: $("wb-name").value.trim(),
access_token: token,
refresh_token: $("wb-refresh").value.trim(),
domain: $("wb-domain").value
}).then(function () {
toast("WorkBuddy 账号已添加", "ok");
closeModal("modal-add"); refreshState();
}).catch(function (e) { addMsg(e.message, false); });
});
$("btn-trae-add").addEventListener("click", function () {
var token = $("trae-token").value.trim();
if (!token) return addMsg("请填写 Access Token", false);
api("POST", "/accounts/add_trae", {
name: $("trae-name").value.trim(),
access_token: token,
refresh_token: $("trae-refresh").value.trim()
}).then(function () {
toast("TRAE 账号已添加", "ok");
closeModal("modal-add"); refreshState();
}).catch(function (e) { addMsg(e.message, false); });
});
$("btn-qoder-add").addEventListener("click", function () {
var token = $("qoder-token").value.trim();
if (!token) return addMsg("请填写 Access Token", false);
if (token.length < 10) return addMsg("Access Token 仅 " + token.length + " 字符,明显不完整,请重新复制", false);
var rt0 = $("qoder-refresh").value.trim();
if (rt0 && rt0.length < 10) return addMsg("Refresh Token 仅 " + rt0.length + " 字符,明显不完整,请重新复制", false);
var dev = {};
var map = {
"qoder-machine-id": "machineId",
"qoder-machine-token": "machineToken",
"qoder-machine-code": "machineCode",
"qoder-machine-type": "machineType",
"qoder-version": "version",
};
Object.keys(map).forEach(function (id) {
var v = $(id).value.trim();
if (v) dev[map[id]] = v;
});
api("POST", "/accounts/add_qoder", {
name: $("qoder-name").value.trim(),
access_token: token,
refresh_token: $("qoder-refresh").value.trim(),
domain: $("qoder-domain").value,
device: dev
}).then(function () {
toast("Qoder 账号已添加", "ok");
closeModal("modal-add"); refreshState();
}).catch(function (e) { addMsg(e.message, false); });
});
// ---- workbuddy oauth flow
function stopOauth() {
if (oauthTimer) { clearInterval(oauthTimer); oauthTimer = null; }
oauthState = null;
$("oauth-box").classList.add("hidden");
}
$("btn-oauth-begin").addEventListener("click", function () {
this.disabled = true;
api("POST", "/wb_oauth_begin", {})
.then(function (d) {
oauthState = d.state;
$("oauth-url").value = d.login_url;
$("oauth-box").classList.remove("hidden");
$("oauth-status").textContent = "等待登录…(链接 " + Math.round(d.ttl / 60) + " 分钟内有效)";
oauthTimer = setInterval(pollOauth, 2000);
})
.catch(function (e) { addMsg(e.message, false); })
.finally(function () { this.disabled = false; }.bind(this));
});
$("btn-oauth-copy").addEventListener("click", function () {
var input = $("oauth-url");
input.select();
var done = function () { toast("链接已复制", "ok"); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(input.value).then(done, function () { document.execCommand("copy"); done(); });
} else { document.execCommand("copy"); done(); }
});
function pollOauth() {
if (!oauthState) return;
api("GET", "/wb_oauth_poll?state=" + encodeURIComponent(oauthState))
.then(function (d) {
if (d.status === "done") {
stopOauth();
toast("WorkBuddy 账号已绑定", "ok");
closeModal("modal-add");
refreshState();
}
})
.catch(function (e) {
$("oauth-status").textContent = e.message;
stopOauth();
});
}
// ---------------------------------------------------------------- logs
function loadLogs() {
api("GET", "/logs?lines=300").then(function (d) {
$("log-view").textContent = (d.lines || []).join("\n") || "暂无日志";
$("log-view").scrollTop = $("log-view").scrollHeight;
}).catch(function () {
$("log-view").textContent = "日志加载失败";
});
}
$("btn-refresh-logs").addEventListener("click", loadLogs);
setInterval(function () {
if (document.hidden) return;
if ($("log-autorefresh").checked && $("tab-logs").classList.contains("active")) loadLogs();
}, 15000);
// ---------------------------------------------------------------- settings
// Local editing copy: initialised from server state ONLY when the tab is
// opened (and there are no unsaved edits). The 10s state poll must never
// overwrite it, otherwise in-progress edits are silently discarded.
var settings = null;
var settingsDirty = false;
function openSettingsTab() {
if (!settingsDirty) {
settings = JSON.parse(JSON.stringify(
(state && state.settings) || { schedule_times: [], webhook_url: "", notify_on: [] }));
}
renderSettings();
}
function renderSettings() {
// renders ONLY from the local copy — never re-reads from `state` here
settings = settings || { schedule_times: [], webhook_url: "", notify_on: [] };
var chips = $("time-chips");
chips.innerHTML = "";
(settings.schedule_times || []).forEach(function (t) {
var el = document.createElement("span");
el.className = "time-chip";
el.innerHTML = esc(t) + '<button title="删除">×</button>';
el.querySelector("button").addEventListener("click", function () {
settings.schedule_times = settings.schedule_times.filter(function (x) { return x !== t; });
settingsDirty = true;
renderSettings();
});
chips.appendChild(el);
});
$("webhook-url").value = settings.webhook_url || "";
$("webhook-type").value = settings.webhook_type || "auto";
$("notify-fail").checked = (settings.notify_on || []).indexOf("fail") >= 0;
$("notify-success").checked = (settings.notify_on || []).indexOf("success") >= 0;
}
$("btn-add-time").addEventListener("click", function () {
var v = $("new-time").value;
if (!v) { toast("请先选择要添加的时间", "err"); return; }
settings.schedule_times = settings.schedule_times || [];
if (settings.schedule_times.indexOf(v) >= 0) {
toast("该时间已存在", "err");
return;
}
settings.schedule_times.push(v);
settings.schedule_times.sort();
settingsDirty = true;
renderSettings();
$("new-time").value = "";
});
$("btn-save-settings").addEventListener("click", function () {
var times = (settings.schedule_times || []).slice().sort();
if (!times.length) { $("settings-msg").textContent = "至少保留一个签到时间点"; $("settings-msg").className = "msg err"; return; }
var notify = [];
if ($("notify-fail").checked) notify.push("fail");
if ($("notify-success").checked) notify.push("success");
api("POST", "/settings", {
schedule_times: times,
webhook_url: $("webhook-url").value.trim(),
webhook_type: $("webhook-type").value,
notify_on: notify
}).then(function () {
settingsDirty = false;
$("settings-msg").textContent = "已保存";
$("settings-msg").className = "msg ok";
toast("设置已保存", "ok");
refreshState();
}).catch(function (e) {
$("settings-msg").textContent = e.message;
$("settings-msg").className = "msg err";
});
});
$("btn-test-notify").addEventListener("click", function () {
var url = $("webhook-url").value.trim();
if (!url) { $("settings-msg").textContent = "请先填写 Webhook 地址"; $("settings-msg").className = "msg err"; return; }
// save first so the daemon/notify uses the new url, then fire a real run
var notify = [];
if ($("notify-fail").checked) notify.push("fail");
if ($("notify-success").checked) notify.push("success");
api("POST", "/settings", { webhook_url: url, webhook_type: $("webhook-type").value, notify_on: notify })
.then(function () {
settingsDirty = false;
return api("POST", "/checkin", {});
})
.then(function () {
$("settings-msg").textContent = "已触发一次签到,通知将随结果发送";
$("settings-msg").className = "msg ok";
waitForRun(null);
})
.catch(function (e) {
$("settings-msg").textContent = e.message;
$("settings-msg").className = "msg err";
});
});
// ---------------------------------------------------------------- run all
$("btn-run-all").addEventListener("click", function () { runCheckin(null, null); });
// ---------------------------------------------------------------- boot
var STATE_POLL_MS = 30000;
function boot() {
refreshState(true);
renderAccounts();
pollTimer = setInterval(function () {
if (document.hidden) return; // 页面/iframe 不可见时暂停轮询
refreshState().then(renderAccountsIfVisible);
}, STATE_POLL_MS);
document.addEventListener("visibilitychange", function () {
if (!document.hidden) refreshState(true).then(renderAccountsIfVisible);
});
}
function renderAccountsIfVisible() {
if ($("tab-accounts").classList.contains("active")) renderAccounts();
}
boot();
})();
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3b6cff"/>
<stop offset="1" stop-color="#7a5af8"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#g)"/>
<rect x="14" y="16" width="36" height="34" rx="6" fill="none" stroke="#fff" stroke-width="3.5"/>
<rect x="22" y="11" width="4.5" height="8" rx="2.25" fill="#fff"/>
<rect x="37.5" y="11" width="4.5" height="8" rx="2.25" fill="#fff"/>
<line x1="14" y1="26" x2="50" y2="26" stroke="#fff" stroke-width="3.5"/>
<path d="M23 38 L29 44 L42 31" fill="none" stroke="#7ef0b2" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 769 B

+241
View File
@@ -0,0 +1,241 @@
/* AgentCheckin — fnOS app styles */
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--border: #e5e7eb;
--text: #1f2329;
--muted: #8a909a;
--primary: #3b6cff;
--primary-dark: #2f57d6;
--green: #16a34a;
--green-bg: #eaf7ef;
--red: #dc2626;
--red-bg: #fdecec;
--amber: #d97706;
--amber-bg: #fdf3e3;
--blue-bg: #e8efff;
--gray-bg: #f0f1f3;
--radius: 12px;
--shadow: 0 1px 3px rgba(16, 24, 40, .06), 0 1px 2px rgba(16, 24, 40, .04);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.6;
}
/* ---------- top bar ---------- */
.topbar {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; flex-wrap: wrap;
padding: 16px 24px;
background: linear-gradient(135deg, #3b6cff 0%, #7a5af8 100%);
color: #fff;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-logo { width: 44px; height: 44px; }
.brand h1 { font-size: 18px; margin: 0; font-weight: 600; }
.brand .sub { margin: 0; font-size: 12px; opacity: .85; }
.topbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 12px; border-radius: 999px;
font-size: 12px; font-weight: 500;
background: rgba(255,255,255,.22); color: #fff;
}
.chip .dot { width: 8px; height: 8px; border-radius: 50%; background: #fff; }
.chip.chip-green .dot { background: #4ade80; }
.chip.chip-red .dot { background: #f87171; }
/* ---------- tabs ---------- */
.tabs {
display: flex; gap: 4px;
padding: 0 24px;
background: var(--panel);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 10;
}
.tab {
border: none; background: none; cursor: pointer;
padding: 12px 18px; font-size: 14px; color: var(--muted);
border-bottom: 2px solid transparent;
font-weight: 500;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
main { max-width: 1080px; margin: 0 auto; padding: 20px 24px 60px; }
.tabpage { display: none; }
.tabpage.active { display: block; }
/* ---------- buttons ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
border: 1px solid var(--border); background: var(--panel); color: var(--text);
border-radius: 8px; padding: 7px 14px; font-size: 13px; cursor: pointer;
transition: all .15s ease; white-space: nowrap;
}
.btn:hover { border-color: #c7cdd6; background: #fafbfc; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
.btn-danger { color: var(--red); border-color: #f3c2c2; }
.btn-danger:hover { background: var(--red-bg); border-color: var(--red); }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
/* ---------- panels & cards ---------- */
.panel {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 20px; margin-bottom: 20px;
}
.panel h2 { margin: 0 0 12px; font-size: 15px; font-weight: 600; }
.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.panel-head h2 { margin: 0; }
.muted { color: var(--muted); font-size: 13px; margin: 4px 0; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px; margin-bottom: 20px;
}
.acct-card {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 16px; display: flex; flex-direction: column; gap: 10px;
}
.acct-card.disabled { opacity: .62; }
.acct-head { display: flex; align-items: center; gap: 8px; }
.platform-badge {
font-size: 11px; font-weight: 700; letter-spacing: .4px;
padding: 3px 8px; border-radius: 6px;
}
.platform-badge.trae { background: #e8f3ff; color: #0a5cd8; }
.platform-badge.workbuddy { background: #e8f7ef; color: #0a8a4a; }
.platform-badge.qoder { background: #f3eaff; color: #7c3aed; }
.acct-name { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct-status { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.status-chip { padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; }
.status-chip.ok { background: var(--green-bg); color: var(--green); }
.status-chip.already { background: var(--blue-bg); color: var(--primary); }
.status-chip.pending { background: var(--gray-bg); color: var(--muted); }
.status-chip.fail, .status-chip.no_session, .status-chip.no_auth { background: var(--red-bg); color: var(--red); }
.status-chip.inactive, .status-chip.retry_later, .status-chip.ok_unverified { background: var(--amber-bg); color: var(--amber); }
.acct-meta { font-size: 12px; color: var(--muted); display: flex; flex-direction: column; gap: 2px; }
.acct-meta b { color: var(--text); font-weight: 600; }
.acct-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
.hint-list { margin: 0; padding-left: 20px; color: var(--text); }
.hint-list li { margin-bottom: 6px; }
/* ---------- table ---------- */
table.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl th {
text-align: left; padding: 8px 10px; color: var(--muted);
font-weight: 500; font-size: 12px; border-bottom: 1px solid var(--border);
}
.tbl td { padding: 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.tbl tr:last-child td { border-bottom: none; }
.tbl .actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* ---------- forms ---------- */
.input {
border: 1px solid var(--border); border-radius: 8px;
padding: 8px 12px; font-size: 13px; font-family: inherit;
background: #fff; color: var(--text);
outline: none; transition: border-color .15s;
}
.input:focus { border-color: var(--primary); }
.input-wide { width: 100%; }
.input-time { width: 140px; }
label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 12px; }
label .input { margin-top: 6px; }
.req { color: var(--red); }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.switch-label { display: inline-flex; align-items: center; gap: 6px; font-weight: 400; margin-bottom: 0; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.time-chip {
display: inline-flex; align-items: center; gap: 6px;
background: var(--blue-bg); color: var(--primary);
border-radius: 999px; padding: 4px 8px 4px 12px; font-size: 13px; font-weight: 500;
}
.time-chip button {
border: none; background: rgba(59,108,255,.15); color: var(--primary);
width: 18px; height: 18px; border-radius: 50%; cursor: pointer;
font-size: 12px; line-height: 1; display: inline-flex; align-items: center; justify-content: center;
}
.time-chip button:hover { background: var(--primary); color: #fff; }
.msg { margin-top: 10px; font-size: 13px; min-height: 20px; }
.msg.ok { color: var(--green); }
.msg.err { color: var(--red); }
/* ---------- modal ---------- */
.modal-mask {
position: fixed; inset: 0; background: rgba(15, 18, 25, .45);
display: flex; align-items: center; justify-content: center;
z-index: 100; padding: 20px;
}
.modal-mask.hidden { display: none; }
.modal {
background: #fff; border-radius: 14px; width: 100%; max-width: 520px;
max-height: 90vh; overflow-y: auto; padding: 22px;
box-shadow: 0 20px 50px rgba(0,0,0,.18);
}
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.modal-head h3 { margin: 0; font-size: 16px; }
.modal-close {
border: none; background: var(--gray-bg); width: 28px; height: 28px;
border-radius: 50%; font-size: 16px; cursor: pointer; color: var(--muted);
}
.modal-close:hover { background: var(--border); color: var(--text); }
.modal-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
.mtab {
border: none; background: none; padding: 8px 12px; cursor: pointer;
font-size: 13px; color: var(--muted); border-bottom: 2px solid transparent;
}
.mtab.active { color: var(--primary); border-bottom-color: var(--primary); }
.mtabpage { display: none; }
.mtabpage.active { display: block; }
.help { border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 14px; background: #fafbfc; }
.help summary { cursor: pointer; font-size: 13px; font-weight: 500; color: var(--primary); }
.help-body { margin-top: 10px; font-size: 13px; }
.help-body p { margin: 6px 0; }
.help-body code { background: var(--gray-bg); padding: 2px 6px; border-radius: 4px; font-size: 12px; }
/* ---------- logs ---------- */
.log-view {
background: #0f1420; color: #c9d4e5; border-radius: 10px;
padding: 14px 16px; font-size: 12px; line-height: 1.7;
font-family: "SF Mono", Menlo, Consolas, "Courier New", monospace;
max-height: 560px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;
margin: 0;
}
/* ---------- toast ---------- */
.toast {
position: fixed; left: 50%; bottom: 36px; transform: translateX(-50%);
background: #1f2329; color: #fff; padding: 10px 20px;
border-radius: 10px; font-size: 13px; z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,.25);
max-width: 80vw;
}
.toast.hidden { display: none; }
.toast.err { background: var(--red); }
.toast.ok { background: var(--green); }
.hidden { display: none !important; }
@media (max-width: 640px) {
.topbar { padding: 14px 16px; }
.tabs { padding: 0 12px; }
main { padding: 16px 12px 40px; }
.tab { padding: 10px 12px; }
}
+214
View File
@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent 签到助手</title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<img src="assets/logo.svg" alt="" class="brand-logo">
<div>
<h1>Agent 签到助手</h1>
<p class="sub">TRAE · WorkBuddy 每日积分自动签到</p>
</div>
</div>
<div class="topbar-right">
<span id="daemon-chip" class="chip chip-gray">守护进程 —</span>
<span id="nextrun-chip" class="chip chip-blue">下次签到 —</span>
<button id="btn-run-all" class="btn btn-primary">立即签到</button>
</div>
</header>
<nav class="tabs">
<button class="tab active" data-tab="dashboard">仪表盘</button>
<button class="tab" data-tab="accounts">账号管理</button>
<button class="tab" data-tab="logs">签到记录</button>
<button class="tab" data-tab="settings">设置</button>
</nav>
<main>
<!-- ======================= 仪表盘 ======================= -->
<section id="tab-dashboard" class="tabpage active">
<div id="dash-cards" class="card-grid"></div>
<div class="panel">
<h2>使用说明</h2>
<ul class="hint-list">
<li><b>TRAE</b>(字节跳动·国内版):每日自动领取 <b>200</b> Work 专属积分。</li>
<li><b>WorkBuddy / CodeBuddy</b>(腾讯):7 天循环签到,第 1-6 天每天 100 分,第 7 天 1000 分。</li>
<li><b>Qoder</b>(国内版):每日自动领取 <b>100</b> Credits(活动非零点准时下发,多时段自动补领)。</li>
<li>签到由 NAS 本地守护进程自动执行,失败会在下一个时段自动补签; Token 过期会标记需重新授权。</li>
<li>所有凭据仅保存在本机 <code>@appdata/AgentCheckin</code>,不会上传到任何第三方。</li>
</ul>
</div>
</section>
<!-- ======================= 账号管理 ======================= -->
<section id="tab-accounts" class="tabpage">
<div class="panel">
<div class="panel-head">
<h2>账号列表</h2>
<button class="btn btn-primary" id="btn-add-account">+ 添加账号</button>
</div>
<div id="account-table-wrap"></div>
</div>
</section>
<!-- ======================= 签到记录 ======================= -->
<section id="tab-logs" class="tabpage">
<div class="panel">
<div class="panel-head">
<h2>运行日志</h2>
<div>
<label class="switch-label"><input type="checkbox" id="log-autorefresh" checked> 自动刷新</label>
<button class="btn" id="btn-refresh-logs">刷新</button>
</div>
</div>
<pre id="log-view" class="log-view">加载中…</pre>
</div>
</section>
<!-- ======================= 设置 ======================= -->
<section id="tab-settings" class="tabpage">
<div class="panel">
<h2>签到时间</h2>
<p class="muted">每天在这些时间点自动检查并签到(已签会自动跳过)。错过时段后在守护进程启动时会自动补跑。</p>
<div id="time-chips" class="chips"></div>
<div class="row">
<input type="time" id="new-time" class="input input-time">
<button class="btn" id="btn-add-time">添加时间点</button>
</div>
</div>
<div class="panel">
<h2>消息通知(可选)</h2>
<p class="muted">签到后向 Webhook 发送 POST JSON(<code>{"title","content","text","msgtype"}</code>),兼容企业微信/钉钉/飞书/Bark 等机器人。留空则不通知。</p>
<input type="url" id="webhook-url" class="input input-wide" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...">
<label style="margin-top:12px">通知平台
<select id="webhook-type" class="input">
<option value="auto">自动识别(推荐)</option>
<option value="wecom">企业微信机器人</option>
<option value="dingtalk">钉钉机器人</option>
<option value="feishu">飞书机器人</option>
<option value="bark">Bark(iOS 推送)</option>
<option value="generic">通用 JSON(Server酱 / PushPlus 等)</option>
</select>
</label>
<div class="row">
<label class="switch-label"><input type="checkbox" id="notify-fail" checked> 失败时通知</label>
<label class="switch-label"><input type="checkbox" id="notify-success"> 成功时也通知</label>
</div>
<div class="row">
<button class="btn btn-primary" id="btn-save-settings">保存设置</button>
<button class="btn" id="btn-test-notify">发送测试通知</button>
</div>
<div id="settings-msg" class="msg"></div>
</div>
</section>
</main>
<!-- ======================= 添加账号弹窗 ======================= -->
<div id="modal-add" class="modal-mask hidden">
<div class="modal">
<div class="modal-head">
<h3>添加账号</h3>
<button class="modal-close" data-close="modal-add">×</button>
</div>
<div class="modal-tabs">
<button class="mtab active" data-mtab="wb-oauth">WorkBuddy 网页授权</button>
<button class="mtab" data-mtab="wb-manual">WorkBuddy 手动</button>
<button class="mtab" data-mtab="trae">TRAE</button>
<button class="mtab" data-mtab="qoder">Qoder</button>
</div>
<div id="mtab-wb-oauth" class="mtabpage active">
<p>点击下方按钮生成授权链接,在浏览器中打开并登录腾讯 CodeBuddy / WorkBuddy 账号,本页面会自动完成绑定。</p>
<button class="btn btn-primary" id="btn-oauth-begin">生成授权链接</button>
<div id="oauth-box" class="hidden">
<p>请在浏览器中打开以下链接完成登录(10 分钟内有效):</p>
<div class="row">
<input id="oauth-url" class="input input-wide" readonly>
<button class="btn" id="btn-oauth-copy">复制</button>
</div>
<p id="oauth-status" class="muted">等待登录…</p>
</div>
</div>
<div id="mtab-wb-manual" class="mtabpage">
<label>名称(可选)<input id="wb-name" class="input input-wide" placeholder="我的 CodeBuddy"></label>
<label>Access Token <span class="req">*</span><input id="wb-token" class="input input-wide" placeholder="accessToken"></label>
<label>Refresh Token(可选,用于自动续期)<input id="wb-refresh" class="input input-wide" placeholder="refreshToken"></label>
<label>区域
<select id="wb-domain" class="input">
<option value="codebuddy.cn">国内版 codebuddy.cn(支持签到)</option>
<option value="workbuddy.ai">国际版 workbuddy.ai(无签到)</option>
</select>
</label>
<button class="btn btn-primary" id="btn-wb-manual-add">添加</button>
</div>
<div id="mtab-trae" class="mtabpage">
<label>名称(可选)<input id="trae-name" class="input input-wide" placeholder="TRAE 主账号"></label>
<label>Access Token <span class="req">*</span><input id="trae-token" class="input input-wide" placeholder="eyJ 开头的 JWT"></label>
<label>Refresh Token(可选)<input id="trae-refresh" class="input input-wide" placeholder="可选"></label>
<details class="help">
<summary>如何获取 TRAE Token?</summary>
<div class="help-body">
<p>在登录了 TRAE 国内版客户端的电脑上,凭据保存在:</p>
<p><code>Windows: %APPDATA%\Trae CN\User\globalStorage\storage.json</code></p>
<p>其中键 <code>iCubeAuthInfo://icube.cloudide</code> 的值为加密串,解密后可得 <code>token</code>(即 Access Token,JWT 格式)。</p>
<p>也可使用社区工具 <code>trae_get_token.py</code>(Trae-AutoCheckin 项目)一键提取。</p>
<p class="muted">Token 约 60 天有效;过期后在本页重新粘贴即可。</p>
</div>
</details>
<button class="btn btn-primary" id="btn-trae-add">添加</button>
</div>
<div id="mtab-qoder" class="mtabpage">
<label>名称(可选)<input id="qoder-name" class="input input-wide" placeholder="Qoder 主账号"></label>
<label>Access Token <span class="req">*</span><input id="qoder-token" class="input input-wide" placeholder="dt- 开头的 JWT"></label>
<label>Refresh Token(可选,用于自动续期)<input id="qoder-refresh" class="input input-wide" placeholder="drt- 开头,可选"></label>
<label>站点区域(重要:两个站点 Token 不互通,选错会 401)
<select id="qoder-domain" class="input">
<option value="openapi.qoder.com.cn">国内版 openapi.qoder.com.cn(qoder.cn 客户端)</option>
<option value="openapi.qoder.sh">国际版 openapi.qoder.sh(qoder.com 客户端)</option>
</select>
</label>
<details class="help" open>
<summary>如何获取 Qoder Token?(三种方式)</summary>
<div class="help-body">
<p><b>方式 A · 设备授权登录(推荐,无需抓包)</b></p>
<p>在电脑上下载社区工具 <code>qoder_login.py</code>(GitHub: L0NE-6/Qoder-Daily),运行 <code>python qoder_login.py</code>,在浏览器打开授权页确认一次,工具会输出 <code>access_token|refresh_token</code>,分别粘贴到上方即可。</p>
<p><b>方式 B · 浏览器抓包(最快)</b></p>
<p>浏览器登录 Qoder 网页版 → F12 开发者工具 → 网络 → 点页面里任意功能,任选一个请求 → 在请求头里找到 <code>authorization: Bearer dt-...</code></p>
<p><b>复制技巧</b>:右键该值 →「Copy value」复制完整内容,粘贴后删掉开头的 <code>Bearer </code>。</p>
<p><b>注意</b>:Qoder 的 <code>dt-</code> Token 原生就只有 <b>27/28 个字符</b>(不是截断,这是它的原生格式),<code>drt-</code> 刷新串同理。</p>
<p><b>关键</b>:国内版(qoder.cn)与国际版(qoder.com)的 Token <b>不互通</b>——用哪个客户端登录提的 Token,上方「站点区域」就要选对应的那个,选错会直接 401。拿不准可以先用「测连接」,它会自动探测 Token 属于哪个站点。</p>
<p><b>方式 C · 本机客户端提取</b></p>
<p>在装了并登录 Qoder 客户端的 Windows 上运行社区工具 <code>01_extract.py</code>(GitHub: wallechfox/qoder-checkin),自动解密 <code>auth.v1.dat</code> 取出 Token 和设备标识。</p>
<p class="muted">Token 有效期较短,过期后重新获取即可;填了 Refresh Token 的应用会自动续期。</p>
</div>
</details>
<details class="help">
<summary>设备标识(可选,领取提示风控时填写)</summary>
<div class="help-body">
<p>Qoder 签到需要 Cosy-* 设备标识。一般情况留空即可;若提示风控/领取失败,用方式 C 的工具提取后填入:</p>
<div class="row"><input id="qoder-machine-id" class="input input-wide" placeholder="Cosy-MachineId"></div>
<div class="row"><input id="qoder-machine-token" class="input input-wide" placeholder="Cosy-MachineToken"></div>
<div class="row"><input id="qoder-machine-code" class="input input-wide" placeholder="Cosy-MachineCode"></div>
<div class="row"><input id="qoder-machine-type" class="input input-wide" placeholder="Cosy-MachineType"></div>
<div class="row"><input id="qoder-version" class="input input-wide" placeholder="Cosy-Version(客户端版本号)"></div>
</div>
</details>
<button class="btn btn-primary" id="btn-qoder-add">添加</button>
</div>
<div id="add-msg" class="msg"></div>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script src="assets/app.js"></script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# Initialize the AgentCheckin data directory and default settings.
DATA_DIR="${TRIM_PKGVAR:-}"
if [ -n "$DATA_DIR" ] && [ -d "$DATA_DIR" ]; then
mkdir -p "${DATA_DIR}/logs"
[ -f "${DATA_DIR}/accounts.json" ] || printf '{"accounts":[]}\n' > "${DATA_DIR}/accounts.json"
[ -f "${DATA_DIR}/settings.json" ] || printf '{"schedule_times":["00:05","09:00","12:30","19:00"],"webhook_url":"","notify_on":["fail"]}\n' > "${DATA_DIR}/settings.json"
[ -f "${DATA_DIR}/state.json" ] || printf '{}\n' > "${DATA_DIR}/state.json"
chmod 600 "${DATA_DIR}/accounts.json" "${DATA_DIR}/settings.json" 2>/dev/null
fi
exit 0
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+99
View File
@@ -0,0 +1,99 @@
#!/bin/bash
# AgentCheckin service control.
# The scheduler daemon runs as a detached background process and manages its
# own pidfile in the persistent data dir (/vol{n}/@appdata/AgentCheckin).
PYBIN=""
for candidate in \
/var/apps/python312/target/bin/python3 \
/vol1/@appcenter/python312/target/bin/python3 \
/var/apps/python311/target/bin/python3 \
/var/apps/python310/target/bin/python3 \
/usr/bin/python3 \
/usr/local/bin/python3; do
if [ -x "$candidate" ]; then PYBIN="$candidate"; break; fi
done
APPDEST="${TRIM_APPDEST:-$(cd "$(dirname "$0")/.." && pwd)}"
BACKEND_DIR="${APPDEST}/target/backend"
if [ ! -d "$BACKEND_DIR" ]; then
BACKEND_DIR="${APPDEST}/backend"
fi
DAEMON="${BACKEND_DIR}/daemon.py"
# Resolve the persistent data dir (mirrors backend/common.py).
if [ -n "${TRIM_PKGVAR}" ] && [ -d "${TRIM_PKGVAR}" ]; then
DATA_DIR="${TRIM_PKGVAR}"
else
DATA_DIR=""
for candidate in \
"${APPDEST}/../../@appdata/AgentCheckin" \
"${APPDEST}/var" \
"/var/apps/AgentCheckin/var" \
"/vol1/@appdata/AgentCheckin" \
"/vol2/@appdata/AgentCheckin"; do
if [ -d "$candidate" ]; then DATA_DIR="$candidate"; break; fi
done
if [ -z "$DATA_DIR" ]; then
DATA_DIR="${APPDEST}/../../@appdata/AgentCheckin"
fi
fi
PIDFILE="${DATA_DIR}/daemon.pid"
daemon_alive() {
[ -f "$PIDFILE" ] || return 1
local pid
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] || return 1
kill -0 "$pid" 2>/dev/null
}
case "$1" in
start)
if daemon_alive; then
echo "AgentCheckin daemon already running (pid $(cat "$PIDFILE"))"
exit 0
fi
if [ -z "$PYBIN" ]; then
echo "python3 runtime not found (install_dep_apps=python312)" > "${TRIM_TEMP_LOGFILE:-/dev/stderr}"
exit 1
fi
mkdir -p "$DATA_DIR"
AGENTCHECKIN_DATA_DIR="$DATA_DIR" setsid nohup "$PYBIN" "$DAEMON" >> "${DATA_DIR}/daemon.out" 2>&1 &
# wait for the daemon to write its pidfile (max ~5s)
for _ in 1 2 3 4 5 6 7 8 9 10; do
if daemon_alive; then
echo "AgentCheckin daemon started (pid $(cat "$PIDFILE"))"
exit 0
fi
sleep 0.5
done
echo "AgentCheckin daemon failed to start; see ${DATA_DIR}/daemon.out" > "${TRIM_TEMP_LOGFILE:-/dev/stderr}"
exit 1
;;
stop)
if daemon_alive; then
pid="$(cat "$PIDFILE")"
kill "$pid" 2>/dev/null
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$pid" 2>/dev/null || break
sleep 0.5
done
kill -9 "$pid" 2>/dev/null
echo "AgentCheckin daemon stopped (pid $pid)"
else
echo "AgentCheckin daemon not running"
fi
rm -f "$PIDFILE"
exit 0
;;
status)
if daemon_alive; then
exit 0
fi
exit 3
;;
*)
exit 1
;;
esac
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# Stop the daemon before uninstall (best effort).
DATA_DIR="${TRIM_PKGVAR:-}"
if [ -n "$DATA_DIR" ] && [ -f "${DATA_DIR}/daemon.pid" ]; then
pid="$(cat "${DATA_DIR}/daemon.pid" 2>/dev/null)"
[ -n "$pid" ] && kill "$pid" 2>/dev/null
rm -f "${DATA_DIR}/daemon.pid"
fi
exit 0
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 0
+8
View File
@@ -0,0 +1,8 @@
{
"defaults":
{
"run-as": "package"
},
"username": "agentcheckin",
"groupname": "agentcheckin"
}
+1
View File
@@ -0,0 +1 @@
{}
+16
View File
@@ -0,0 +1,16 @@
appname = AgentCheckin
version = 1.2.2
display_name = Agent 签到助手
desc = AI 编程工具每日积分自动签到:支持 TRAE(字节跳动,国内版每日 200 积分)与 WorkBuddy / CodeBuddy(腾讯,7 天循环签到最高 1000 积分/天)。多账号管理、多时段自动补签、积分看板、签到日志、Webhook 通知,数据全部保存在 NAS 本地。
platform = all
source = thirdparty
maintainer = AgentCheckin
maintainer_url = https://club.fnnas.com
distributor = AgentCheckin
distributor_url = https://club.fnnas.com
os_min_version = 1.2.0
install_dep_apps = python312
desktop_uidir = ui
desktop_applaunchname = AgentCheckin.Application
checkport = false
disable_authorization_path = true