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:
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user