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
+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 账号"