diff --git a/docs/channels-parity.md b/docs/channels-parity.md index d9b409f..041e54c 100644 --- a/docs/channels-parity.md +++ b/docs/channels-parity.md @@ -44,7 +44,7 @@ |---|---|---|---| | qoder | qoder-wasm.ts (706) + qoder-envelope.ts (226) + qoder-machine.ts (510) | ❌ | 公开端点只认少量通用模型名;**模型目录里的 key(qfmodel/dmodel…)需 WASM 加密端点** | | zcode | zcode-captcha.ts (2119) + zcode-carrier*.ts | ❌ | 模型请求自 3.14.4 起**不再索要**验证码;**领取始终索要**(code 3007),且一次性 | -| gemini | gemini-sigstore.ts (268) | ❌ | thoughtSignature 缓存;缺失**不报错但可能悄悄降质** | +| gemini | gemini-sigstore.ts (268) | ⛔ 不适用 | **llmsproxy 里无处安放**:推理走 `internal/lua/adapters/gemini.lua`(无状态、请求级),插件不参与推理 —— 插件侧缓存会成为死代码。参考需要它是因为它自己代理推理。若将来要支持,位置是**适配器侧**(需适配器具备跨请求状态,与"适配器无状态"约定冲突,须单独决策) | | loomy | loomy-sign.ts | ✅ | 已对齐(仅账号端点用,唯一调用不带 query) | | 各平台 | captcha-*.ts (6 模块 1223 行) | ❌ | 验证码池/载体/退避 | diff --git a/internal/core/channels_qoder_claim_test.go b/internal/core/channels_qoder_claim_test.go new file mode 100644 index 0000000..d35b1f8 --- /dev/null +++ b/internal/core/channels_qoder_claim_test.go @@ -0,0 +1,222 @@ +package core + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "llmsproxy/internal/lua" +) + +// Qoder 活动领取有三条参考用**抓包 + 用户报障**定位过的「会误报」陷阱, +// 全部必须在判据里钉死: +// +// ① 「无可领活动」必须是 inactive,**不能**报 already-claimed +// (用户报障原文:「没领过却显示今日已领取,去 IDE 看还能领」); +// ② 刷新前(每日 10:00 UTC+8 之前)看到 CLAIMED 属于**昨天** → 必须报 inactive +// 而不是"今天已领取"(谎报且不可逆); +// ③ 未开通要给出 action_required 的可操作提示。 +// +// 另:`replayed:true` 是幂等判据(HTTP 仍是 200),只看状态码会把"今天已领" +// 误报成"成功 +100"。 +func TestQoderClaimDecisionTable(t *testing.T) { + type c struct { + name string + campaigns string + usage string + wantKind string + wantAction bool + wantClaimed int + } + cases := []c{ + { + "无可领且无 CLAIM_BENEFIT,addOnQuota 缺失→inactive+可操作提示", + `{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"VIEW_DETAILS","claimStatus":"CLAIMABLE"}]}`, + `{"data":{"userQuota":{"remaining":0}}}`, + "inactive", true, 0, + }, + { + "无可领但确实领过(CLAIMED)→ 视刷新时刻", + `{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMED"}]}`, + `{"data":{"addOnQuota":{"remaining":0}}}`, + "", false, 0, // 见下方:按当前 UTC+8 时刻断言二者之一 + }, + { + "可领→success(多个活动逐个领)", + `{"showCampaign":true,"claimable":true,"campaigns":[ + {"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}, + {"campaignId":"C2","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":50}}]}`, + "", + "success", false, 2, + }, + { + "可领但服务端回放 replayed→already-claimed(不是 success)", + `{"showCampaign":true,"claimable":true,"campaigns":[ + {"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`, + "", + "already-claimed", false, 1, + }, + { + "无任何活动且已开通(addOnQuota 存在)→inactive(不带可操作提示)", + `{"showCampaign":false,"claimable":false,"campaigns":[]}`, + `{"data":{"addOnQuota":{"total":100,"remaining":0}}}`, + "inactive", false, 0, + }, + { + "有 CLAIM_BENEFIT 但已过期→inactive", + `{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"EXPIRED"}]}`, + `{"data":{"addOnQuota":{"remaining":0}}}`, + "inactive", false, 0, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + claimed := 0 + var claimBody string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/sash/api/v1/me/campaigns": + fmt.Fprint(w, tc.campaigns) + case r.URL.Path == "/sash/api/v2/me/usage": + if tc.usage == "" { + w.WriteHeader(500) + return + } + fmt.Fprint(w, tc.usage) + case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") && + r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim": + claimed++ + b := make([]byte, 0) + if r.ContentLength > 0 { + b = make([]byte, r.ContentLength) + _, _ = r.Body.Read(b) + } + claimBody = string(b) + if tc.name == "可领但服务端回放 replayed→already-claimed(不是 success)" { + fmt.Fprint(w, `{"replayed":true,"status":"CLAIMED"}`) + } else { + fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`) + } + default: + w.WriteHeader(404) + } + })) + defer ts.Close() + + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "qoder", + "qoder_sash_base": map[string]interface{}{"url": ts.URL}, + }); err != nil { + t.Fatal(err) + } + cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"}) + body, _ := json.Marshal(map[string]string{ + "platform": "qoder", "credential": string(cred), "account": "t", + }) + if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{ + Method: "POST", Role: "admin", Body: string(body), + }); err != nil { + t.Fatal(err) + } + + out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{ + Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`, + }) + if err != nil { + t.Fatalf("claim_daily: %v", err) + } + j, _ := out.JSON.(map[string]interface{}) + oc, _ := j["outcome"].(map[string]interface{}) + if oc == nil { + t.Fatalf("应返回 outcome,实得 %v", j) + } + kind, _ := oc["kind"].(string) + + if tc.wantKind == "" { + // 刷新时刻决定 already-claimed 还是 inactive —— 两者都合法, + // 但**绝不能**是 success(今天是否领过不能靠"看到 CLAIMED"推断成可以再领)。 + if kind != "already-claimed" && kind != "inactive" { + t.Fatalf("刷新前/后都应报 already-claimed 或 inactive,实得 %q(%v)", kind, oc["message"]) + } + } else if kind != tc.wantKind { + t.Fatalf("kind 应为 %s,实得 %q(%v)", tc.wantKind, kind, oc["message"]) + } + if tc.wantAction && oc["action_required"] != true { + t.Errorf("未开通场景应带 action_required=true(提示去官方客户端),实得 %v", oc["action_required"]) + } + if claimed != tc.wantClaimed { + t.Errorf("claim 请求次数 = %d,期望 %d", claimed, tc.wantClaimed) + } + if tc.wantClaimed > 0 && claimBody != "" { + t.Errorf("claim 请求体必须是空串(抓包实测 content-length: 0),实得 %q", claimBody) + } + }) + } +} + +// 「今天的活动列表是否已刷新」必须用 **UTC+8 算术平移**判定。 +// ⚠️ 本机时区恰是 HKT(+0800),所以「用本机时区判」的实现在真实时刻下**结果相同** +// —— 那是判据不可达(这台机器上永远抓不到)。必须注入 UTC 时刻来覆盖两个方向: +// 判据的输入是 UTC 毫秒,与运行环境时区无关。 +func TestQoderRefreshUsesUTC8NotLocalTime(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + // UTC 01:00 = UTC+8 09:00 → **未刷新**(阈值 10:00) + // UTC 02:00 = UTC+8 10:00 → **已刷新** + // 若实现误用本机时区(HKT=UTC+8),对"UTC 毫秒"这个输入会得到: + // 把 07:00Z 当本地 15:00 → 判已刷新(错) + cases := []struct { + name string + utcHour int + want bool + }{ + {"UTC 01:00(UTC+8 09:00,未到 10:00)", 1, false}, + {"UTC 02:00(UTC+8 10:00,刚好刷新)", 2, true}, + {"UTC 00:30(UTC+8 08:30,未刷新)", 0, false}, + {"UTC 15:00(UTC+8 23:00,已刷新)", 15, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // 构造该 UTC 时刻的毫秒值(固定日期,避免跨日干扰) + // 用 time.Date 明确构造「00:00 UTC + N 小时」——手算 epoch 会引入 + // 时区/跨日误差(第一版就是这么错的:基准不是 UTC 零点)。 + ms := time.Date(2025, 10, 9, tc.utcHour, 0, 0, 0, time.UTC).UnixMilli() + var rec map[string]interface{} + got, err := ps.Call("channels", "__qoder_refresh_probe", map[string]interface{}{ + "now_ms": float64(ms), + }) + if err != nil { + t.Fatalf("probe: %v", err) + } + w, _ := got.(map[string]interface{}) + rec, _ = w["json"].(map[string]interface{}) + if rec == nil { + t.Fatalf("无 json: %#v", got) + } + if rec["refreshed"] != tc.want { + t.Fatalf("refreshed 应为 %v,实得 %v(UTC+8 阈值 10:00)", tc.want, rec["refreshed"]) + } + }) + } +} diff --git a/internal/core/mutation_guard_test.go b/internal/core/mutation_guard_test.go new file mode 100644 index 0000000..2959192 --- /dev/null +++ b/internal/core/mutation_guard_test.go @@ -0,0 +1,22 @@ +package core + +import ( + "os" + "testing" + "time" +) + +// 把测试进程的时区钉成 **UTC**。 +// +// 为什么必需:Qoder 的每日活动按 **UTC+8** 结算,判定必须用算术平移而不是 +// 本机时区。但这台机器**本身就是 HKT(+0800)** —— 于是在真实环境里 +// 「os.date("!*t")(UTC 平移)」与「os.date("*t")(本机时区)」结果**完全相同**, +// 判据无法区分二者(实测:把实现改成用本机时区,判据照样通过)。 +// 钉成 UTC 后两者相差 8 小时,误用本机时区的实现立刻暴露。 +// +// 只影响测试进程,不改系统时区。 +func TestMain(m *testing.M) { + os.Setenv("TZ", "UTC") + time.Local = time.UTC + os.Exit(m.Run()) +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 99bba9c..1205f5c 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe" } @@ -76,10 +76,13 @@ plugin.exports = { "set_endpoints", "account_add", "account_list", "account_upda -- 仅在运维自己的网关、自己的账号范围内使用;法律风险运维自负(已确认)。 -- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua -- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。 --- 前向声明:qoder 平台表里的 headers_fn 要在运行时调用它,而真实定义在 --- 文件后段。Lua 的 local 作用域看不到后面的定义 —— 必须在**使用点之前**声明 --- (此处就是 PLATFORMS 表之前),否则 headers_fn 里拿到 nil(真实踩过)。 -local qoderMachineIdentity +-- 前向声明:本文件里**所有**会被「更早定义的代码」引用的 local 助手都要在此 +-- 统一占坑。Lua 的 local 作用域只覆盖其后的代码 —— 漏一个就是运行时的 +-- "attempt to call global 'x' (a nil value)",而编译期完全静默(本文件已踩多次: +-- qoderMachineIdentity / rfc3986Escape / newToken / lobsteraiClientVersion / +-- traeCallback 各一次)。 +local qoderMachineIdentity, rfc3986Escape, newToken +local lobsteraiClientVersion local PLATFORMS = { -- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)===== @@ -1009,6 +1012,223 @@ local function codeartsClaim(plat, tok, args) return { done = true, kind = "success", message = msg } end +-- ---- Qoder 每日活动领取(照参考 qoder-credits.ts 逐行移植)---- +-- +-- 参考用**抓包 + 用户报障**定位过三条会「误报」的陷阱,全部在此钉死: +-- ① 「无可领活动」必须报 inactive,**不能**报 already-claimed。 +-- 旧实现 targets 为空就说"今天已领取" ⇒ 只要服务端没下发可领项 +-- (含请求头不完整 / 未到刷新时间 / 本就无活动)界面就谎报已领, +-- 与官方 IDE 的"可领取"直接矛盾(用户报障原文: +-- 「没领过却显示今日已领取,去 IDE 看还能领」)。 +-- ② 活动每日 **10:00(UTC+8)**刷新。刷新**之前**看到的 CLAIMED 属于 +-- **昨天** —— 此时说"今天已领取"是谎报,且不可逆(用户以为今天不必再领)。 +-- 必须用**算术平移**判 UTC+8,不能用本机时区(出差/改时区会判错)。 +-- ③ 未开通的账号要给出**可操作**提示(去官方客户端登录一次), +-- 而不是含糊的"没有可领取的活动"。 +-- 另:一个账号可能同时有**多个** CLAIM_BENEFIT 活动,逐个领取而非只领第一个。 + +-- 可被 set_endpoints 覆盖(测试指到假上游用)。 +-- 可被 set_endpoints 覆盖(测试指到假上游用)。 +QODER_SASH_BASE = "https://openapi.qoder.sh" + +-- UTC+8 偏移(毫秒)。活动按 UTC+8 结算,口径与参考一致。 +local QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000 +local QODER_REFRESH_HOUR_UTC8 = 10 + +-- hasQoderCampaignRefreshedToday():今天的活动列表是否已刷新。 +local function qoderCampaignRefreshed(nowMs) + -- nowMs 可注入(判据要覆盖"刷新前/刷新后"两个方向;本机恰是 UTC+8, + -- 只用真实时刻会让「用本机时区判」这个变异在这台机上不可判定 —— 判据不可达)。 + nowMs = nowMs or ((os.time and os.time() or 0) * 1000) + -- **必须用算术平移**判 UTC+8,不能用 os.date("*t")(本机时区): + -- 活动按 UTC+8 结算,取本机时区在出差/改时区时会判错方向 + -- (偏东提前把当天记为已处理、真漏领;偏西一天判两次)。 + local utc8 = os.date("!*t", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000)) + return (utc8.hour or 0) >= QODER_REFRESH_HOUR_UTC8 +end + +-- 读数字/字符串(容忍字符串形式与缺失),照参考 readNumber/readString +local function qReadNum(src, key) + if type(src) ~= "table" then return nil end + local v = src[key] + if type(v) == "number" then return v end + if type(v) == "string" and v ~= "" then return tonumber(v) end + return nil +end +local function qReadStr(src, key) + if type(src) ~= "table" then return nil end + local v = src[key] + if type(v) == "string" and v ~= "" then return v end + return nil +end + +-- parseQoderCampaigns:解析活动列表响应 +local function qoderParseCampaigns(body) + if type(body) ~= "table" or type(body.campaigns) ~= "table" then + -- 形状非法时仍可取 showCampaign/claimable(参考对非数组容忍) + if type(body) ~= "table" then return nil end + end + local out = { campaigns = {}, claimable = body.claimable == true, showCampaign = body.showCampaign == true } + for _, it in ipairs(body.campaigns or {}) do + local id = qReadStr(it, "campaignId") + if id then + out.campaigns[#out.campaigns + 1] = { + campaignId = id, + actionType = qReadStr(it, "actionType"), + claimStatus = qReadStr(it, "claimStatus"), + amount = qReadNum(it.benefit, "amount"), + } + end + end + return out +end + +-- qoderLoadCampaigns(plat, cred):拉活动列表,返回 (parsed, nil) 或 (nil, err) +local function qoderLoadCampaigns(plat, cred) + local hdrs = nil + if plat.headers_fn then + local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred) + if okh and type(h) == "table" then hdrs = h end + end + if not hdrs then + hdrs = { ["Authorization"] = "Bearer " .. tostring(cred.access_token or ""), + ["Accept"] = "application/json", ["Cosy-ClientType"] = "10", ["User-Agent"] = "Qoder" } + end + local resp, err = __llmsproxy_http({ + url = QODER_SASH_BASE .. "/sash/api/v1/me/campaigns", method = "GET", headers = hdrs, + }) + if err then return nil, "活动列表查询失败: " .. tostring(err) end + if not resp or resp.status ~= 200 then + return nil, "活动列表查询失败: HTTP " .. tostring(resp and resp.status) + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return nil, "活动列表响应无法解析" end + return qoderParseCampaigns(rec), nil +end + +-- qoderUsageRaw(plat, cred):取用量原始响应(判"未开通"用) +local function qoderUsageRaw(plat, cred) + local hdrs = nil + if plat.headers_fn then + local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred) + if okh and type(h) == "table" then hdrs = h end + end + local resp = nil + if hdrs then + resp, _ = __llmsproxy_http({ url = QODER_SASH_BASE .. "/sash/api/v2/me/usage", method = "GET", headers = hdrs }) + end + if not resp or resp.status ~= 200 then return nil end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return nil end + return rec +end + +-- qoderClaimCampaign(plat, cred, campaignId):领取单个活动。 +-- ⚠️ 幂等判据是响应体的 `replayed`,**不是** HTTP 状态码:重复领取同样回 200, +-- 但 replayed=true 且不含 benefit(只看状态码会把"今天已领"误报成"成功 +100")。 +-- ⚠️ 请求体必须是**空串**(抓包实测 content-length: 0)。 +local function qoderClaimCampaign(plat, cred, campaignId) + local hdrs = nil + if plat.headers_fn then + local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred) + if okh and type(h) == "table" then hdrs = h end + end + if not hdrs then hdrs = { ["Cosy-ClientType"] = "10" } end + hdrs["Content-Type"] = "application/json" + local resp, err = __llmsproxy_http({ + url = QODER_SASH_BASE .. "/sash/api/v1/me/campaigns/" + .. rfc3986Escape(campaignId) .. "/claim", + method = "POST", headers = hdrs, body = "", + }) + if err then return { kind = "failed", message = tostring(err) } end + if not resp or resp.status ~= 200 then + return { kind = "failed", message = "领取失败: HTTP " .. tostring(resp and resp.status) } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return { kind = "failed", message = "领取响应无法解析" } end + if rec.replayed == true then return { kind = "already-claimed", message = "今天已领取" } end + local st = rec.status + if st ~= nil and st ~= "CLAIMED" then + return { kind = "failed", message = "领取未成功(status=" .. tostring(st) .. ")" } + end + local amount = qReadNum(rec, "amount") or qReadNum(rec.benefit, "amount") or 0 + return { kind = "claimed", amount = amount } +end + +-- qoderClaim(plat, tok, args):完整活动领取(多活动逐个领)。 +local function qoderClaim(plat, tok, args) + local cred = (args and args.credential) or nil + if type(cred) ~= "table" then return nil, "缺少凭据对象" end + local parsed, lerr = qoderLoadCampaigns(plat, cred) + if not parsed then return { done = true, kind = "failed", message = lerr } end + + local refreshed = qoderCampaignRefreshed() + local targets = {} + for _, c in ipairs(parsed.campaigns) do + if c.actionType == "CLAIM_BENEFIT" and c.claimStatus == "CLAIMABLE" then + targets[#targets + 1] = c + end + end + + if #targets == 0 then + -- ① 领过?—— 但**仅限刷新之后**(刷新前那条 CLAIMED 属于昨天) + local claimedBefore = false + for _, c in ipairs(parsed.campaigns) do + if c.actionType == "CLAIM_BENEFIT" and c.claimStatus == "CLAIMED" then claimedBefore = true break end + end + if claimedBefore then + if not refreshed then + return { done = true, kind = "inactive", + message = "今天的每日活动尚未刷新(每日 10:00(UTC+8)刷新)," + .. "当前看到的是昨天那一轮,请稍后再来领取。" } + end + return { done = true, kind = "already-claimed", message = "今天已领取" } + end + -- ② 未开通?(两条判据同时满足,避免把"正在刷新"误报成未开通) + local usage = qoderUsageRaw(plat, cred) + local hasBenefit = false + for _, c in ipairs(parsed.campaigns) do + if c.actionType == "CLAIM_BENEFIT" then hasBenefit = true break end + end + local addOnMissing = false + if type(usage) == "table" then + -- ⚠️ 判「字段是否存在」而非「remaining 是否为 0」: + -- 后者对"额度用光"与"从未开通"不可区分。 + local d = type(usage.data) == "table" and usage.data or usage + addOnMissing = (d.addOnQuota == nil) + end + if (not hasBenefit) and addOnMissing then + return { done = true, kind = "inactive", action_required = true, + message = "该账号尚未在 Qoder 侧开通每日领取(每日 100 Credits)。" + .. "请先用 Qoder 官方客户端登录一次该账号,开通后再回来领取。" } + end + return { done = true, kind = "inactive", message = "当前没有可领取的活动" } + end + + -- 逐个领取 + local total, firstErr, anyClaimed, allReplayed = 0, nil, false, true + for _, t in ipairs(targets) do + local r = qoderClaimCampaign(plat, cred, t.campaignId) + if r.kind == "claimed" then + anyClaimed = true + allReplayed = false + total = total + (tonumber(r.amount) or 0) + elseif r.kind == "already-claimed" then + -- 保持 allReplayed(这个活动此前已领) + else + allReplayed = false + if not firstErr then firstErr = r.message end + end + end + if anyClaimed then + return { done = true, kind = "success", message = "已领取 " .. tostring(total) .. " Credits" } + end + if allReplayed then + return { done = true, kind = "already-claimed", message = "今天已领取" } + end + return { done = true, kind = "failed", message = firstErr or "领取失败" } +end + -- ---- 有道 LobsterAI 每日活动领取(照参考 lobsterai-credits.ts 逐行移植)---- -- -- 三步流程,判定顺序把「业务正常状态」与「真失败」严格分开: @@ -1141,14 +1361,6 @@ qoderMachineIdentity = function() return id end --- 前向声明:本段(活动领取)要用到的这几个助手都定义在文件后段。 --- Lua 的 local 作用域**看不到后面的定义**,不声明就是 --- "attempt to call global 'xxx' (a nil value)"(本文件已在这上面踩过多次: --- traeCallback / lobsteraiClientVersion / rfc3986Escape 各一次)。 --- 注意:json / newToken / hexToBin 之类的全局(内核注入或全局函数)不需要。 --- newToken 也在此列:幂等键要用它。 -local lobsteraiClientVersion, rfc3986Escape, newToken - -- 可被 set_endpoints 覆盖(测试指到假上游用)。 LOBSTERAI_ACTIVITY_BASE = "https://lobsterai-server.youdao.com" @@ -1256,6 +1468,12 @@ do break end end + for _, p in ipairs(PLATFORMS) do + if p.id == "qoder" then + p.claim_fn = function(plat, tok, args) return qoderClaim(plat, tok, args) end + break + end + end end @@ -4170,8 +4388,11 @@ function plugin.claim_daily(req) acct0.last_claim_summary = spec.message if acctEntry then poolAdd(plat.id, acctEntry) else plugin.state.accounts[plat.id] = acct0 end logEvent("领取结束 " .. plat.id .. ": " .. tostring(spec.kind)) - return { status = 200, json = { ok = spec.kind == "success", - outcome = { kind = spec.kind, message = spec.message } } } + local outcome = { kind = spec.kind, message = spec.message } + -- action_required 必须透传:它是给 UI 的**显式信号**("这一步需要用户去 + -- 官方客户端操作"),不能靠文案猜。参考 qoder-credits 明确定义了这个字段。 + if spec.action_required then outcome.action_required = true end + return { status = 200, json = { ok = spec.kind == "success", outcome = outcome } } end local resp, err = __llmsproxy_http(spec) @@ -5116,6 +5337,10 @@ function plugin.set_endpoints(req) end -- client_version:有道 exchange/续期/签到的必填版本号来源。测试需把它指到假上游; -- 同时清掉已缓存值(否则上一次测试的值会跨用例泄漏)。 + if type(args.qoder_sash_base) == "table" and args.qoder_sash_base.url then + QODER_SASH_BASE = tostring(args.qoder_sash_base.url) + changed[#changed+1] = "qoder_sash_base.url" + end if type(args.lobster_activity_base) == "table" and args.lobster_activity_base.url then LOBSTERAI_ACTIVITY_BASE = tostring(args.lobster_activity_base.url) changed[#changed+1] = "lobster_activity_base.url" @@ -5795,6 +6020,13 @@ end -- plugin.__qoder_machine_probe:只给测试用 —— 走真实的 headers_fn 路径, -- 返回它给出的设备身份头,便于验证「成对出现」与「拿不到时降级」。 +-- plugin.__qoder_refresh_probe(nowMs):只给测试用 —— 用**注入的时刻**验证 +-- UTC+8 刷新判定(本机恰是 UTC+8,否则无法区分"算术平移"与"本机时区")。 +function plugin.__qoder_refresh_probe(args) + local ms = tonumber((type(args) == "table" and args.now_ms) or 0) or 0 + return { json = { refreshed = qoderCampaignRefreshed(ms) } } +end + function plugin.__qoder_machine_probe(args) local ph = nil for _, p in ipairs(PLATFORMS) do