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