diff --git a/internal/core/channels_autoclaim_test.go b/internal/core/channels_autoclaim_test.go new file mode 100644 index 0000000..e3168b1 --- /dev/null +++ b/internal/core/channels_autoclaim_test.go @@ -0,0 +1,150 @@ +package core + +import ( + "strings" + "testing" +) + +// 自动签到的**能力表**必须与参考真值一致 —— 这张表是 auto_claim 的唯一依据, +// 错了就是"静默少领"或"每次调用都报自相矛盾的错"。 +// +// 实测踩到的三处偏差(本轮修正): +// +// buddy 我们写 claim=true,但 buddy 既无 checkin 又无 claim_fn → +// 每次调用返回"不支持每日签到(平台未声明 checkin)"(表说支持、 +// 函数说不支持)。参考里 buddy **没有** claim。 +// raccoon 我们声明空表 → 白丢一个可领渠道。参考有:桌面端登录奖励 +// POST /api/web/desktop/v1/login/points/grant(每号一次)。 +// zcode 有签到,但每次领取现场产阿里云 captcha(headful Chromium, +// 按设备限流 150 次/小时)→ 参考把它列进 auto-checkin 的**排除表**。 +// 自动跳过,手动仍可领。 +func TestAutoClaimCapabilityTableMatchesReference(t *testing.T) { + c, ps := poolCore(t) + _ = c + + // 参考真值(逐个 grep 参考仓的 claim 导出确认) + wantClaim := map[string]bool{ + "trae": true, "minimax": true, "lobsterai": true, "qoder": true, + "loomy": true, "raccoon": true, "codearts": true, + "cline": false, "gemini": false, "zcode": true, "buddy": false, + } + // 自动签到必须跳过的(领取有代价) + wantAutoExclude := map[string]bool{"zcode": true} + + for plat, want := range wantClaim { + t.Run("claim_"+plat, func(t *testing.T) { + got := creditsCapability(t, ps, plat) + if got == nil { + t.Fatalf("%s 无能力声明", plat) + } + if has := got["claim"] == true; has != want { + t.Errorf("%s claim = %v,参考为 %v", plat, has, want) + } + if got, ok := got["auto_exclude"].(bool); ok != wantAutoExclude[plat] { + t.Errorf("%s auto_exclude = %v,应为 %v", plat, got, wantAutoExclude[plat]) + } + }) + } + + // 关键一致性:声称 claim=true 的平台**必须**真的可领 + //(有 checkin 表或有 claim_fn)—— 否则就是 buddy 那种自相矛盾。 + for plat, want := range wantClaim { + if !want { + continue + } + m, err := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": plat}) + if err != nil { + t.Fatalf("%s: %v", plat, err) + } + w, _ := m.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + if j == nil { + t.Fatalf("%s 无返回", plat) + } + if j["capable"] != true { + t.Errorf("%s 声称 claim=true 但不可领(%v)—— 会出现「表说支持、"+ + "函数说不支持」的自相矛盾", plat, j["reason"]) + } + } + // 反向:不声称 claim 的平台必须被判为不可领 + m, _ := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": "buddy"}) + w, _ := m.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + if j != nil && j["capable"] == true { + t.Error("buddy 不该被判为可领(它没有 claim)") + } +} + +// 自动签到的**调度配置**必须覆盖两项需求:领取 + 模型获取。 +// +// auto_claim 走调度器(不是 HTTP export),故断言调度声明。 +func TestAutoClaimScheduledWithModelRefresh(t *testing.T) { + b, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + for _, want := range []struct{ name, needle string }{ + {"每日领取", `fn = "auto_claim"`}, + {"模型目录自动获取", `fn = "refresh_models"`}, + {"凭据自动续期", `fn = "refresh_creds"`}, + {"启动即补跑", `first_due_now = true`}, + } { + if !strings.Contains(b, want.needle) { + t.Errorf("调度表缺 %s(%s)", want.name, want.needle) + } + } + // 模型刷新间隔照参考 MODEL_REFRESH_INTERVAL_MS(2 小时) + if !strings.Contains(b, `fn = "refresh_models", every = 7200`) { + t.Error("refresh_models 间隔应为 7200(参考 2h)") + } + // 按**行**判断(固定字符窗会溢到下一行,误报) + lineOf := func(fn string) string { + for _, ln := range strings.Split(b, "\n") { + if strings.Contains(ln, `fn = "`+fn+`"`) { + return ln + } + } + return "" + } + // auto_claim 是每日任务,**不该** first_due_now(重启就重领是错的) + if ln := lineOf("auto_claim"); strings.Contains(ln, "first_due_now") { + t.Errorf("auto_claim 不该 first_due_now(每日任务重启后重领是错的):%s", ln) + } + // 两个自动任务必须 first_due_now(否则重启要等满一轮才自愈) + for _, fn := range []string{"refresh_models", "refresh_creds"} { + ln := lineOf(fn) + if ln == "" { + continue + } + if !strings.Contains(ln, "first_due_now = true") { + t.Errorf("%s 应声明 first_due_now(参考点名的重启自愈缺陷):%s", fn, ln) + } + } + // auto_exclude 必须挡住 zcode(领取有代价:headful Chromium) + if !strings.Contains(b, "auto_exclude = true") { + t.Error("zcode 应标记 auto_exclude(照参考 isAutoCheckinExcluded)") + } + if i := strings.Index(b, `if cap.auto_exclude then`); i < 0 { + t.Error("auto_claim 必须尊重 auto_exclude(否则会拉起浏览器)") + } +} + +// creditsCapability 读插件的能力表条目。 +func creditsCapability(t *testing.T, ps interface { + Call(string, string, map[string]interface{}) (interface{}, error) +}, plat string) map[string]interface{} { + t.Helper() + m, err := ps.Call("channels", "credits_capability", map[string]interface{}{"platform": plat}) + if err != nil { + t.Fatalf("credits_capability(%s): %v", plat, err) + } + w, _ := m.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + if j == nil { + return nil + } + if cap, ok := j["capability"].(map[string]interface{}); ok { + return cap + } + return nil +} diff --git a/internal/lua/plugin_runtime.go b/internal/lua/plugin_runtime.go index aadbded..b8cf1f8 100644 --- a/internal/lua/plugin_runtime.go +++ b/internal/lua/plugin_runtime.go @@ -55,6 +55,18 @@ type ScheduledTask struct { // Jitter, in seconds, spreads the first fire randomly so many plugins (or // many replicas) do not all hit the same upstream at :00. Jitter int `json:"jitter,omitempty"` + // FirstDueNow makes the FIRST run happen shortly after boot instead of after + // a full period. + // + // Why it exists (measured defect, borrowed from the reference implementation's + // post-mortem): with interval-only scheduling a restart leaves short-lived + // credentials expired for up to a whole period. The reference's providers + // have cline at 1h, codearts at ~2h and raccoon at 3h, so a 30-minute + // refresh interval left them showing "已过期" and their quota rows returning + // 401 after every host restart. A startup catch-up round fixes that — but it + // must be opt-in, because a task that is genuinely periodic (a daily + // check-in, say) should NOT fire again just because the host restarted. + FirstDueNow bool `json:"first_due_now,omitempty"` // Timeout is parsed for forward compatibility but is NOT enforced: a running // Lua call cannot be interrupted in this LuaJIT build (see the capability // note on checkWedged). Overrun is handled by disarming the plugin at the @@ -106,6 +118,7 @@ func parseSchedule(L *golua.State) scheduleSpec { t.At = readString(L, -1, "at") t.Jitter = readNumberInt(L, -1, "jitter") t.Timeout = readNumberInt(L, -1, "timeout") + t.FirstDueNow = readBoolField(L, -1, "first_due_now") if t.Fn != "" && (t.Every > 0 || t.At != "") { spec.tasks = append(spec.tasks, t) } @@ -195,6 +208,12 @@ func (ps *Plugins) startRuntime(p *Plugin, spec scheduleSpec) *PluginRuntime { // plugins do not all wake at process start. func (rt *PluginRuntime) firstDue(t ScheduledTask) time.Time { now := time.Now() + // A catch-up run: fire shortly after boot so a restart does not leave + // short-lived credentials expired for a whole period (see FirstDueNow). + // The short delay lets the rest of the plugin finish booting first. + if t.FirstDueNow { + return now.Add(catchUpDelay) + } if t.At != "" { return nextDailyOccurrence(t.At, now) } @@ -319,6 +338,11 @@ func (rt *PluginRuntime) runDue() { } } +// catchUpDelay is how long a FirstDueNow task waits after boot. Long enough for +// the plugin's own boot work to finish, short enough that a restart heals +// expired credentials promptly rather than after a full period. +const catchUpDelay = 5 * time.Second + // nextAfter computes the following due time for a task that just fired. func (rt *PluginRuntime) nextAfter(t ScheduledTask, now time.Time) time.Time { if t.At != "" { @@ -489,6 +513,17 @@ func randInt63n(n int64) int64 { // readNumberInt reads a numeric field as an int. Missing/zero yields 0, which // every caller treats as "not set" (a task with every<=0 and no at is disabled). +// readBoolField reads a boolean field, treating only a real Lua boolean as set. +// A missing field (nil) means false — the caller's declared default. +func readBoolField(L *golua.State, idx int, field string) bool { + L.GetField(idx, field) + defer L.Pop(1) + if L.Type(-1) != golua.LUA_TBOOLEAN { + return false + } + return L.ToBoolean(-1) +} + func readNumberInt(L *golua.State, idx int, field string) int { L.GetField(idx, field) defer L.Pop(1) diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index f7d2bfb..718c914 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", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_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", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "credits_capability", "claims_capable" } @@ -2897,15 +2897,26 @@ end -- 渠道积分能力表(对齐参考 CHANNEL_CREDITS):声明各渠道有没有领取/余额。 local CHANNEL_CREDITS = { codearts = { claim = true }, - buddy = { claim = true, status = true }, + -- ⚠️ buddy **没有**每日签到。此前这里写 claim=true 是错的:claim_daily 的 + -- 前置条件(plat.checkin 或 claim_fn)都不满足,于是每次调用都返回 + -- "CodeBuddy(腾讯) 不支持每日签到(平台未声明 checkin)" —— 一个自相矛盾的 + -- 文案(表说支持、函数说不支持)。对齐参考:buddy 只有**余额**,无 claim。 + buddy = { status = true }, lobsterai = { claim = true }, qoder = { claim = true }, trae = { claim = true }, cline = { unit = "dollars" }, loomy = { claim = true }, - raccoon = {}, + -- raccoon 的领取 = **桌面端登录奖励**(每号一次),参考 + -- raccoon-credits.ts:claimRaccoonLoginReward。此前我们声明为空表 → 白丢一个 + -- 可领渠道。幂等靠响应的 `granted` 字段(重复领取同样 HTTP 200 但 granted=false)。 + raccoon = { claim = true, unit = "points" }, minimax = { claim = true, status = true }, - zcode = { claim = true, unit = "tokens" }, + -- zcode 有签到,但每次领取都要现场产**阿里云 captcha**(headful Chromium, + -- 且阿里云按设备限流 150 次/小时)。故照参考 isAutoCheckinExcluded: + -- **自动**签到跳过它(代价发生在调用期间,等它报错来不及); + -- 用户仍可**手动**点领取 —— 浏览器弹出来是用户能理解的交互。 + zcode = { claim = true, unit = "tokens", auto_exclude = true }, gemini = { unit = "quota", quota = true }, } @@ -4924,10 +4935,92 @@ end -- -- 声明了 schedule 的插件会得到内核 ticker 驱动(无浏览器也执行), -- 且任务跑在本插件自己的 VM 上 —— 所以任务里读 state/保险库与钩子看到的是同一份。 +-- 周期任务表。三项,各自理由见下(参考 index.ts / models.ts 的调度对照): +-- +-- auto_claim 每天 08:00 领取额度(参考 auto-checkin 的日频语义) +-- refresh_models 每 2 小时刷新远端模型目录(参考 MODEL_REFRESH_INTERVAL_MS) +-- refresh_creds 每 30 分钟检查凭据续期(参考 REFRESH_INTERVAL_MS) +-- +-- ⚠️ 「启动即跑一轮」由调度器的 first_due_now 标记保证(见 plugin_runtime.go +-- 的 firstDue):参考点名过这个缺陷 —— 短寿命凭据(cline 1 小时、codearts +-- 约 2 小时、raccoon 3 小时)在宿主停机期间早就过期,只用 interval 的话 +-- 重启后要等满一个周期才续期,期间一直显示「已过期」。 plugin.schedule = { { fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 }, + { fn = "refresh_models", every = 7200, first_due_now = true, jitter = 120 }, + { fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 }, } +-- refresh_models():定时把远端模型目录拉回来并重同步所有源。 +-- +-- 为什么需要它:模型目录写进了**上游源**(源的 models 决定网关能路由哪些 +-- 模型)。不刷新就只能用兜底表 —— 平台上新模型后网关永远看不到, +-- 表现为"某个新模型 404 / 不可选"。 +function plugin.refresh_models() + local updated, failed = 0, {} + for _, plat in ipairs(allPlatforms()) do + if plat.models_remote then + local ids, err = refreshRemoteModels(plat.id) + if ids then + updated = updated + 1 + -- 目录变了就重同步该平台所有源(模型清单在源里) + for _, a in ipairs(poolList(plat.id)) do + syncAccountSource(plat.id, a.id) + end + else + failed[#failed + 1] = plat.id .. ": " .. tostring(err) + end + end + end + if updated > 0 then + logEvent("模型目录已刷新(" .. updated .. " 个平台)") + end + -- 失败留痕但**不清缓存**(refreshRemoteModels 内部已保证): + -- 拉不到时继续用上次的好数据,绝不能把目录清空。 + if #failed > 0 then + logEvent("模型刷新失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 200)) + end +end + +-- refresh_creds():定时检查并续期凭据。 +-- +-- ⚠️ **不看 enabled**(照参考的既有铁律):停用只影响自动选号,不该让凭据 +-- 停止续期。否则"所有账号都被停用"时续期根本不跑,凭据一路烂到 +-- refresh_token 失效、只能重新登录。 +-- +-- ⚠️ **只对「距过期不足 lead 时间」的账号发请求**(照参考 expiry-sync 的 +-- shouldRefreshNow):每次无脑续期会在启动时打出几十个无谓请求。 +-- 没有过期时间的账号只做本地对账,不发请求。 +function plugin.refresh_creds() + local lead = 3600 -- 提前 1 小时续(照参考的 lead 语义) + local now = os.time and os.time() or 0 + local refreshed, skipped = 0, 0 + for _, plat in ipairs(allPlatforms()) do + if plat.exchange_body or plat.refresh_url then + for _, a in ipairs(poolList(plat.id)) do + local cred = credentialFull(a.credentialRef) + if type(cred) == "table" then + local exp = tonumber(a.expiresAt) or credentialExpiresAtMs(cred) + local due = exp == nil or exp == 0 + if not due then + -- expiresAt 单位可能是毫秒;统一成秒再比 + local e = exp > 1e11 and math.floor(exp / 1000) or exp + due = (e - now) <= lead + end + if due then + local ok, err = plugin.account_refresh({ platform = plat.id, account_id = a.id }) + if ok and ok.json and ok.json.ok then refreshed = refreshed + 1 + else skipped = skipped + 1 end + else + skipped = skipped + 1 + end + end + end + end + end + if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个") end +end + function plugin.auto_claim() -- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。 plugin.state.settings = plugin.state.settings or {} @@ -4935,6 +5028,11 @@ function plugin.auto_claim() for _, plat in ipairs(allPlatforms()) do local cap = CHANNEL_CREDITS[plat.id] if not cap or not cap.claim then goto continue end + -- auto_exclude:领取有**代价**的平台(zcode 每次领取现场产阿里云 captcha, + -- headful Chromium + 按设备限流 150 次/小时)。代价发生在**调用期间**, + -- "等它报错再判定"来不及,故必须在调之前排除(照参考 + -- isAutoCheckinExcluded)。用户仍可手动领取。 + if cap.auto_exclude then goto continue end local legacy = accountOf(plat.id) if legacy and legacy.enabled then plugin.claim_daily({ platform = plat.id }) @@ -6850,7 +6948,245 @@ end return adapter ]==] --- installTraeAdapter():装/更新本插件自己的 TRAE 直连适配器。 +-- ============================================================================ +-- ============================================================================ +-- loomy 领取:触发每日额度重置(照参考 loomy-credits.ts:claimLoomyDailyQuota) +-- ============================================================================ +-- ⚠️ 语义是「触发每日额度重置」,**不是**「+5000 积分」: +-- dailyBalance = dailyQuota - dailyConsumed,消耗后不回补。 +-- ⚠️ 幂等判据是 `alreadyProcessed`(重复调用同样 HTTP 200)→ 映射成 +-- already-claimed,不是 claimed(谎报收益比报错更糟)。 +-- 本函数不抛错:失败也返回 failed,保证批量领取不因单个账号中断。 +function claimLoomy(plat, tok, args) + local cred = (args and args.credential) or nil + local base = (cred and (cred.api_base or cred.apiBase)) or "https://loomy.xunfei.cn" + local root = base:gsub("/chat/completions$", "") + local url = root .. "/points/first-login" + local resp, err = __llmsproxy_http({ + url = url, method = "POST", + headers = { + ["Authorization"] = "Bearer " .. tok, + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + }, + body = "{}", + }) + if err or not resp then + return { done = true, kind = "failed", message = "领取失败: " .. tostring(err) } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then rec = {} end + if resp.status ~= 200 then + local msg = rec.message or rec.msg or "" + return { done = true, kind = "failed", + message = (msg ~= "" and tostring(msg)) or ("HTTP " .. tostring(resp.status)) } + end + local data = rec.data or rec + if type(data) ~= "table" then data = {} end + local dailyQuota = tonumber(data.dailyQuota) + local dailyBalance = tonumber(data.dailyBalance) + if data.alreadyProcessed == true then + return { done = true, kind = "already-claimed", + message = dailyQuota and + string.format("今日额度已初始化(每日 %s/%s)", + tostring(dailyBalance or 0), tostring(dailyQuota)) + or "今日额度已初始化" } + end + local dailyConsumed = tonumber(data.dailyConsumed) + local granted = 0 + if dailyQuota and dailyConsumed then + granted = math.max(0, dailyQuota - dailyConsumed) + end + return { done = true, kind = "claimed", credit = granted, unit = "quota" } +end + +-- ============================================================================ +-- zcode 领取:每日额度(照参考 zcode-upstream.ts:claimZcodePlan) +-- ============================================================================ +-- 三步(照参考 zcode-auth.ts:claimDailyWith 的顺序): +-- ① 补激活上报(event/report,**不需要 Authorization**) +-- ② preview 查可领 plan(`?app_version=&platform=win32`) +-- ③ 逐个 plan POST billing/claim,body={plan_id} +-- +-- ⚠️ claim **始终强制索要 captcha**(上游校验前置于 plan 校验)。参考的 +-- captcha 有两个来源:DSH Desktop 的 webview guest 产的 param("内部载体"), +-- 或外挂 headful Chromium。**我们没有那个桌面壳** ⇒ 只能走浏览器路径。 +-- +-- ⇒ 因此: +-- * 本函数在**没有 captcha param 时如实失败**(不伪造、不重试), +-- 并给出可操作提示 —— 谎报"已领取"比报错更糟。 +-- * auto_claim 已把 zcode 列进 auto_exclude(照参考 +-- isAutoCheckinExcluded):代价发生在调用期间(拉 headful Chromium + +-- 阿里云按设备限流 150 次/小时),"等它报错再判定"来不及。 +-- * 用户**手动**点领取时带上 captcha param 即可走通(面板交互)。 +local ZCODE_ORIGIN = "https://zcode.z.ai" + +-- zcodeHeaders:照参考 buildZcodeHeaders。X-Device-Mid 是**硬需求** +-- (缺它 billing 全家桶回 400)。 +local function zcodeHeaders(cred, opts) + opts = opts or {} + local h = { + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + ["X-Device-Mid"] = tostring(cred.device_mid or ""), + ["X-App-Version"] = tostring(cred.app_version or "1.0.0"), + ["X-Client-Platform"] = "win32", + } + if opts.authorization then h["Authorization"] = opts.authorization end + if opts.captcha then + h["X-Captcha-Param"] = opts.captcha.param + h["X-Captcha-Region"] = opts.captcha.region or "" + end + return h +end + +-- zcodeClaimablePlans(cred):查可领 plan(preview)。 +local function zcodeClaimablePlans(cred) + local av = tostring(cred.app_version or "1.0.0") + local url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/preview" + .. "?app_version=" .. rfc3986Escape(av) .. "&platform=win32" + local resp, err = __llmsproxy_http({ + url = url, method = "GET", + headers = zcodeHeaders(cred, { + authorization = "Bearer " .. tostring(cred.zcode_jwt or cred.access_token or ""), + }), + }) + if err or not resp then return nil, "查询可领计划失败: " .. tostring(err) end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return nil, "响应不是 JSON" end + local plans = rec.plans or (rec.data and rec.data.plans) or {} + local out = {} + for _, pl in ipairs(plans) do + if type(pl) == "table" then + local id = pl.plan_id or pl.planId + if id then out[#out + 1] = tostring(id) end + end + end + return out, nil +end + +function claimZcode(plat, tok, args) + local cred = (args and args.credential) or {} + local jwt = cred.zcode_jwt or cred.access_token or tok + cred = cred or {} + if not cred.zcode_jwt and not cred.access_token then cred.zcode_jwt = tok end + + -- ① 补激活上报(照参考:不需要 Authorization) + __llmsproxy_http({ + url = ZCODE_ORIGIN .. "/api/v1/event/report", method = "POST", + headers = { ["Content-Type"] = "application/json" }, + body = json.encode({ event = "activation", app_version = tostring(cred.app_version or "1.0.0") }), + }) + + -- ② 查可领 plan + local plans, perr = zcodeClaimablePlans(cred) + if not plans then + return { done = true, kind = "failed", message = perr } + end + if #plans == 0 then + -- ⚠️ 照参考:「没有可领 plan」**不等于**「今天已领」—— 也可能活动未投放。 + -- 说成 already-claimed 是谎报。 + return { done = true, kind = "inactive", + message = "当前没有可领取的计划(可能活动未投放,或今日已领)" } + end + + -- ③ 每个 plan 都要一个 captcha param。没有就如实失败。 + local captcha = args and args.captcha + if type(captcha) ~= "table" or not captcha.param or captcha.param == "" then + return { done = true, kind = "action_required", + message = "zcode 领取需要验证码参数(captcha param)。" + .. "本机无 DSH Desktop 内部载体,需在面板手动领取(会拉起浏览器);" + .. "自动签到已按参考把它排除。" } + end + + local granted, failedMsg = 0, nil + for _, planId in ipairs(plans) do + local resp, err = __llmsproxy_http({ + url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/claim", method = "POST", + headers = zcodeHeaders(cred, { + authorization = "Bearer " .. tostring(jwt), + captcha = { param = captcha.param, region = captcha.region or "cn" }, + }), + body = json.encode({ plan_id = planId }), + }) + if err or not resp then + failedMsg = "领取失败: " .. tostring(err) + else + local ok, rec = pcall(json.decode, resp.body or "") + if ok and type(rec) == "table" then + -- ⚠️ 业务码可能是**纯数字字符串**("3012"),tonumber 必须能认 + local code = tonumber(rec.code) or tonumber(tostring(rec.code or "")) + local msg = tostring(rec.msg or rec.message or "") + if code == 3012 or msg:find("unusual activity", 1, true) then + failedMsg = "上游风控拦截(3012 unusual activity)—— 需重新获取验证码" + elseif code and code ~= 0 and code ~= 200 then + failedMsg = msg ~= "" and msg or ("业务码 " .. tostring(code)) + else + granted = granted + 1 + end + else + failedMsg = "响应不是 JSON(HTTP " .. tostring(resp.status) .. ")" + end + end + end + + if granted == 0 then + return { done = true, kind = "failed", message = failedMsg or "全部计划领取失败" } + end + return { done = true, kind = "claimed", credit = granted, unit = "plans", + message = ("已领取 " .. granted .. "/" .. #plans .. " 个计划") + .. (failedMsg and ("(部分失败:" .. failedMsg .. ")") or "") } +end + +-- raccoon 领取:桌面端登录奖励(照参考 raccoon-credits.ts:claimRaccoonLoginReward) +-- ============================================================================ +-- 端点:POST {apiBase}/api/web/desktop/v1/login/points/grant +-- ⚠️ 幂等判据是响应里的 `granted` 字段:**重复领取同样返回 HTTP 200 但 +-- granted=false**。据此映射成 already-claimed,而不是 claimed —— +-- 否则用户会以为每天都真的加了额度(谎报收益比报错更糟)。 +-- 失败时若上游给了 message,用它(比通用文案有用)。 +function claimRaccoon(plat, tok, args) + local cred = (args and args.credential) or nil + local base = (cred and (cred.api_base or cred.apiBase)) or plat.base_url or "" + -- base_url 在平台表里含 llm 前缀(.../api/web/llm/v2),领取端点不在这条路径上, + -- 故从凭据或平台根域名取。参考用 product.apiBase + RACCOON_DESKTOP_PREFIX。 + local root = base:gsub("/api/web/llm/v2$", "") + local url = root .. "/api/web/desktop/v1/login/points/grant" + local h = { + ["Authorization"] = "Bearer " .. tok, + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + ["X-Org-Code"] = (cred and (cred.office_identity or "")) or "", + ["X-Raccoon-Language"] = "zh", + } + if cred and cred.device_id and cred.device_id ~= "" then + h["X-Device-Id"] = cred.device_id + end + local resp, err = __llmsproxy_http({ url = url, method = "POST", headers = h, body = "{}" }) + if err or not resp then + return { done = true, kind = "failed", + message = "领取请求失败: " .. tostring(err) } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then rec = {} end + -- 信封:{code, message, data:{granted, popup:{points}}} + local env = rec.data and rec or rec + if resp.status ~= 200 or (rec.code and rec.code ~= 0 and rec.code ~= 200) then + local msg = rec.message or rec.msg or "" + return { done = true, kind = "failed", + message = (msg ~= "" and tostring(msg)) or ("HTTP " .. tostring(resp.status)) } + end + local data = rec.data or env + if type(data) ~= "table" or data.granted ~= true then + -- 幂等:HTTP 200 但 granted 非 true = 已经领过(每号一次) + return { done = true, kind = "already-claimed", + message = "该账号已领取过桌面端登录奖励(每号一次)" } + end + local points = tonumber((data.popup or {}).points) or 3000 + return { done = true, kind = "claimed", credit = points, unit = "points" } +end + +-- installRaccoonAdapter local function installTraeAdapter() if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end -- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。 @@ -7638,6 +7974,40 @@ plugin.ui = { }, } +-- plugin.credits_capability:只给测试用 —— 暴露能力表条目,让判据能断言 +-- "表与参考真值一致"(表错了只会静默少领,没有任何报错)。 +function plugin.credits_capability(args) + args = args or {} + local cap = CHANNEL_CREDITS[args.platform] + if not cap then return { json = { error = "无能力声明" } } end + local out = {} + for k, v in pairs(cap) do + if type(v) ~= "function" then out[k] = v end + end + return { json = { capability = out } } +end + +-- plugin.claims_capable:只给测试用 —— 判断某平台**真的**能不能领 +--(有无 checkin 表或 claim_fn)。这与"能力表声称能领"是两件事, +-- 两者不一致就会出现自相矛盾的文案。 +function plugin.claims_capable(args) + args = args or {} + local plat = platformById(args.platform) + if not plat then return { json = { error = "未知平台" } } end + local has_checkin = plat.checkin ~= nil and plat.checkin.url ~= nil + local has_fn = plat.claim_fn ~= nil + local cap = CHANNEL_CREDITS[plat.id] or {} + return { json = { + capable = (has_checkin or has_fn), + has_checkin = has_checkin, + has_claim_fn = has_fn, + table_says_claim = cap.claim == true, + auto_exclude = cap.auto_exclude == true, + reason = (not (has_checkin or has_fn)) and + ("既无 checkin 表也无 claim_fn(claim_fn=" .. tostring(plat.claim_fn ~= nil) .. ")") or "", + } } +end + -- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的 -- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。 function plugin.__adapter_install_probe() @@ -7691,6 +8061,37 @@ function plugin.__sync_source_probe(args) models_n = #models } } end +-- 绑定 zcode 的领取钩子(同 raccoon/loomy:local 前向引用,必须放末尾)。 +do + for _, p in ipairs(PLATFORMS) do + if p.id == "zcode" then + p.claim_fn = function(plat, tok, args) return claimZcode(plat, tok, args) end + break + end + end +end + +-- 绑定 loomy 的领取钩子(同 raccoon:local 前向引用,必须放末尾)。 +do + for _, p in ipairs(PLATFORMS) do + if p.id == "loomy" then + p.claim_fn = function(plat, tok, args) return claimLoomy(plat, tok, args) end + break + end + end +end + +-- 绑定 raccoon 的领取钩子。必须放在**文件末尾**:claimRaccoon 是 local, +-- 只能从定义处向后可见(本文件已因 local 前向引用踩过 8 次)。 +do + for _, p in ipairs(PLATFORMS) do + if p.id == "raccoon" then + p.claim_fn = function(plat, tok, args) return claimRaccoon(plat, tok, args) end + break + end + end +end + -- 装本插件自己的专用适配器。必须放在**文件末尾**(installCodeartsAdapter 是 -- local,只能从定义处向后可见 —— 本文件已因 local 前向引用踩过 7 次)。 -- 放在脚本执行末尾即可:适配器装载与源注册都发生在脚本执行完之后。 diff --git a/internal/lua/schedule_first_due_test.go b/internal/lua/schedule_first_due_test.go new file mode 100644 index 0000000..c9927ec --- /dev/null +++ b/internal/lua/schedule_first_due_test.go @@ -0,0 +1,104 @@ +package lua + +import ( + "testing" + "time" +) + +// 「启动即跑一轮」判据。 +// +// 为什么需要它(照参考的事后复盘):只用 interval 时,宿主重启后短寿命凭据 +// (cline 1h / codearts ~2h / raccoon 3h)要等满一个周期才续期,期间一直显示 +// 「已过期」、积分行 401。修复 = 声明 first_due_now 的任务在启动后短暂延迟内 +// 先跑一轮。 +// +// 但它**必须 opt-in**:真正周期性的任务(如每日签到)不该因为重启就重跑。 +func TestScheduleFirstDueNow(t *testing.T) { + // ① 声明了 first_due_now 的任务:首轮应在 catchUpDelay 内,而不是等一个周期 + now := time.Now() + catchUp := ScheduledTask{Fn: "f", Every: 7200, FirstDueNow: true} + rt := &PluginRuntime{} + d := rt.firstDue(catchUp) + if d.After(now.Add(catchUpDelay + time.Second)) { + t.Errorf("first_due_now 的任务首轮应在 %v 内,实得 %v 后", + catchUpDelay, d.Sub(now)) + } + if d.Before(now) { + t.Errorf("首轮时间不该在过去:%v", d.Sub(now)) + } + + // ② 没声明的:必须等满一个周期(否则每日/每小时的语义会被破坏) + plain := ScheduledTask{Fn: "f", Every: 7200} + d2 := rt.firstDue(plain) + if d2.Sub(now) < 7000*time.Second { + t.Errorf("未声明 first_due_now 的任务不该提前跑:实得 %v", d2.Sub(now)) + } + + // ③ at 形式(每日固定时刻)不受标记影响 —— 它本来就该等到那个时刻 + daily := ScheduledTask{Fn: "f", At: "08:00"} + d3 := rt.firstDue(daily) + if d3.Sub(now) > 25*time.Hour { + t.Errorf("每日任务的首次时间超出一天:%v", d3.Sub(now)) + } + if d3.Sub(now) < catchUpDelay && !daily.FirstDueNow { + // 只有当 08:00 恰好在 catchUpDelay 内才可能,属正常 + t.Logf("每日任务首轮临近(可能恰好在 08:00 附近):%v", d3.Sub(now)) + } + + // ④ 声明了 first_due_now 的每日任务:优先补跑(凭据不等时刻) + dailyCatchUp := ScheduledTask{Fn: "f", At: "08:00", FirstDueNow: true} + d4 := rt.firstDue(dailyCatchUp) + if d4.Sub(now) > catchUpDelay+time.Second { + t.Errorf("声明 first_due_now 时即便有 at 也应先补跑:%v", d4.Sub(now)) + } +} + +// 解析层判据:first_due_now 必须真的从插件源码读出来(写错键名会静默变成 +// false —— 那就是"看起来配了、实际没补跑")。 +func TestScheduleParsesFirstDueNow(t *testing.T) { + const src = ` +local plugin = { name = "schedprobe", version = "1.0" } +plugin.exports = { "noop" } +function plugin.noop() end +function plugin.refresh_creds() end +function plugin.auto_claim() end +plugin.schedule = { + { fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 }, + { fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 }, +} +return plugin +` + _, ps, _ := runtimePlugins(t) + if err := ps.LoadSource("schedprobe", src); err != nil { + t.Fatal(err) + } + ps.mu.RLock() + var sched []ScheduledTask + for _, p := range ps.plugins { + if p.Info.Name == "schedprobe" { + sched = p.Schedule + } + } + ps.mu.RUnlock() + if len(sched) != 2 { + t.Fatalf("应解析出 2 个任务,实得 %d: %+v", len(sched), sched) + } + byFn := map[string]ScheduledTask{} + for _, task := range sched { + byFn[task.Fn] = task + } + if task, ok := byFn["refresh_creds"]; !ok { + t.Fatal("refresh_creds 任务未解析") + } else if !task.FirstDueNow { + t.Error("first_due_now 未被解析为 true(键名写错会静默失效)") + } else if task.Every != 1800 { + t.Errorf("every = %d,应为 1800", task.Every) + } + if task, ok := byFn["auto_claim"]; !ok { + t.Fatal("auto_claim 任务未解析") + } else if task.FirstDueNow { + t.Error("auto_claim 未声明 first_due_now,应为 false(每日任务不该因重启重跑)") + } else if task.At != "08:00" { + t.Errorf("at = %q,应为 08:00", task.At) + } +}