diff --git a/internal/core/channels_lobster_claim_test.go b/internal/core/channels_lobster_claim_test.go new file mode 100644 index 0000000..45d7acb --- /dev/null +++ b/internal/core/channels_lobster_claim_test.go @@ -0,0 +1,110 @@ +package core + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "llmsproxy/internal/lua" +) + +// 有道每日活动领取是**三步流程**,参考注释点名:无可用活动是**正常业务状态**, +// 不能显示成"领取失败"(否则用户会去查网络/账号,方向全错)。 +// 本判据钉住决策表的四类结果:inactive / already-claimed / success / failed。 +func TestLobsterActivityClaimDecisionTable(t *testing.T) { + type c struct { + name string + slot string + ctx string + wantKind string + wantCalled bool // 是否应发出 claim 请求 + } + cases := []c{ + {"槽位不可用→inactive", `{"code":0,"data":{"slotState":"unavailable","activity":{"activityCode":"A1"}}}`, "", "inactive", false}, + {"无 activityCode→inactive", `{"code":0,"data":{"slotState":"available","activity":{}}}`, "", "inactive", false}, + {"今天已领→already-claimed", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`, + `{"code":0,"data":{"state":{"claimedToday":true},"actions":["check_in"]}}`, "already-claimed", false}, + {"无 check_in 动作→inactive", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`, + `{"code":0,"data":{"state":{"claimedToday":false},"actions":["share"]}}`, "inactive", false}, + {"可领→success", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`, + `{"code":0,"data":{"state":{"claimedToday":false},"actions":["check_in"]}}`, "success", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + claimHit := 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 == "/api/client-activities/slot": + fmt.Fprint(w, tc.slot) + case r.URL.Path == "/api/client-activities/A1/context": + if tc.ctx == "" { + w.WriteHeader(500) + return + } + fmt.Fprint(w, tc.ctx) + case r.URL.Path == "/api/client-activities/A1/actions/check_in": + claimHit++ + b := make([]byte, r.ContentLength) + _, _ = r.Body.Read(b) + claimBody = string(b) + fmt.Fprint(w, `{"code":0,"data":{"result":{"creditsGranted":30}}}`) + 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": "lobsterai", + "lobster_activity_base": map[string]interface{}{"url": ts.URL}, + }); err != nil { + t.Fatal(err) + } + // 存入凭据(claim 需要结构化 credential 对象) + cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"}) + body, _ := json.Marshal(map[string]string{ + "platform": "lobsterai", "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":"lobsterai"}`, + }) + 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) + } + if k, _ := oc["kind"].(string); k != tc.wantKind { + t.Fatalf("kind 应为 %s,实得 %v(%v)", tc.wantKind, oc["kind"], oc["message"]) + } + if (claimHit > 0) != tc.wantCalled { + t.Errorf("claim 请求次数 = %d,期望发出=%v", claimHit, tc.wantCalled) + } + if tc.wantCalled && claimBody == "" { + t.Error("claim 请求体不应为空(要带 configRevision 与幂等键)") + } + }) + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 0d422a1..bfa931c 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -986,6 +986,114 @@ local function codeartsClaim(plat, tok, args) return { done = true, kind = "success", message = msg } end +-- ---- 有道 LobsterAI 每日活动领取(照参考 lobsterai-credits.ts 逐行移植)---- +-- +-- 三步流程,判定顺序把「业务正常状态」与「真失败」严格分开: +-- ① 槽位查询失败 → failed;slotState≠available 或无 activityCode → inactive +-- ② 上下文查询失败 → failed;claimedToday → already-claimed; +-- actions 不含 check_in → inactive +-- ③ POST .../{activityCode}/actions/check_in(带客户端幂等键)→ 成功 +-- 参考注释特别强调:无可用活动是**正常业务状态**,不能显示成"领取失败"。 +-- 前向声明:本段(活动领取)要用到的这几个助手都定义在文件后段。 +-- 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" + +local function lobsteraiActivityHeaders(cred) + return { + ["Authorization"] = "Bearer " .. tostring(cred.access_token or ""), + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + ["User-Agent"] = "LobsterAI/0.1.0", + } +end + +-- lobsteraiClaim(plat, tok, args):完整 3 步领取。 +-- 与 codearts 同款:自己发多个请求,用 done 哨兵回给 claim_daily。 +local function lobsteraiClaim(plat, tok, args) + local cred = (args and args.credential) or nil + if type(cred) ~= "table" then return nil, "缺少凭据对象" end + local hdrs = lobsteraiActivityHeaders(cred) + -- clientVersion 必须是**动态真值**(与交换/续期/签到同一口径) + local cver = lobsteraiClientVersion(plat) + + -- ① 槽位 + local slotURL = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/slot?placement=desktop_sidebar" + .. "&clientVersion=" .. rfc3986Escape(cver) + .. "&containerApiVersion=2&platform=win32" + local sresp, serr = __llmsproxy_http({ url = slotURL, method = "GET", headers = hdrs }) + if serr or not sresp or sresp.status ~= 200 then + return { done = true, kind = "failed", + message = "活动槽位查询失败: " .. tostring(serr or (sresp and sresp.status)) } + end + local sok, srec = pcall(json.decode, sresp.body or "") + if not sok or type(srec) ~= "table" or srec.code ~= 0 or type(srec.data) ~= "table" then + return { done = true, kind = "failed", message = "活动槽位信封异常" } + end + local act = type(srec.data.activity) == "table" and srec.data.activity or {} + local slotState = tostring(srec.data.slotState or "") + local activityCode = tostring(act.activityCode or "") + local configRevision = tonumber(act.configRevision) or 0 + if slotState ~= "available" or activityCode == "" then + -- 正常业务状态,不是失败 + return { done = true, kind = "inactive", message = "无可用活动(slotState=" .. slotState .. ")" } + end + + -- ② 上下文 + local ctxURL = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/" + .. rfc3986Escape(activityCode) .. "/context?configRevision=" .. tostring(configRevision) + local cresp, cerr = __llmsproxy_http({ url = ctxURL, method = "GET", headers = hdrs }) + if cerr or not cresp or cresp.status ~= 200 then + return { done = true, kind = "failed", + message = "活动上下文查询失败: " .. tostring(cerr or (cresp and cresp.status)) } + end + local cok, crec = pcall(json.decode, cresp.body or "") + if not cok or type(crec) ~= "table" or crec.code ~= 0 or type(crec.data) ~= "table" then + return { done = true, kind = "failed", message = "活动上下文信封异常" } + end + local state = type(crec.data.state) == "table" and crec.data.state or {} + if state.claimedToday == true then + return { done = true, kind = "already-claimed", message = "今天已签到" } + end + local hasCheckIn = false + for _, a in ipairs(crec.data.actions or {}) do + if a == "check_in" then hasCheckIn = true break end + end + if not hasCheckIn then + return { done = true, kind = "inactive", message = "当前不可签到" } + end + + -- ③ 领取(客户端幂等键) + local body = json.encode({ configRevision = configRevision, + idempotencyKey = newToken():sub(1, 8) .. "-" .. newToken():sub(9, 12) .. "-4" + .. newToken():sub(14, 16) .. "-8" .. newToken():sub(18, 20) .. "-" .. newToken():sub(21, 32), + payload = {} }) + local url = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/" + .. rfc3986Escape(activityCode) .. "/actions/check_in" + local resp, err = __llmsproxy_http({ url = url, method = "POST", headers = hdrs, body = body }) + if err or not resp or resp.status ~= 200 then + return { done = true, kind = "failed", + message = "领取请求失败: " .. tostring(err or (resp and resp.status)) } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" or rec.code ~= 0 then + local why = (ok and type(rec) == "table") and tostring(rec.msg or rec.message or rec.code) or "信封异常" + return { done = true, kind = "failed", message = "领取失败: " .. why } + end + -- 积分字段三级回退(参考:creditsGranted → rewardCredits → credits) + local result = type(rec.data) == "table" and type(rec.data.result) == "table" and rec.data.result or {} + local credit = tonumber(result.creditsGranted) or tonumber(result.rewardCredits) or tonumber(result.credits) + local msg = "已签到" + if credit then msg = msg .. ",获得 " .. tostring(credit) .. " 积分" end + return { done = true, kind = "success", message = msg } +end + -- 绑定 codearts 的领取钩子。它自己跑完 4 步流程(账户→活动→claim→confirm), -- 用 done 哨兵返回,claim_daily 识别后不再发送单个请求。 do @@ -995,6 +1103,12 @@ do break end end + for _, p in ipairs(PLATFORMS) do + if p.id == "lobsterai" then + p.claim_fn = function(plat, tok, args) return lobsteraiClaim(plat, tok, args) end + break + end + end end @@ -1102,7 +1216,7 @@ end -- 认证头前缀是 `account {ak}:{sig}`(**不是** Bearer)。 -- rfc3986Escape:encodeURIComponent 等价 + 补转 ! ' ( ) * -local function rfc3986Escape(v) +rfc3986Escape = function(v) v = tostring(v or "") v = v:gsub("([^A-Za-z0-9%-%._~])", function(c) return string.format("%%%02X", string.byte(c)) @@ -2111,7 +2225,7 @@ end -- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。 local SESSION_TTL = 900 -- 15 分钟,过期会话在读取时惰性清理 -local function newToken() +newToken = function() -- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex) return sha256_hex(tostring(os.time()) .. math.random(1e12) .. (plugin.name or "channels")) end @@ -3274,7 +3388,7 @@ end -- - 远程成功 → 用远程值并缓存; -- - 失败 → 用兜底值但**不缓存**(缓存会让一次瞬时故障在 TTL 内持续发酵)。 -- **绝不能写死**:实测 2026-10-13 上游已是 2026.9.23。 -local function lobsteraiClientVersion(plat) +lobsteraiClientVersion = function(plat) local cv = plat.client_version if type(cv) ~= "table" then return "2026.9.4" end local cache = plugin.state.clientVersion @@ -3861,7 +3975,10 @@ function plugin.claim_daily(req) if args.platform == nil then args = req end local plat = platformById(args and args.platform) if not plat then return { status = 400, json = { error = "未知平台" } } end - if not plat.checkin or not plat.checkin.url then + -- 有 claim_fn 的平台即使没声明 checkin 端点也能领(如 codearts 的 4 步流程、 + -- 有道的 3 步活动领取 —— 它们自己发多个请求,不走 checkin 表驱动)。 + -- 此前这里硬要求 plat.checkin,导致这两个平台的钩子**永远走不到**。 + if (not plat.checkin or not plat.checkin.url) and not plat.claim_fn then return { status = 400, json = { error = plat.name .. " 不支持每日签到(平台未声明 checkin)" } } end -- 账号池路径:claim 某个池账号;否则旧路径单凭据。 @@ -3885,10 +4002,11 @@ function plugin.claim_daily(req) -- 参考实现的特殊形状(SDK-HMAC-SHA256 签名等)由该平台的 claim 钩子生成 -- (见 PLATFORMS 的 claim_fn),这里是通用路径。 local spec = { - url = plat.checkin.url, - method = plat.checkin.method or "POST", - headers = plat.checkin.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }, - body = plat.checkin.body or "{}", + url = plat.checkin and plat.checkin.url or "", + method = (plat.checkin and plat.checkin.method) or "POST", + headers = (plat.checkin and plat.checkin.headers) + or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }, + body = (plat.checkin and plat.checkin.body) or "{}", } if plat.claim_fn then local custom, cerr = plat.claim_fn(plat, tok, credArgs) @@ -4851,6 +4969,10 @@ function plugin.set_endpoints(req) end -- client_version:有道 exchange/续期/签到的必填版本号来源。测试需把它指到假上游; -- 同时清掉已缓存值(否则上一次测试的值会跨用例泄漏)。 + 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" + end if type(args.client_version) == "table" and plat.client_version then for _, k in ipairs({ "url", "path", "fallback" }) do if args.client_version[k] then