Files
fnos-agent-checkin/AgentCheckin/app/backend/qoder.py
T
cbb c263edc3e7 feat(qoder): 适配国际版每日签到 — UMID 设备风控身份 + 国际版活动 schema
核心发现(实测):
- Qoder 国际版服务端只对带 Cosy-MachineToken/Code/Type 的请求下发签到活动,
  否则 campaigns 返回空数组(国内版无此限制)
- 国际版活动 actionType=VIEW_DETAILS(国内版 CLAIM_BENEFIT),claimStatus 一致
- 活动窗口 2026-09-01~10-31,每日 10:00 (UTC+8) 刷新,领取后 30 天有效

实现:
- 新增 umid.py:从官方 npm 包 @qoder-ai/qodercli 下载并提取内嵌 UMID 二进制
  (x86_64/arm64 ELF),试跑校验后缓存到 @appdata/AgentCheckin/umid/
- qoder.py:签到前自动确保风控身份,支持国际版/国内版双 schema 活动解析
- API:/api/umid/status、/api/umid/install、/api/accounts/refresh_risk
- 前端:设置页风控身份面板(下载/状态)、账号列表「刷新风控」按钮
- 诊断:测连接返回 risk_identity / campaigns_debug(原始字段)

验证:NAS 实测 Qoder 国际版从「活动列表为空」变为「今日已领取(75 积分)」
测试:37 后端 + 25 E2E + 9 前端,全部通过
2026-10-19 12:04:44 +08:00

478 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
import umid
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)
# 客户端默认版本(社区实现实测值:缺 Cosy-Version 时 campaigns 可能返回空数组)
DEFAULT_VERSION = "0.3.4"
DEVICE_DEFAULTS = {
"clientType": "10",
"version": DEFAULT_VERSION,
"machineOS": "",
"machineHostname": "",
"machineId": "",
"machineToken": "",
"machineCode": "",
"machineType": "",
}
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 risk_identity(account, force=False):
"""获取(并缓存到账号)设备风控身份 Cosy-MachineToken/Code/Type。
Qoder 服务端(尤其国际版)只对带风控身份的请求下发「每天领 100 Credits」活动。
优先用账号 device 块里已存的;缺失时调用本机 runtime-info 现场生成。
"""
dev = account.get("device") or {}
if not force and dev.get("machineToken") and dev.get("machineCode") and dev.get("machineType"):
return dev["machineToken"], dev["machineCode"], dev["machineType"], None
provider = "qoder" if is_global(account.get("auth") or {}) else "qoder-cn"
ident, err = umid.risk_identity(provider, account.get("uid") or "")
if err:
return None, None, None, err
dev = dict(dev)
dev.update({"machineToken": ident["machineToken"],
"machineCode": ident["machineCode"],
"machineType": ident["machineType"]})
account["device"] = dev
return dev["machineToken"], dev["machineCode"], dev["machineType"], None
def _headers(account):
h = {"Accept": "application/json", "Accept-Language": "zh-CN", "User-Agent": "Qoder"}
# 设备标识:有值才发(空值可能触发服务端设备校验)
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 campaigns_debug(account):
"""诊断:返回活动接口的原始响应摘要(不含 token)。"""
data, status, err = _api(account, PATH_CAMPAIGNS + "?forceRefresh=true", "GET")
out = {"status": status, "error": err}
if isinstance(data, dict):
cs = data.get("campaigns")
out["campaign_count"] = len(cs) if isinstance(cs, list) else None
if isinstance(cs, list) and cs:
out["campaigns"] = [describe_campaign(c) for c in cs[:10]]
# 原始字段摘要(便于排查 schema 差异)
raw = {}
for k, v in cs[0].items():
if isinstance(v, (str, int, float, bool)) or v is None:
raw[k] = (str(v)[:60] if isinstance(v, str) else v)
elif isinstance(v, dict):
raw[k] = {kk: (str(vv)[:40] if isinstance(vv, str) else vv) for kk, vv in list(v.items())[:8]}
elif isinstance(v, list):
raw[k] = "[%d items]" % len(v)
out["campaign_raw_fields"] = raw
else:
out["raw_keys"] = list(data.keys())[:10]
return out
def _benefits(campaigns):
"""CLAIM_BENEFIT campaigns(活动/签到坑位)。
注意:真实数据里 benefit 可能只有 kind 没有 amount(活动信息在别处),
所以只按 actionType 过滤;kind 明确非 CREDITS 时才排除。
"""
out = []
for c in campaigns:
if not isinstance(c, dict):
continue
action = c.get("actionType")
claim_status = c.get("claimStatus")
benefit = c.get("benefit")
kind = benefit.get("kind") if isinstance(benefit, dict) else None
# 判定规则(已实测):
# 国内版:actionType == CLAIM_BENEFIT 且 claimStatus 为 CLAIMABLE/CLAIMED
# 国际版:actionType 为 VIEW_DETAILS(点「领取」按钮才领),claimStatus 同样有效
is_benefit_action = action in ("CLAIM_BENEFIT", "VIEW_DETAILS")
has_claim_state = claim_status in ("CLAIMABLE", "CLAIMED")
if not (is_benefit_action or (action is None and has_claim_state)):
continue
if not has_claim_state:
continue
# 只排除明确非积分权益(如实物/优惠券)
if kind and kind not in ("CREDITS",):
continue
out.append(c)
return out
def describe_campaign(c):
"""Human-readable campaign summary (title / amount / window / countdown).
Mirrors what the client shows in the activity card: 每天领 100 Credits,
每日 10:00 (UTC+8) 刷新, 领取后 30 天有效, plus a countdown to next reset.
"""
b = c.get("benefit") or {}
amount = b.get("amount")
kind = b.get("kind") or "CREDITS"
title = c.get("campaignName") or c.get("title") or c.get("campaignKey") or ""
if title.startswith("act-"):
title = "每日签到活动"
info = {"campaignId": c.get("campaignId"), "campaignKey": c.get("campaignKey"),
"title": title, "amount": amount, "kind": kind,
"actionType": c.get("actionType"),
"claimStatus": c.get("claimStatus")}
# 有效期
for src_key, dst_key in (("startAt", "startAt"), ("endAt", "endAt")):
if c.get(src_key):
info[dst_key] = c[src_key]
# 刷新周期(每日 10:00 UTC+8 等)
for key in ("refreshCron", "refreshTime", "resetAt", "resetTime", "nextRefreshAt",
"dailyRefreshTime", "refreshPeriod"):
if c.get(key) is not None:
info[key] = c[key]
# 领取后有效期
for key in ("validDays", "benefitValidDays", "expireDays", "validityDays"):
if c.get(key) is not None:
info["validDays"] = c[key]
# 倒计时(服务端可能直接给剩余秒数/毫秒)
for key in ("countdownMs", "countdown", "remainingMs", "remainingSeconds", "nextClaimInMs"):
if c.get(key) is not None:
info["countdownMs"] = c[key]
return info
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}
# Qoder 服务端只对带风控身份的请求下发签到活动——先确保身份就绪
tok, code, mtype, rerr = risk_identity(account)
if rerr:
log("qoder risk identity unavailable: %s" % rerr, "WARN")
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:
# 没有任何可识别活动:可能是活动未下发/已结束,也可能是 schema 变化
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 账号"