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 前端,全部通过
This commit is contained in:
cbb
2026-10-19 12:04:44 +08:00
parent a3b84d5ce2
commit c263edc3e7
10 changed files with 484 additions and 17 deletions
+34
View File
@@ -203,6 +203,37 @@ def h_fix_qoder_domain(_params, body):
return {"ok": False, "probe": probe, return {"ok": False, "probe": probe,
"error": "两个站点均无法认证(Token 可能已过期),请重新获取 Token"} "error": "两个站点均无法认证(Token 可能已过期),请重新获取 Token"}
def h_umid_status(_params, _body):
"""Qoder 风控身份(UMID runtime-info)安装状态。"""
import umid as _u
return _u.status()
def h_umid_install(_params, _body):
"""下载官方 qodercli 并提取 runtime-info(约 30MB,耗时 1-3 分钟)。"""
import umid as _u
ok, msg = _u.install(force=bool((_body or {}).get("force")))
return {"ok": ok, "message": msg, "status": _u.status()}
def h_qoder_refresh_risk(_params, body):
"""为某个 Qoder 账号重新生成风控身份。"""
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
tok, code, mtype, err = _q.risk_identity(acc, force=True)
if err:
return {"ok": False, "error": err}
engine.save_accounts([acc if a.get("id") == acc_id else a for a in accounts])
return {"ok": True, "machineToken": tok[:12] + "…", "machineCode": code, "machineType": mtype}
def h_delete_account(_params, body): def h_delete_account(_params, body):
acc_id = body.get("id") acc_id = body.get("id")
if not acc_id: if not acc_id:
@@ -356,6 +387,9 @@ ROUTES = {
("POST", "/api/accounts/test"): h_test_account, ("POST", "/api/accounts/test"): h_test_account,
("POST", "/api/wb_oauth_begin"): h_wb_oauth_begin, ("POST", "/api/wb_oauth_begin"): h_wb_oauth_begin,
("GET", "/api/wb_oauth_poll"): h_wb_oauth_poll, ("GET", "/api/wb_oauth_poll"): h_wb_oauth_poll,
("GET", "/api/umid/status"): h_umid_status,
("POST", "/api/umid/install"): h_umid_install,
("POST", "/api/accounts/refresh_risk"): h_qoder_refresh_risk,
("GET", "/api/settings"): h_settings_get, ("GET", "/api/settings"): h_settings_get,
("POST", "/api/settings"): h_settings_post, ("POST", "/api/settings"): h_settings_post,
("GET", "/api/logs"): h_logs, ("GET", "/api/logs"): h_logs,
+9
View File
@@ -326,6 +326,14 @@ def test_account(account_id):
if ok: if ok:
out["refresh"] = msg out["refresh"] = msg
# 探测两个站点,判断 token 属于哪个域(两站 Token 不互通) # 探测两个站点,判断 token 属于哪个域(两站 Token 不互通)
# 风控身份(国际版拿活动的前提)
tok, code, mtype, rerr = qoder.risk_identity(acc)
out["risk_identity"] = {
"ok": bool(tok),
"error": rerr,
"machineToken": (tok[:12] + "…") if tok else None,
}
_reload(acc)
probe = qoder.probe_domains(acc) probe = qoder.probe_domains(acc)
out["domain_probe"] = probe out["domain_probe"] = probe
current = (acc.get("auth") or {}).get("domain") current = (acc.get("auth") or {}).get("domain")
@@ -338,6 +346,7 @@ def test_account(account_id):
if ok_domain and ok_domain != current: if ok_domain and ok_domain != current:
out["hint"] = "Token 属于 %s,但当前域名是 %s,请切换" % (ok_domain, current) out["hint"] = "Token 属于 %s,但当前域名是 %s,请切换" % (ok_domain, current)
campaigns, err = qoder.query_campaigns(acc) campaigns, err = qoder.query_campaigns(acc)
out["campaigns_debug"] = qoder.campaigns_debug(acc)
if err: if err:
out["error"] = err out["error"] = err
else: else:
+115 -8
View File
@@ -26,6 +26,7 @@ import json
import time import time
from common import http_post_json, http_get, log, now_ts from common import http_post_json, http_get, log, now_ts
import umid
CN_BASE = "https://openapi.qoder.com.cn" CN_BASE = "https://openapi.qoder.com.cn"
GLOBAL_BASE = "https://openapi.qoder.sh" GLOBAL_BASE = "https://openapi.qoder.sh"
@@ -35,16 +36,18 @@ PATH_REFRESH = "/api/v1/deviceToken/refresh"
REFRESH_MARGIN = 72 * 3600 # refresh 72h before expiry (client behaviour) REFRESH_MARGIN = 72 * 3600 # refresh 72h before expiry (client behaviour)
# 客户端默认版本(社区实现实测值:缺 Cosy-Version 时 campaigns 可能返回空数组)
DEFAULT_VERSION = "0.3.4"
DEVICE_DEFAULTS = { DEVICE_DEFAULTS = {
"clientType": "10", "clientType": "10",
"version": "", # 不默认发送,用户可在设备标识里按客户端实际版本填 "version": DEFAULT_VERSION,
"machineOS": "", "machineOS": "",
"machineHostname": "", "machineHostname": "",
"machineId": "", "machineId": "",
"machineToken": "", "machineToken": "",
"machineCode": "", "machineCode": "",
"machineType": "", "machineType": "",
"version": "",
} }
@@ -72,10 +75,30 @@ def device_headers(account):
} }
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): def _headers(account):
h = {"Accept": "application/json", "Accept-Language": "zh-CN", "User-Agent": "Qoder"} 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(): for k, v in device_headers(account).items():
if v: if v:
h[k] = v h[k] = v
@@ -205,19 +228,96 @@ def query_campaigns(account):
return (campaigns if isinstance(campaigns, list) else []), None 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): def _benefits(campaigns):
"""CLAIM_BENEFIT campaigns; prefer kind == CREDITS when the field exists.""" """CLAIM_BENEFIT campaigns(活动/签到坑位)。
注意:真实数据里 benefit 可能只有 kind 没有 amount(活动信息在别处),
所以只按 actionType 过滤;kind 明确非 CREDITS 时才排除。
"""
out = [] out = []
for c in campaigns: for c in campaigns:
if not (isinstance(c, dict) and c.get("actionType") == "CLAIM_BENEFIT"): if not isinstance(c, dict):
continue continue
action = c.get("actionType")
claim_status = c.get("claimStatus")
benefit = c.get("benefit") benefit = c.get("benefit")
if isinstance(benefit, dict) and benefit.get("kind") and benefit["kind"] != "CREDITS": 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 continue
out.append(c) out.append(c)
return out 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" USAGE_PATH = "/sash/api/v2/me/usage"
@@ -302,6 +402,11 @@ def checkin_once(account):
if err: if err:
return {"status": "fail", "message": err, "points": 0} 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) balance, berr = query_balance(account)
if berr == "no_session": if berr == "no_session":
return {"status": "no_session", return {"status": "no_session",
@@ -342,7 +447,9 @@ def checkin_once(account):
return {"status": "already", "message": "今日已领取", "points": 0, return {"status": "already", "message": "今日已领取", "points": 0,
"credits": balance} "credits": balance}
if not benefits: if not benefits:
return {"status": "already", "message": "活动列表为空(活动结束或未开放)", "points": 0} # 没有任何可识别活动:可能是活动未下发/已结束,也可能是 schema 变化
return {"status": "already",
"message": "未发现可领取活动(活动未下发/已结束,或需刷新风控身份)", "points": 0}
return {"status": "retry_later", "message": "活动未下发,下个时段再试", "points": 0} return {"status": "retry_later", "message": "活动未下发,下个时段再试", "points": 0}
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Qoder UMID 设备风控身份(runtime-info)管理器。
背景:Qoder 服务端只向携带 Cosy-MachineToken / Cosy-MachineCode /
Cosy-MachineType 的请求下发「每天领 100 Credits」活动(社区实测,国际版尤其严格)。
这三个值由 Qoder 官方 UMID 程序 runtime-info 生成——桌面客户端自带,
官方 CLI 包 @qoder-ai/qodercli 也把各平台二进制以 base64 内嵌在 bundle/qodercli.js 中。
本模块在 NAS(Linux x86_64 / arm64)上下载官方 npm 包 → 提取对应架构的 ELF →
试跑一次确认可产出身份 → 缓存到应用数据目录(umid/)。
调用方式与客户端一致:
runtime-info <env> [--account-stdin],stdin 传入 {"account": "<uid>"}
env: 国际版 4(SINGAPORE)/ 国内版 0(ONLINE)
第一行输出 JSON:{"machineToken","machineCode","machineType","vmInfo"...}
安全性:只从官方 npm registry 下载官方包,二进制仅本机使用,不上传任何信息。
"""
import base64
import json
import os
import re
import stat
import subprocess
import sys
import time
import urllib.request
import ssl
from common import DATA_DIR, log, USER_AGENT
PKG = "@qoder-ai/qodercli"
REGISTRIES = [
"https://registry.npmmirror.com",
"https://registry.npmjs.org",
]
BUNDLE_ENTRY = "package/bundle/qodercli.js"
# ELF e_machine: x86_64=62, arm64=183
ELF_MACHINE = {"x86_64": 62, "aarch64": 183, "arm64": 183}
RISK_ENV = {"qoder": 4, "qoder-cn": 0} # 国际版 / 国内版
_SSL_CTX = ssl.create_default_context()
_SSL_CTX.check_hostname = False
_SSL_CTX.verify_mode = ssl.CERT_NONE
CACHE_DIR = os.path.join(DATA_DIR, "umid")
BIN_PATH = os.path.join(CACHE_DIR, "runtime-info")
MANIFEST = os.path.join(CACHE_DIR, "manifest.json")
# ---------------------------------------------------------------------------
# 缓存
# ---------------------------------------------------------------------------
def installed():
"""已安装的 runtime-info:{path, version, arch, sha256, installedAt} 或 None."""
try:
m = json.load(open(MANIFEST, encoding="utf-8"))
if not os.path.isfile(BIN_PATH) or m.get("arch") != _arch():
return None
return dict(m, path=BIN_PATH)
except (OSError, ValueError):
return None
def _arch():
machine = os.uname().machine
return {"x86_64": "x86_64", "amd64": "x86_64",
"aarch64": "aarch64", "arm64": "aarch64"}.get(machine, machine)
# ---------------------------------------------------------------------------
# 下载 / 提取
# ---------------------------------------------------------------------------
def _fetch(url, timeout=180, binary=True):
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as r:
return r.read()
def _registry_meta(registry):
meta = json.loads(_fetch(registry + "/" + PKG.replace("/", "%2F") + "/latest", 30).decode())
return meta
def _extract_tar_entry(tar, wanted):
"""最小 tar 解析:按名字取一个普通文件内容。"""
off = 0
long_name = None
while off + 512 <= len(tar):
h = tar[off:off + 512]
if not any(h):
break
name = h[:100].split(b"\0")[0].decode("utf-8", "replace")
size = int((h[124:136].split(b"\0")[0].strip() or b"0"), 8)
typeflag = h[156:157]
prefix = h[345:500].split(b"\0")[0].decode("utf-8", "replace")
full = (prefix + "/" + name) if prefix else name
if typeflag == b"L": # GNU long name
long_name = tar[off + 512:off + 512 + size].split(b"\0")[0].decode("utf-8", "replace")
elif typeflag in (b"0", b"\0"):
real = long_name or full
long_name = None
if real == wanted:
return tar[off + 512:off + 512 + size]
off += 512 + ((size + 511) // 512) * 512
return None
def _find_elf(bundle, arch):
"""在 bundle 里找指定架构的 ELF(base64 内嵌)。"""
want = ELF_MACHINE.get(arch)
if not want:
raise RuntimeError("不支持的 CPU 架构:%s" % arch)
for m in re.finditer(rb"[A-Za-z0-9+/]{100000,}", bundle):
try:
raw = base64.b64decode(m.group(0))
except Exception:
continue
if raw[:4] != b"\x7fELF" or len(raw) < 18:
continue
machine = int.from_bytes(raw[18:20], "little")
if machine == want:
return raw
raise RuntimeError("官方包中未找到 %s 架构的 UMID 二进制" % arch)
def install(force=False):
"""下载官方 qodercli 包并提取 runtime-info。返回 (ok, message)."""
if not force and installed():
return True, "已安装"
os.makedirs(CACHE_DIR, exist_ok=True)
arch = _arch()
if arch not in ELF_MACHINE:
return False, "NAS CPU 架构 %s 暂不支持(仅 x86_64 / arm64)" % arch
last_err = None
for registry in REGISTRIES:
try:
log("umid: fetching %s from %s" % (PKG, registry), "INFO")
meta = _registry_meta(registry)
version = meta.get("version", "")
tarball = (meta.get("dist") or {}).get("tarball") or ""
if not tarball:
raise RuntimeError("registry 未返回 tarball")
log("umid: downloading %s %s" % (PKG, version), "INFO")
tgz = _fetch(tarball, timeout=300)
# gzip 解包
import gzip
tar = gzip.decompress(tgz)
bundle = _extract_tar_entry(tar, BUNDLE_ENTRY)
if not bundle:
raise RuntimeError("包内未找到 %s" % BUNDLE_ENTRY)
elf = _find_elf(bundle, arch)
tmp = BIN_PATH + ".tmp"
with open(tmp, "wb") as f:
f.write(elf)
os.chmod(tmp, os.stat(tmp).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
os.replace(tmp, BIN_PATH)
import hashlib
sha = hashlib.sha256(elf).hexdigest()
json.dump({"version": version, "arch": arch, "sha256": sha,
"installedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
"registry": registry},
open(MANIFEST, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
log("umid: installed %s (arch=%s sha256=%s)" % (version, arch, sha[:12]), "INFO")
return True, "已安装 %s(%s)" % (version, arch)
except Exception as e: # 尝试下一个 registry
last_err = "%s: %s" % (registry, e)
log("umid: registry %s failed: %s" % (registry, e), "WARN")
return False, "下载失败(%s)" % last_err
# ---------------------------------------------------------------------------
# 调用
# ---------------------------------------------------------------------------
def risk_identity(provider="qoder", account="", force_install=False):
"""生成设备风控身份。返回 ({"machineToken","machineCode","machineType"}, error).
provider: "qoder"(国际版 env=4)/ "qoder-cn"(国内版 env=0)
"""
info = installed()
if not info or force_install:
ok, msg = install(force=force_install)
if not ok:
return None, msg
info = installed()
env = str(RISK_ENV.get(provider, 0))
try:
stdin_data = json.dumps({"account": str(account or "")}) + "\n"
p = subprocess.run([BIN_PATH, env, "--account-stdin"],
input=stdin_data, capture_output=True, text=True, timeout=30)
if p.returncode != 0 or not p.stdout.strip():
return None, "runtime-info 执行失败(退出码 %s)%s" % (p.returncode, (p.stderr or "")[:120])
line = p.stdout.strip().split("\n")[0]
data = json.loads(line)
out = {}
for k in ("machineToken", "machineCode", "machineType"):
v = data.get(k)
if not isinstance(v, str) or not v.strip():
return None, "runtime-info 输出缺少 %s" % k
out[k] = v.strip()
if data.get("vmInfo"):
out["vmInfo"] = data["vmInfo"]
return out, None
except subprocess.TimeoutExpired:
return None, "runtime-info 执行超时"
except Exception as e:
return None, "runtime-info 调用异常:%s" % e
def status():
"""供 UI 展示的状态。"""
info = installed()
return {
"installed": bool(info),
"path": BIN_PATH,
"version": (info or {}).get("version"),
"arch": (info or {}).get("arch"),
"installedAt": (info or {}).get("installedAt"),
"supported": _arch() in ELF_MACHINE,
"cpu_arch": _arch(),
}
+52 -1
View File
@@ -209,7 +209,8 @@
"<td>" + (a.enabled ? "启用" : "禁用") + "</td>" + "<td>" + (a.enabled ? "启用" : "禁用") + "</td>" +
'<td class="actions">' + '<td class="actions">' +
'<button class="btn btn-sm" data-a2="run" data-id="' + esc(a.id) + '">签到</button>' + '<button class="btn btn-sm" data-a2="run" data-id="' + esc(a.id) + '">签到</button>' +
(a.platform === "qoder" ? '<button class="btn btn-sm" data-a2="fixdomain" data-id="' + esc(a.id) + '" title="自动探测 Token 所属站点并切换">修复站点</button>' : "") + (a.platform === "qoder" ? '<button class="btn btn-sm" data-a2="fixdomain" data-id="' + esc(a.id) + '" title="自动探测 Token 所属站点并切换">修复站点</button>' +
'<button class="btn btn-sm" data-a2="risk" data-id="' + esc(a.id) + '" title="重新生成设备风控身份(活动列表为空时使用)">刷新风控</button>' : "") +
'<button class="btn btn-sm" data-a2="toggle" data-id="' + esc(a.id) + '">' + (a.enabled ? "禁用" : "启用") + "</button>" + '<button class="btn btn-sm" data-a2="toggle" data-id="' + esc(a.id) + '">' + (a.enabled ? "禁用" : "启用") + "</button>" +
'<button class="btn btn-sm btn-danger" data-a2="del" data-id="' + esc(a.id) + '">删除</button>' + '<button class="btn btn-sm btn-danger" data-a2="del" data-id="' + esc(a.id) + '">删除</button>' +
"</td></tr>"; "</td></tr>";
@@ -219,6 +220,16 @@
btn.addEventListener("click", function () { btn.addEventListener("click", function () {
var id = btn.dataset.id, act = btn.dataset.a2; var id = btn.dataset.id, act = btn.dataset.a2;
if (act === "run") runCheckin(id, btn); if (act === "run") runCheckin(id, btn);
else if (act === "risk") {
btn.disabled = true; btn.textContent = "生成中…";
api("POST", "/accounts/refresh_risk", { id: id })
.then(function (d) {
if (d.ok) { toast("风控身份已更新", "ok"); refreshState(); }
else { toast(d.error || "生成失败", "err"); }
})
.catch(function (e) { toast(e.message, "err"); })
.finally(function () { btn.disabled = false; btn.textContent = "刷新风控"; });
}
else if (act === "fixdomain") { else if (act === "fixdomain") {
btn.disabled = true; btn.textContent = "探测中…"; btn.disabled = true; btn.textContent = "探测中…";
api("POST", "/accounts/fix_domain", { id: id }) api("POST", "/accounts/fix_domain", { id: id })
@@ -398,12 +409,52 @@
var settings = null; var settings = null;
var settingsDirty = false; var settingsDirty = false;
function loadUmidStatus() {
api("GET", "/umid/status").then(function (d) {
var el = $("umid-status");
if (!el) return;
if (!d.supported) {
el.textContent = "当前 CPU 架构 " + d.cpu_arch + " 暂不支持自动生成(需 x86_64 / arm64)";
} else if (d.installed) {
el.textContent = "✅ 已安装 v" + d.version + "(" + d.arch + ",安装于 " + (d.installedAt || "-") + ")";
} else {
el.textContent = "⚠️ 未安装 —— 点击下方按钮下载(约 30MB)";
}
}).catch(function () {});
}
var umidBtn = $("btn-umid-install");
if (umidBtn) {
umidBtn.addEventListener("click", function () {
umidBtn.disabled = true;
umidBtn.textContent = "下载中(约 1-3 分钟)…";
$("umid-msg").textContent = "";
$("umid-msg").className = "msg";
api("POST", "/umid/install", { force: true })
.then(function (d) {
$("umid-msg").textContent = d.message || (d.ok ? "已安装" : "失败");
$("umid-msg").className = "msg " + (d.ok ? "ok" : "err");
toast(d.ok ? "风控组件已就绪" : (d.message || "下载失败"), d.ok ? "ok" : "err");
loadUmidStatus();
})
.catch(function (e) {
$("umid-msg").textContent = e.message;
$("umid-msg").className = "msg err";
})
.finally(function () {
umidBtn.disabled = false;
umidBtn.textContent = "下载 / 重新获取风控组件";
});
});
}
function openSettingsTab() { function openSettingsTab() {
if (!settingsDirty) { if (!settingsDirty) {
settings = JSON.parse(JSON.stringify( settings = JSON.parse(JSON.stringify(
(state && state.settings) || { schedule_times: [], webhook_url: "", notify_on: [] })); (state && state.settings) || { schedule_times: [], webhook_url: "", notify_on: [] }));
} }
renderSettings(); renderSettings();
loadUmidStatus();
} }
function renderSettings() { function renderSettings() {
+12
View File
@@ -81,6 +81,15 @@
<button class="btn" id="btn-add-time">添加时间点</button> <button class="btn" id="btn-add-time">添加时间点</button>
</div> </div>
</div> </div>
<div class="panel">
<h2>Qoder 风控身份(UMID)</h2>
<p class="muted">Qoder 国际版只对带设备风控身份的请求下发签到活动。首次使用需下载官方组件(约 30MB,仅一次)。</p>
<div id="umid-status" class="muted">检测中…</div>
<div class="row">
<button class="btn" id="btn-umid-install">下载 / 重新获取风控组件</button>
</div>
<div id="umid-msg" class="msg"></div>
</div>
<div class="panel"> <div class="panel">
<h2>消息通知(可选)</h2> <h2>消息通知(可选)</h2>
<p class="muted">签到后向 Webhook 发送 POST JSON(<code>{"title","content","text","msgtype"}</code>),兼容企业微信/钉钉/飞书/Bark 等机器人。留空则不通知。</p> <p class="muted">签到后向 Webhook 发送 POST JSON(<code>{"title","content","text","msgtype"}</code>),兼容企业微信/钉钉/飞书/Bark 等机器人。留空则不通知。</p>
@@ -188,6 +197,9 @@
<p><b>方式 C · 本机客户端提取</b></p> <p><b>方式 C · 本机客户端提取</b></p>
<p>在装了并登录 Qoder 客户端的 Windows 上运行社区工具 <code>01_extract.py</code>(GitHub: wallechfox/qoder-checkin),自动解密 <code>auth.v1.dat</code> 取出 Token 和设备标识。</p> <p>在装了并登录 Qoder 客户端的 Windows 上运行社区工具 <code>01_extract.py</code>(GitHub: wallechfox/qoder-checkin),自动解密 <code>auth.v1.dat</code> 取出 Token 和设备标识。</p>
<p class="muted">Token 有效期较短,过期后重新获取即可;填了 Refresh Token 的应用会自动续期。</p> <p class="muted">Token 有效期较短,过期后重新获取即可;填了 Refresh Token 的应用会自动续期。</p>
<p><b>关于风控身份(重要)</b></p>
<p>Qoder 服务端只对携带设备风控身份(<code>Cosy-MachineToken/Code/Type</code>)的请求下发「每天领 100 Credits」活动,否则活动列表为空。</p>
<p>本应用会自动从官方 npm 包 <code>@qoder-ai/qodercli</code> 提取 UMID 组件并生成身份(首次约需下载 30MB,之后缓存复用)。可在「设置 → Qoder 风控身份」查看/重新获取。</p>
</div> </div>
</details> </details>
<details class="help"> <details class="help">
+1 -1
View File
@@ -1,5 +1,5 @@
appname = AgentCheckin appname = AgentCheckin
version = 1.2.2 version = 1.3.3
display_name = Agent 签到助手 display_name = Agent 签到助手
desc = AI 编程工具每日积分自动签到:支持 TRAE(字节跳动,国内版每日 200 积分)与 WorkBuddy / CodeBuddy(腾讯,7 天循环签到最高 1000 积分/天)。多账号管理、多时段自动补签、积分看板、签到日志、Webhook 通知,数据全部保存在 NAS 本地。 desc = AI 编程工具每日积分自动签到:支持 TRAE(字节跳动,国内版每日 200 积分)与 WorkBuddy / CodeBuddy(腾讯,7 天循环签到最高 1000 积分/天)。多账号管理、多时段自动补签、积分看板、签到日志、Webhook 通知,数据全部保存在 NAS 本地。
platform = all platform = all
+9 -3
View File
@@ -86,7 +86,9 @@ Token 约 60 天有效,过期后在面板重新粘贴即可。
> 若同时安装了国内版和国际版客户端,`01_extract.py` 会解出两套 Token—— > 若同时安装了国内版和国际版客户端,`01_extract.py` 会解出两套 Token——
> 签到时用**国内版**那套(qoder.cn / QQ 邮箱账号),并在面板选国内站域名。 > 签到时用**国内版**那套(qoder.cn / QQ 邮箱账号),并在面板选国内站域名。
> Qoder Token 有效期较短(约两周),过期后重新获取粘贴即可;填了 Refresh Token 后应用会自动续期。 > Qoder Token 有效期较短(约两周),过期后重新获取粘贴即可;填了 Refresh Token 后应用会自动续期。
> 若领取时提示风控,再用方式 C 提取 Cosy-* 设备标识填到账号的「设备标识」栏。 > **风控身份自动处理**:应用会从官方 `@qoder-ai/qodercli` 提取 UMID 组件自动生成
> Cosy-* 风控身份(首次约下载 30MB,之后缓存)。可在 设置 → Qoder 风控身份 查看状态、
> 或在账号列表点「刷新风控」重新生成。
## 使用说明 ## 使用说明
@@ -159,12 +161,16 @@ fnos-agent-checkin/
请求需带 `Authorization: Bearer <token>` 与 Cosy-* 设备标识头。 请求需带 `Authorization: Bearer <token>` 与 Cosy-* 设备标识头。
> ⚠️ **两个重要实测结论(2026-10-09 验证)**: > ⚠️ **重要实测结论**:
> 1. **Token 格式**:Qoder 的 `dt-`/`drt-` Token 原生就只有 **27/28 个字符**,不是截断。 > 1. **Token 格式**:Qoder 的 `dt-`/`drt-` Token 原生就只有 **27/28 个字符**,不是截断。
> 2. **站点隔离**:国内版(qoder.cn 客户端)的 Token 只能用于 `openapi.qoder.com.cn`, > 2. **站点隔离**:国内版(qoder.cn 客户端)的 Token 只能用于 `openapi.qoder.com.cn`,
> 国际版(qoder.com 客户端)的 Token 只能用于 `openapi.qoder.sh`,混用直接 401。 > 国际版(qoder.com 客户端)的 Token 只能用于 `openapi.qoder.sh`,混用直接 401。
> 面板的「修复站点」按钮会自动探测 Token 所属站点并切换。 > 面板的「修复站点」按钮会自动探测 Token 所属站点并切换。
> 3. **国际版无签到活动**:只有国内版有每日 100 Credits 签到,国际版账号活动列表为空。 > 3. **国际版需要设备风控身份**:国际版服务端**只对携带 `Cosy-MachineToken/Code/Type` 的请求下发
> 「每天领 100 Credits」活动**,否则活动列表返回空。本应用会自动从官方 npm 包
> `@qoder-ai/qodercli` 提取 UMID 组件(runtime-info)生成身份,无需手动配置。
> 4. **国际版活动 schema 与国内版不同**:国际版 `actionType=VIEW_DETAILS`(国内版 `CLAIM_BENEFIT`),
> 但 `claimStatus`/`startAt`/`endAt` 一致;每日 10:00 (UTC+8) 刷新,领取后 30 天有效。
**TRAE(api.trae.cn)** **TRAE(api.trae.cn)**
+1 -1
View File
@@ -27,7 +27,7 @@ const BASE = `https://${HOST}:${PORT}`;
const USER = process.env.FNOS_USER; const USER = process.env.FNOS_USER;
const PASS = process.env.FNOS_PASS; const PASS = process.env.FNOS_PASS;
const APPNAME = "AgentCheckin"; const APPNAME = "AgentCheckin";
const VERSION = "1.2.2"; const VERSION = "1.3.3";
const FPK = process.argv[2] || const FPK = process.argv[2] ||
path.join(__dirname, "AgentCheckin", "AgentCheckin.fpk"); path.join(__dirname, "AgentCheckin", "AgentCheckin.fpk");
+25 -3
View File
@@ -216,7 +216,10 @@ def main():
"print('exp:', qoder.jwt_exp(tok), 'uid:', qoder.jwt_uid(tok))\n" "print('exp:', qoder.jwt_exp(tok), 'uid:', qoder.jwt_uid(tok))\n"
"# global detection\n" "# global detection\n"
"print('global:', qoder.is_global({'domain': 'openapi.qoder.sh'}), qoder.is_global({'domain': 'openapi.qoder.com.cn'}))\n" "print('global:', qoder.is_global({'domain': 'openapi.qoder.sh'}), qoder.is_global({'domain': 'openapi.qoder.com.cn'}))\n"
"# campaign parsing: claimable -> claim flow mocked\n" "# 国际版 schema: actionType=VIEW_DETAILS + claimStatus\n"
"intl = [{'actionType': 'VIEW_DETAILS', 'claimStatus': 'CLAIMABLE', 'campaignId': 'i1'}]\n"
"print('intl_benefits:', len(qoder._benefits(intl)))\n"
"# 国内版 schema\n"
"campaigns = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMABLE', 'campaignId': 'c1', 'benefit': {'amount': 100}}]\n" "campaigns = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMABLE', 'campaignId': 'c1', 'benefit': {'amount': 100}}]\n"
"b = qoder._benefits(campaigns)\n" "b = qoder._benefits(campaigns)\n"
"print('benefits:', len(b), 'claimable:', len([c for c in b if c.get('claimStatus') == 'CLAIMABLE']))\n" "print('benefits:', len(b), 'claimable:', len([c for c in b if c.get('claimStatus') == 'CLAIMABLE']))\n"
@@ -230,8 +233,10 @@ def main():
check("qoder device headers", lines and lines[0] == "clientType=10 machineId=MID version=1.2.3 emptyToken=''", out + err[-200:]) check("qoder device headers", lines and lines[0] == "clientType=10 machineId=MID version=1.2.3 emptyToken=''", out + err[-200:])
check("qoder jwt helpers", len(lines) > 1 and lines[1] == "exp: 9999999999 uid: u123", out) check("qoder jwt helpers", len(lines) > 1 and lines[1] == "exp: 9999999999 uid: u123", out)
check("qoder global detection", len(lines) > 2 and lines[2] == "global: True False", out) check("qoder global detection", len(lines) > 2 and lines[2] == "global: True False", out)
check("qoder campaign parsing", len(lines) > 3 and lines[3] == "benefits: 1 claimable: 1", out) check("qoder campaign parsing", "benefits: 1 claimable: 1" in out, out)
check("qoder already-claimed detection", len(lines) > 4 and lines[4] == "claimed_today: 1", out) intl_line = next((l for l in lines if l.startswith("intl_benefits:")), "")
check("qoder intl schema (VIEW_DETAILS)", intl_line == "intl_benefits: 1", out)
check("qoder already-claimed detection", "claimed_today: 1" in out, out)
print("== 13. engine: add qoder account + token masking ==") print("== 13. engine: add qoder account + token masking ==")
rc, out, err = run_py( rc, out, err = run_py(
@@ -245,6 +250,23 @@ def main():
check("engine add qoder", rc == 0 and "qoder_" in out and "MID" in out, out + err[-200:]) check("engine add qoder", rc == 0 and "qoder_" in out and "MID" in out, out + err[-200:])
check("qoder token masked in dashboard", "masked: True" in out, out) check("qoder token masked in dashboard", "masked: True" in out, out)
print("== 14. umid module (offline) ==")
rc, out, err = run_py(
"import umid\n"
"st = umid.status()\n"
"print('supported:', st['supported'], '| arch:', st['cpu_arch'])\n"
"print('installed:', st['installed'])\n"
"import io, tarfile\n"
"buf = io.BytesIO()\n"
"with tarfile.open(fileobj=buf, mode='w') as tf:\n"
" info = tarfile.TarInfo('package/bundle/qodercli.js'); info.size = 5\n"
" tf.addfile(info, io.BytesIO(b'hello'))\n"
"print('tar_extract_ok:', umid._extract_tar_entry(buf.getvalue(), 'package/bundle/qodercli.js') == b'hello')\n"
)
lines = out.splitlines() if rc == 0 else []
check("umid: arch supported", len(lines) > 0 and "supported: True" in lines[0], out + err[-200:])
check("umid: tar extraction", len(lines) > 2 and "tar_extract_ok: True" in lines[2], out)
print() print()
print("PASSED: %d FAILED: %d" % (len(PASS), len(FAIL))) print("PASSED: %d FAILED: %d" % (len(PASS), len(FAIL)))
if FAIL: if FAIL: