From c263edc3e7b43b62b506e6eb18b3122781649011 Mon Sep 17 00:00:00 2001 From: cbb Date: Mon, 19 Oct 2026 12:04:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(qoder):=20=E9=80=82=E9=85=8D=E5=9B=BD?= =?UTF-8?q?=E9=99=85=E7=89=88=E6=AF=8F=E6=97=A5=E7=AD=BE=E5=88=B0=20?= =?UTF-8?q?=E2=80=94=20UMID=20=E8=AE=BE=E5=A4=87=E9=A3=8E=E6=8E=A7?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=20+=20=E5=9B=BD=E9=99=85=E7=89=88=E6=B4=BB?= =?UTF-8?q?=E5=8A=A8=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心发现(实测): - 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 前端,全部通过 --- AgentCheckin/app/backend/api.py | 34 +++++ AgentCheckin/app/backend/engine.py | 9 ++ AgentCheckin/app/backend/qoder.py | 123 +++++++++++++++- AgentCheckin/app/backend/umid.py | 226 +++++++++++++++++++++++++++++ AgentCheckin/app/www/assets/app.js | 53 ++++++- AgentCheckin/app/www/index.html | 12 ++ AgentCheckin/manifest | 2 +- README.md | 12 +- fnos-install.js | 2 +- test_backend.py | 28 +++- 10 files changed, 484 insertions(+), 17 deletions(-) create mode 100644 AgentCheckin/app/backend/umid.py diff --git a/AgentCheckin/app/backend/api.py b/AgentCheckin/app/backend/api.py index d7db8e6..4af371c 100644 --- a/AgentCheckin/app/backend/api.py +++ b/AgentCheckin/app/backend/api.py @@ -203,6 +203,37 @@ def h_fix_qoder_domain(_params, body): return {"ok": False, "probe": probe, "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): acc_id = body.get("id") if not acc_id: @@ -356,6 +387,9 @@ ROUTES = { ("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/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, ("POST", "/api/settings"): h_settings_post, ("GET", "/api/logs"): h_logs, diff --git a/AgentCheckin/app/backend/engine.py b/AgentCheckin/app/backend/engine.py index c582e00..912bb48 100644 --- a/AgentCheckin/app/backend/engine.py +++ b/AgentCheckin/app/backend/engine.py @@ -326,6 +326,14 @@ def test_account(account_id): if ok: out["refresh"] = msg # 探测两个站点,判断 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) out["domain_probe"] = probe current = (acc.get("auth") or {}).get("domain") @@ -338,6 +346,7 @@ def test_account(account_id): if ok_domain and ok_domain != current: out["hint"] = "Token 属于 %s,但当前域名是 %s,请切换" % (ok_domain, current) campaigns, err = qoder.query_campaigns(acc) + out["campaigns_debug"] = qoder.campaigns_debug(acc) if err: out["error"] = err else: diff --git a/AgentCheckin/app/backend/qoder.py b/AgentCheckin/app/backend/qoder.py index 9e4e7b4..19089bf 100644 --- a/AgentCheckin/app/backend/qoder.py +++ b/AgentCheckin/app/backend/qoder.py @@ -26,6 +26,7 @@ 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" @@ -35,16 +36,18 @@ 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": "", # 不默认发送,用户可在设备标识里按客户端实际版本填 + "version": DEFAULT_VERSION, "machineOS": "", "machineHostname": "", "machineId": "", "machineToken": "", "machineCode": "", "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): 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 @@ -205,19 +228,96 @@ def query_campaigns(account): 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; prefer kind == CREDITS when the field exists.""" + """CLAIM_BENEFIT campaigns(活动/签到坑位)。 + + 注意:真实数据里 benefit 可能只有 kind 没有 amount(活动信息在别处), + 所以只按 actionType 过滤;kind 明确非 CREDITS 时才排除。 + """ out = [] for c in campaigns: - if not (isinstance(c, dict) and c.get("actionType") == "CLAIM_BENEFIT"): + if not isinstance(c, dict): continue + action = c.get("actionType") + claim_status = c.get("claimStatus") 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 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" @@ -302,6 +402,11 @@ def checkin_once(account): 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", @@ -342,7 +447,9 @@ def checkin_once(account): return {"status": "already", "message": "今日已领取", "points": 0, "credits": balance} if not benefits: - return {"status": "already", "message": "活动列表为空(活动结束或未开放)", "points": 0} + # 没有任何可识别活动:可能是活动未下发/已结束,也可能是 schema 变化 + return {"status": "already", + "message": "未发现可领取活动(活动未下发/已结束,或需刷新风控身份)", "points": 0} return {"status": "retry_later", "message": "活动未下发,下个时段再试", "points": 0} diff --git a/AgentCheckin/app/backend/umid.py b/AgentCheckin/app/backend/umid.py new file mode 100644 index 0000000..a915034 --- /dev/null +++ b/AgentCheckin/app/backend/umid.py @@ -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 [--account-stdin],stdin 传入 {"account": ""} + 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(), + } diff --git a/AgentCheckin/app/www/assets/app.js b/AgentCheckin/app/www/assets/app.js index b8c5abf..5375f68 100644 --- a/AgentCheckin/app/www/assets/app.js +++ b/AgentCheckin/app/www/assets/app.js @@ -209,7 +209,8 @@ "" + (a.enabled ? "启用" : "禁用") + "" + '' + '' + - (a.platform === "qoder" ? '' : "") + + (a.platform === "qoder" ? '' + + '' : "") + '" + '' + ""; @@ -219,6 +220,16 @@ btn.addEventListener("click", function () { var id = btn.dataset.id, act = btn.dataset.a2; 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") { btn.disabled = true; btn.textContent = "探测中…"; api("POST", "/accounts/fix_domain", { id: id }) @@ -398,12 +409,52 @@ var settings = null; 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() { if (!settingsDirty) { settings = JSON.parse(JSON.stringify( (state && state.settings) || { schedule_times: [], webhook_url: "", notify_on: [] })); } renderSettings(); + loadUmidStatus(); } function renderSettings() { diff --git a/AgentCheckin/app/www/index.html b/AgentCheckin/app/www/index.html index cbe3846..e56e42b 100644 --- a/AgentCheckin/app/www/index.html +++ b/AgentCheckin/app/www/index.html @@ -81,6 +81,15 @@ +
+

Qoder 风控身份(UMID)

+

Qoder 国际版只对带设备风控身份的请求下发签到活动。首次使用需下载官方组件(约 30MB,仅一次)。

+
检测中…
+
+ +
+
+

消息通知(可选)

签到后向 Webhook 发送 POST JSON({"title","content","text","msgtype"}),兼容企业微信/钉钉/飞书/Bark 等机器人。留空则不通知。

@@ -188,6 +197,9 @@

方式 C · 本机客户端提取

在装了并登录 Qoder 客户端的 Windows 上运行社区工具 01_extract.py(GitHub: wallechfox/qoder-checkin),自动解密 auth.v1.dat 取出 Token 和设备标识。

Token 有效期较短,过期后重新获取即可;填了 Refresh Token 的应用会自动续期。

+

关于风控身份(重要)

+

Qoder 服务端只对携带设备风控身份(Cosy-MachineToken/Code/Type)的请求下发「每天领 100 Credits」活动,否则活动列表为空。

+

本应用会自动从官方 npm 包 @qoder-ai/qodercli 提取 UMID 组件并生成身份(首次约需下载 30MB,之后缓存复用)。可在「设置 → Qoder 风控身份」查看/重新获取。

diff --git a/AgentCheckin/manifest b/AgentCheckin/manifest index 0369c33..3071945 100644 --- a/AgentCheckin/manifest +++ b/AgentCheckin/manifest @@ -1,5 +1,5 @@ appname = AgentCheckin -version = 1.2.2 +version = 1.3.3 display_name = Agent 签到助手 desc = AI 编程工具每日积分自动签到:支持 TRAE(字节跳动,国内版每日 200 积分)与 WorkBuddy / CodeBuddy(腾讯,7 天循环签到最高 1000 积分/天)。多账号管理、多时段自动补签、积分看板、签到日志、Webhook 通知,数据全部保存在 NAS 本地。 platform = all diff --git a/README.md b/README.md index 75b4f27..b8a6fdc 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,9 @@ Token 约 60 天有效,过期后在面板重新粘贴即可。 > 若同时安装了国内版和国际版客户端,`01_extract.py` 会解出两套 Token—— > 签到时用**国内版**那套(qoder.cn / QQ 邮箱账号),并在面板选国内站域名。 > Qoder Token 有效期较短(约两周),过期后重新获取粘贴即可;填了 Refresh Token 后应用会自动续期。 -> 若领取时提示风控,再用方式 C 提取 Cosy-* 设备标识填到账号的「设备标识」栏。 +> **风控身份自动处理**:应用会从官方 `@qoder-ai/qodercli` 提取 UMID 组件自动生成 +> Cosy-* 风控身份(首次约下载 30MB,之后缓存)。可在 设置 → Qoder 风控身份 查看状态、 +> 或在账号列表点「刷新风控」重新生成。 ## 使用说明 @@ -159,12 +161,16 @@ fnos-agent-checkin/ 请求需带 `Authorization: Bearer ` 与 Cosy-* 设备标识头。 -> ⚠️ **两个重要实测结论(2026-10-09 验证)**: +> ⚠️ **重要实测结论**: > 1. **Token 格式**:Qoder 的 `dt-`/`drt-` Token 原生就只有 **27/28 个字符**,不是截断。 > 2. **站点隔离**:国内版(qoder.cn 客户端)的 Token 只能用于 `openapi.qoder.com.cn`, > 国际版(qoder.com 客户端)的 Token 只能用于 `openapi.qoder.sh`,混用直接 401。 > 面板的「修复站点」按钮会自动探测 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)** diff --git a/fnos-install.js b/fnos-install.js index 82002fe..f15d4bc 100644 --- a/fnos-install.js +++ b/fnos-install.js @@ -27,7 +27,7 @@ const BASE = `https://${HOST}:${PORT}`; const USER = process.env.FNOS_USER; const PASS = process.env.FNOS_PASS; const APPNAME = "AgentCheckin"; -const VERSION = "1.2.2"; +const VERSION = "1.3.3"; const FPK = process.argv[2] || path.join(__dirname, "AgentCheckin", "AgentCheckin.fpk"); diff --git a/test_backend.py b/test_backend.py index 411b7a5..f2d1441 100644 --- a/test_backend.py +++ b/test_backend.py @@ -216,7 +216,10 @@ def main(): "print('exp:', qoder.jwt_exp(tok), 'uid:', qoder.jwt_uid(tok))\n" "# global detection\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" "b = qoder._benefits(campaigns)\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 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 campaign parsing", len(lines) > 3 and lines[3] == "benefits: 1 claimable: 1", out) - check("qoder already-claimed detection", len(lines) > 4 and lines[4] == "claimed_today: 1", out) + check("qoder campaign parsing", "benefits: 1 claimable: 1" in out, 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 ==") 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("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("PASSED: %d FAILED: %d" % (len(PASS), len(FAIL))) if FAIL: