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