diff --git a/accounts_cli.go b/accounts_cli.go index 6944dfd7..ed1d13de 100644 --- a/accounts_cli.go +++ b/accounts_cli.go @@ -467,8 +467,9 @@ func refreshAccounts(asJSON bool) error { // checkinWorkBuddy: `magpie accounts checkin` — WorkBuddy's daily check-in // (签到) for each WorkBuddy (China) account not in yet today, and Trae CN's // (每日签到) for each Trae CN account, and MiniMax Code's for each MiniMax -// Code account, and Qoder's daily credits for each Qoder account, now, and -// how each stands. The +// Code account, and Qoder's daily credits for each Qoder account, and each +// plugin's own check-in (auth.checkin) for its accounts, now, and how each +// stands. The // settings do it on their own once a day. func checkinWorkBuddy(asJSON bool) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) @@ -476,6 +477,7 @@ func checkinWorkBuddy(asJSON bool) error { rs := append(provider.CheckInWorkBuddy(ctx), provider.CheckInTrae(ctx)...) rs = append(rs, provider.CheckInMiniMax(ctx)...) rs = append(rs, provider.CheckInQoder(ctx)...) + rs = append(rs, provider.CheckInPlugins(ctx)...) if rs == nil { rs = []provider.WorkBuddyCheckin{} } @@ -498,6 +500,10 @@ func checkinWorkBuddy(asJSON bool) error { r.User = "MiniMax Code " + r.User case "qoder": r.User = "Qoder " + r.User + default: + if r.Vendor != "" { + r.User = strings.TrimSpace(r.Vendor + " " + r.User) + } } switch r.Outcome { case provider.CheckinClaimed, provider.CheckinDone: @@ -516,6 +522,8 @@ func checkinWorkBuddy(asJSON bool) error { fmt.Println(muted.Render("·"), r.User, muted.Render("not eligible for the check-in")) case provider.CheckinInactive: fmt.Println(muted.Render("·"), r.User, muted.Render("no check-in event now")) + case provider.CheckinCaptcha: + fmt.Println(muted.Render("·"), r.User, muted.Render(strings.TrimSpace("asks for a captcha: check in in its own app "+r.Msg))) default: fmt.Println(muted.Render("✗"), r.User, muted.Render(r.Msg)) } diff --git a/docs/reference.md b/docs/reference.md index 63dda3ac..c9dfe9db 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -228,6 +228,12 @@ which checks in every account signed in on this computer at once; its WorkBuddy lines say how today's went. A shared magpie's accounts are checked in on that magpie, from its own app, TUI or CLI. +A plugin whose `auth` hook has `checkin` checks its own accounts in the same +way: once a day while its switch under **Settings → Usage → Plugins** is on, +with the result on each account's Usage card. A check-in the vendor wants a +captcha for is shown as such (check in in the vendor's app); magpie never +solves captchas. See [provider-plugins.md](subsystems/provider-plugins.md#a-plugins-daily-check-in). + The gateway issues **gateway keys** for clients, separate from a provider's upstream API keys. Turn on **Settings → Share on local network**, then open **Gateway → Gateway keys → Add gateway key**. This block appears only while diff --git a/docs/subsystems/provider-plugins.md b/docs/subsystems/provider-plugins.md index 7747a2c3..234f44a8 100644 --- a/docs/subsystems/provider-plugins.md +++ b/docs/subsystems/provider-plugins.md @@ -7,7 +7,7 @@ Some built-in subscriptions are deprecated and have community plugins that provi | Part | Responsibility | Source | | --- | --- | --- | | Provider migration | Match subscriptions to plugins, record ownership, transfer accounts, and maintain required plugin versions | [`migrate.go`](../../internal/provider/migrate.go) and `migrate_*.go` in the same directory | -| Provider integration | Expose plugin-backed provider cards, usage, and sign-in | [`plugins.go`](../../internal/provider/plugins.go), [`plugin_usage.go`](../../internal/provider/plugin_usage.go), [`pluginsignin.go`](../../internal/provider/pluginsignin.go) | +| Provider integration | Expose plugin-backed provider cards, usage, sign-in, and the plugin's own daily check-in | [`plugins.go`](../../internal/provider/plugins.go), [`plugin_usage.go`](../../internal/provider/plugin_usage.go), [`pluginsignin.go`](../../internal/provider/pluginsignin.go), [`plugin_checkin.go`](../../internal/provider/plugin_checkin.go) | | Plugin host | Load plugins and execute their code | [`internal/plugin`](../../internal/plugin), [`host.js`](../../internal/plugin/host.js) | | Gateway | Dispatch requests through the implementation that owns the subscription | [`internal/gateway`](../../internal/gateway) | | GUI | Present available subscriptions, migration state, and plugin sign-in | [`app.js`](../../internal/gui/assets/app.js) | @@ -30,6 +30,16 @@ For a moved subscription, its built-in upstream implementation does not run. Plu Quota aggregation in [`quotas.go`](../../internal/provider/quotas.go) suppresses a key's GLM Coding Plan card only when its comparable reset windows match a ZCode subscription card: built-in or moved `zcode`, or independently installed `zcode-plugin`. [`PlanQuotas`](../../internal/provider/planquota.go) identifies GLM plans by their quota endpoint, including custom provider ids. Matching reset schedules from unrelated vendors do not hide cards. Within this pair, resets remain a heuristic for the shared account; a plan's `User` is a key label or mask, not a login identity. Errors, conflicting resets, or no comparable resets keep the plan visible. This applies to the Usage page, tray, quota reports, alerts, and quota waits; it does not change plugin ownership or upstream requests. +## A plugin's daily check-in + +A plugin can press its vendor's daily check-in (签到) itself: its `auth` hook gains `checkin(getAuth, provider)`, beside `usage`. The host lists the provider with `checkin: true` (`plugin.Provider.Checkin`) and answers `checkin({provider, account})` (`plugin.AccountCheckin`) by refreshing the account as `usage` does and calling the hook with the account's auth. + +The hook returns `{outcome, credit?, streak?, message?}`. `outcome` is one of `claimed` (checked in now), `done` (already in today), `ineligible`, `inactive` (no check-in event now), `captcha` or `failed`. A throw, or an outcome not in that list, is `failed` with the reason. `captcha` means the vendor wants a captcha: magpie never solves one, and says "check in in its own app" instead. A plugin must not solve captchas either. + +[`plugin_checkin.go`](../../internal/provider/plugin_checkin.go) runs these through the same `checkiner` as the built-in check-ins ([`checkin.go`](../../internal/provider/checkin.go)): each signed-in, in-use account once a Beijing day, the first look 2 minutes after start and then every 30 minutes, a `failed` asked again after 30 minutes, every other outcome settled for the day. Results are kept in `plugin-checkin.json` by `provider|account`; a failure is recorded as failed and never resets an account, a key or a previous day's result. Each account's Usage card has the check-in row (`checkinBy: "plugin:"`), and Settings → Usage has a Plugins tab with a Daily check-in switch per provider (`settings.PluginCheckins`, `POST /api/settings/plugin-checkin {provider, on}`; *Check in now* is `POST /api/usage/plugin-checkin {provider}`). `magpie accounts checkin` and `c` on the TUI's Usage page include them. + +The switch is off by default. For a provider whose vendor magpie checked in through the plugin's fetch before (WorkBuddy, Trae CN, MiniMax Code, Qoder), it follows that vendor's existing switch until set on its own, and magpie's own check-in leaves that provider's accounts to the plugin (`pluginChecksIn`), so an account is never checked in twice. + ## How ownership changes Migration records use four states: @@ -71,6 +81,8 @@ The GUI's plugin integration includes `subOf`, `pluginSubs`, and `startPluginSig Gateway parity tests in [`plugin_parity_test.go`](../../internal/gateway/plugin_parity_test.go) and other `plugin_*_test.go` files compare or exercise plugin paths. A built-in test alone does not establish moved-user behavior. Inspect each test's fixture to confirm that it covers the provider and operation being changed. +[`plugin_checkin_test.go`](../../internal/provider/plugin_checkin_test.go) runs the fake plugin's `auth.checkin` (`FAKE_CHECKIN`) through the schedule: off, each outcome, no second press the same day, failures only after the retry window, the next day, cards and Settings, and the vendor check-in stepping aside. `plugin-checkin.test.cjs` covers the card row and the Settings tab. + [`quotas_dedupe_test.go`](../../internal/provider/quotas_dedupe_test.go) checks unrelated reset collisions and ZCode deduplication, including plugin usage window conversion for both plugin ids. [`TestPlanQuotas`](../../internal/provider/planquota_test.go) checks the custom GLM endpoint, cached cards, and fallback readings restored from disk. These fixtures do not contact the live vendors or run the community ZCode plugin. Run the relevant package tests with an isolated home directory. Resolve GOPATH and Go's caches before changing HOME, so repeated runs reuse dependencies and downloaded toolchains. The subshell makes temporary files writable and cleans up its home on exit, including when tests fail, and returns the test command's exit status. diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index c2e3bd68..15b40ca4 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -416,6 +416,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error { go provider.KeepMiniMaxCheckedIn(ctx) // and the Qoder ones' daily credits go provider.KeepQoderCheckedIn(ctx) + // and the accounts of the plugins that check in themselves + go provider.KeepPluginsCheckedIn(ctx) // and moves the built-in subscriptions being retired onto their plugins go provider.KeepRetiringMoved(ctx) // and keeps the community's plugins up to date, noting others' updates, and the Bun they run on diff --git a/internal/gui/api.go b/internal/gui/api.go index 7ac5fcb5..3048df0c 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -15,6 +15,7 @@ import ( "errors" "fmt" "io/fs" + "maps" "mime" "net/http" "os" @@ -383,6 +384,9 @@ type settingsJSON struct { // and a Qoder or Qoder CN account (its plugin's), and theirs Qoder bool `json:"qoder"` QoderCheckins []provider.WorkBuddyCheckin `json:"qoderCheckins,omitempty"` + // and the plugins' providers that check in themselves (auth.checkin), + // each with its switch and its accounts' last check-ins + CheckinPlugins []provider.PluginCheckin `json:"checkinPlugins,omitempty"` // FX is the dollar-to-yuan rate the cny currency choice shows costs at FX fxJSON `json:"fx"` // NotifyProblem is why a usage alert set wouldn't be seen: "denied" @@ -498,6 +502,7 @@ func settingsState() settingsJSON { s.Trae, s.TraeCheckins = provider.HasTrae(), provider.TraeCheckins() s.MiniMax, s.MiniMaxCheckins = provider.HasMiniMax(), provider.MiniMaxCheckins() s.Qoder, s.QoderCheckins = provider.HasQoder(), provider.QoderCheckins() + s.CheckinPlugins = provider.PluginCheckins() s.VisionAuto, s.VisionModels = gateway.AutoVision(), []modelRef{} for _, e := range provider.Served() { if e.Images && (e.ImageInput == nil || *e.ImageInput) && (e.Group != "" || e.Provider.Ready()) { @@ -962,8 +967,9 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { // agent, not by "/" — so they are not among them, // and belong to the Agents page. in.Visible, in.HiddenModels, in.OrderedModels = cur.Visible, cur.HiddenModels, cur.OrderedModels - in.FastPicks = cur.FastPicks // switched in the agents' pickers (#954) - in.AgentEfforts = cur.AgentEfforts // picked in an agent's row (#1003) + in.FastPicks = cur.FastPicks // switched in the agents' pickers (#954) + in.AgentEfforts = cur.AgentEfforts // picked in an agent's row (#1003) + in.PluginCheckins = cur.PluginCheckins // set on its own (plugin-checkin below) settings.CarryPerModel(&in, &cur) in.LAN, in.LANKey = cur.LAN, cur.LANKey in.LANKeyID = cur.LANKeyID @@ -1316,6 +1322,32 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { } writeJSON(rw, settingsState()) }) + // and a plugin's own daily check-in, by its provider + mux.HandleFunc("POST /api/settings/plugin-checkin", func(rw http.ResponseWriter, r *http.Request) { + var in struct { + Provider string + On bool + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(rw, err) + return + } + if in.Provider == "" { + fail(rw, errors.New("no provider")) + return + } + s := settings.Load() + s.PluginCheckins = maps.Clone(s.PluginCheckins) + if s.PluginCheckins == nil { + s.PluginCheckins = map[string]bool{} + } + s.PluginCheckins[in.Provider] = in.On + if err := settings.Save(s); err != nil { + fail(rw, err) + return + } + writeJSON(rw, settingsState()) + }) // how large the window and the panel are drawn: Settings' choice and // Ctrl/Cmd +, − and 0 in either, set on its own so a key pressed while // the Settings page saves something else is never undone by it diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 6bb68aaa..caf3141a 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -13736,10 +13736,23 @@ const CHECKINS = { off: "Claim each Qoder account's daily credits once a day, as Settings' Daily check-in does", }, }; +// pluginCheckin is the check-in of a plugin that presses it itself +// (auth.checkin; Lemon on Discord), by its provider: its switch is its own. +function pluginCheckin(id, name) { + return { + plugin: id, api: "plugin-checkin", + say: t("{name}'s daily check-in, pressed by its plugin", { name }), + on: t("On: magpie has the {name} plugin check each account in once a day, as Settings' Daily check-in does. Click to turn it off.", { name }), + off: t("Have the {name} plugin check each account in once a day, as Settings' Daily check-in does", { name }), + }; +} +function pluginCheckinOn(id) { + return !!(state.settings?.checkinPlugins || []).find((p) => p.id === id)?.on; +} function checkinRow(q, first, subs) { const by = q.checkinBy || ""; - const vendor = CHECKINS[by] || CHECKINS[""]; - const on = !!state.settings?.[vendor.pref]; + const vendor = by.startsWith("plugin:") ? pluginCheckin(by.slice(7), q.name || by.slice(7)) : CHECKINS[by] || CHECKINS[""]; + const on = vendor.plugin ? pluginCheckinOn(vendor.plugin) : !!state.settings?.[vendor.pref]; const r = q.checkin; const today = wbToday(); const row = el("div", "wb-checkin"); @@ -13758,6 +13771,10 @@ function checkinRow(q, first, subs) { case "inactive": text = t("No check-in event now"); break; + case "captcha": + // the vendor wants a captcha, which magpie never solves + text = t("Asks for a captcha; check in in its own app") + (r.msg ? " · " + r.msg : ""); + break; default: kind = "bad"; // why, in the row: a reason only in the tooltip read as no reason (#808) @@ -13782,7 +13799,7 @@ function checkinRow(q, first, subs) { e.stopPropagation(); auto.disabled = true; try { - prefs = await writingPrefs(api("settings/" + vendor.api, { on: !on })); + prefs = await writingPrefs(api("settings/" + vendor.api, vendor.plugin ? { provider: vendor.plugin, on: !on } : { on: !on })); state.settings = prefs; status(t(on ? "Daily check-in turned off" : "Daily check-in turned on; magpie checks in within a few minutes"), "ok"); renderQuotas(); @@ -13803,7 +13820,7 @@ function checkinRow(q, first, subs) { now.disabled = true; now.classList.add("busy"); try { - const rs = await api("usage/" + vendor.api, {}); + const rs = await api("usage/" + vendor.api, vendor.plugin ? { provider: vendor.plugin } : {}); const bad = (rs || []).filter((x) => x.outcome === "failed").length; status(bad ? t("Check-in failed for {n} account(s)", { n: bad }) : t("Checked in"), bad ? "err" : "ok"); } catch (err) { @@ -16803,7 +16820,7 @@ const DISCORD_SVG = ' { + const row = el("div", "row pref"); + row.dataset.provider = p.id; + const who = el("div", "who"); + const sub = el("div", "sub", [t("Has the {name} plugin check each signed-in account in once a day", { name: p.name || p.id }), + ...(p.checkins || []).map(wbCheckinLine)].filter(Boolean).join(" · ")); + who.append(el("div", "name", (p.name || p.id) + " · " + t("Daily check-in")), sub); + const segBox = el("div"); + segBox.append(segs([["off", t("Off")], ["on", t("On")]], p.on ? "on" : "off", async (v) => { + try { + prefs = await writingPrefs(api("settings/plugin-checkin", { provider: p.id, on: v === "on" })); + state.settings = prefs; + renderPluginCheckins(prefs); + } catch (err) { + status(err.message, "err"); + } + })); + row.append(who, segBox); + return row; + })); +} + // wbCheckinLine is how an account's last WorkBuddy check-in went: today's // (a Beijing day) with the credits and the streak, an earlier one by its day. function wbCheckinLine(r) { @@ -18761,6 +18809,8 @@ function wbCheckinLine(r) { return t("{user} is not eligible", { user: r.user }); case "inactive": return t("{user}: no check-in event now", { user: r.user }); + case "captcha": + return t("{user} is asked for a captcha; check in in the app", { user: r.user }); default: return t("{user} couldn't check in, tried again later", { user: r.user }); } diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 90d87cad..88822de2 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -593,6 +593,12 @@ const I18N = { "Auto check-in is off": "自动签到已关闭", "WorkBuddy's daily check-in, as pressing 签到 in WorkBuddy does": "WorkBuddy 的每日签到,和在 WorkBuddy 里点「签到」一样", "Auto check-in": "自动签到", + "{name}'s daily check-in, pressed by its plugin": "{name} 的每日签到,由它的插件来签", + "On: magpie has the {name} plugin check each account in once a day, as Settings' Daily check-in does. Click to turn it off.": "已开启:magpie 每天让 {name} 插件给每个账号签到一次,和设置里的「每日签到」一样。点一下关闭。", + "Have the {name} plugin check each account in once a day, as Settings' Daily check-in does": "让 {name} 插件每天给每个账号签到一次,和设置里的「每日签到」一样", + "Asks for a captcha; check in in its own app": "需要验证码,请在它自己的 App 里签到", + "{user} is asked for a captcha; check in in the app": "{user} 需要验证码,请在 App 里签到", + "Has the {name} plugin check each signed-in account in once a day": "每天让 {name} 插件给每个已登录的账号签到一次", "On: magpie checks each WorkBuddy (China) account in once a day, as Settings' Daily check-in does. Click to turn it off.": "已开启:每天为每个 WorkBuddy 国内版账号签到一次,与设置中的「每日自动签到」为同一开关。点击关闭。", "Check each WorkBuddy (China) account in once a day, as Settings' Daily check-in does": "每天为每个 WorkBuddy 国内版账号签到一次,与设置中的「每日自动签到」为同一开关", "Daily check-in turned off": "已关闭每日自动签到", @@ -4075,6 +4081,12 @@ const I18N = { "Auto check-in is off": "自動チェックインはオフです", "WorkBuddy's daily check-in, as pressing 签到 in WorkBuddy does": "WorkBuddy の毎日のチェックイン(WorkBuddy で「签到」を押すのと同じ)", "Auto check-in": "自動チェックイン", + "{name}'s daily check-in, pressed by its plugin": "{name} のデイリーチェックイン(プラグインが行います)", + "On: magpie has the {name} plugin check each account in once a day, as Settings' Daily check-in does. Click to turn it off.": "オン:設定のデイリーチェックインと同じく、magpie が {name} プラグインに各アカウントを 1 日 1 回チェックインさせます。クリックでオフにします。", + "Have the {name} plugin check each account in once a day, as Settings' Daily check-in does": "設定のデイリーチェックインと同じく、{name} プラグインに各アカウントを 1 日 1 回チェックインさせる", + "Asks for a captcha; check in in its own app": "CAPTCHA が必要です。公式アプリでチェックインしてください", + "{user} is asked for a captcha; check in in the app": "{user} は CAPTCHA が必要です。アプリでチェックインしてください", + "Has the {name} plugin check each signed-in account in once a day": "{name} プラグインがサインイン中の各アカウントを 1 日 1 回チェックインします", "On: magpie checks each WorkBuddy (China) account in once a day, as Settings' Daily check-in does. Click to turn it off.": "オン:magpie が各 WorkBuddy(中国版)アカウントを 1 日 1 回チェックインします。設定の「毎日のチェックイン」と同じです。クリックでオフにします。", "Check each WorkBuddy (China) account in once a day, as Settings' Daily check-in does": "各 WorkBuddy(中国版)アカウントを 1 日 1 回チェックインします。設定の「毎日のチェックイン」と同じです", "Daily check-in turned off": "毎日のチェックインをオフにしました", @@ -7560,6 +7572,12 @@ const I18N = { "Auto check-in is off": "Automatischer Check-in ist aus", "WorkBuddy's daily check-in, as pressing 签到 in WorkBuddy does": "Täglicher Check-in von WorkBuddy, wie ein Klick auf 签到 in WorkBuddy", "Auto check-in": "Automatischer Check-in", + "{name}'s daily check-in, pressed by its plugin": "Täglicher Check-in bei {name}, vom Plugin erledigt", + "On: magpie has the {name} plugin check each account in once a day, as Settings' Daily check-in does. Click to turn it off.": "An: magpie lässt das {name}-Plugin jedes Konto einmal täglich einchecken, wie der tägliche Check-in in den Einstellungen. Klicken zum Ausschalten.", + "Have the {name} plugin check each account in once a day, as Settings' Daily check-in does": "Das {name}-Plugin jedes Konto einmal täglich einchecken lassen, wie der tägliche Check-in in den Einstellungen", + "Asks for a captcha; check in in its own app": "Verlangt ein Captcha; in der eigenen App einchecken", + "{user} is asked for a captcha; check in in the app": "{user} soll ein Captcha lösen; in der App einchecken", + "Has the {name} plugin check each signed-in account in once a day": "Lässt das {name}-Plugin jedes angemeldete Konto einmal täglich einchecken", "On: magpie checks each WorkBuddy (China) account in once a day, as Settings' Daily check-in does. Click to turn it off.": "An: magpie checkt jedes WorkBuddy-Konto (China) einmal täglich ein, wie „Täglicher Check-in“ in den Einstellungen. Zum Ausschalten klicken.", "Check each WorkBuddy (China) account in once a day, as Settings' Daily check-in does": "Jedes WorkBuddy-Konto (China) einmal täglich einchecken, wie „Täglicher Check-in“ in den Einstellungen", "Daily check-in turned off": "Täglicher Check-in ausgeschaltet", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 464d658b..0efe674f 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -357,6 +357,7 @@ +
@@ -403,6 +404,7 @@
+
diff --git a/internal/gui/settings_keep_test.go b/internal/gui/settings_keep_test.go index 7c31c7fc..c81967c1 100644 --- a/internal/gui/settings_keep_test.go +++ b/internal/gui/settings_keep_test.go @@ -115,6 +115,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) { OrderedModels: map[string][]string{"codex": {"group/g", "p/n"}}, FastPicks: map[string][]string{"codex": {"p/n"}}, AgentEfforts: map[string]string{"cursor-local": "high"}, + PluginCheckins: map[string]bool{"fakeco": true}, ModelNames: map[string]string{"p/m": "Mine"}, ModelEfforts: map[string][]string{"p/m": {"low"}}, ModelImages: map[string]bool{"p/m": true}, diff --git a/internal/gui/tests/plugin-checkin.test.cjs b/internal/gui/tests/plugin-checkin.test.cjs new file mode 100644 index 00000000..3b7467b8 --- /dev/null +++ b/internal/gui/tests/plugin-checkin.test.cjs @@ -0,0 +1,154 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Lemon on Discord: "provider 每日签到的方法能放到插件里面去做吗". A plugin +// whose auth hook has checkin presses its vendor's daily check-in itself, and +// magpie shows it as it shows its own: the account's Usage card has the row +// (checkinBy "plugin:"), with Auto check-in posting +// /api/settings/plugin-checkin {provider, on} and Check in now posting +// /api/usage/plugin-checkin {provider}; a captcha the vendor asks for is said +// as such, never as a failure, as magpie never solves one. Settings' check-in +// tabs gain Plugins while such a plugin is signed in, a row a provider with +// Off/On. English, Chinese, Japanese and German, the window and the panel's +// 440px; the API is faked here. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const today = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10); +const quotas = () => [ + { provider: "plugin:fakeco", name: "FakeCo", plan: "Pro", user: "ann", windows: [{ name: "Credits", used: 12 }], + checkins: true, checkinBy: "plugin:fakeco", checkin: { user: "ann", by: "plugin:fakeco", vendor: "FakeCo", day: today, outcome: "claimed", credit: 50, streak: 3 } }, + { provider: "plugin:fakeco", name: "FakeCo", plan: "Free", user: "bob", windows: [{ name: "Credits", used: 2 }], + checkins: true, checkinBy: "plugin:fakeco", checkin: { user: "bob", by: "plugin:fakeco", vendor: "FakeCo", day: today, outcome: "captcha", msg: "slide the puzzle" } }, + { provider: "codex", name: "Codex", icon: "openai", plan: "Plus", windows: [{ name: "5 hours", used: 20 }] }, +]; + +function serve(lang, asked) { + const settings = { theme: "light", lang, quotaLeft: false, currency: "usd", + checkinPlugins: [{ id: "fakeco", name: "FakeCo", on: false, checkins: [ + { user: "ann", by: "plugin:fakeco", vendor: "FakeCo", day: today, outcome: "claimed", credit: 50, streak: 3 }, + { user: "bob", by: "plugin:fakeco", vendor: "FakeCo", day: today, outcome: "captcha" }, + ] }] }; + return async (route) => { + const url = new URL(route.request().url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings }); + if (url.pathname === "/api/settings/plugin-checkin") { + const b = route.request().postDataJSON(); + asked.push(["set", b]); + settings.checkinPlugins = settings.checkinPlugins.map((p) => (p.id === b.provider ? { ...p, on: b.on } : p)); + return json(settings); + } + if (url.pathname === "/api/usage/plugin-checkin") { + asked.push(["now", route.request().postDataJSON()]); + return json([{ user: "bob", by: "plugin:fakeco", day: today, outcome: "captcha" }]); + } + if (url.pathname === "/api/settings") return json(settings); + if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } }); + if (url.pathname === "/api/usage/quotas") return json(quotas()); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); } + }; +} + +const words = { + en: { done: "Checked in today +50 · 3-day streak", captcha: "Asks for a captcha; check in in its own app · slide the puzzle", auto: "Auto check-in", tab: "Plugins", sub: /FakeCo plugin check each signed-in account.*bob is asked for a captcha/ }, + zh: { done: "今日已签到 +50 · 连续 3 天", captcha: "需要验证码,请在它自己的 App 里签到 · slide the puzzle", auto: "自动签到", tab: "插件", sub: /FakeCo 插件.*bob 需要验证码/ }, + ja: { captcha: "CAPTCHA が必要です。公式アプリでチェックインしてください · slide the puzzle", tab: "プラグイン", sub: /FakeCo プラグイン.*bob は CAPTCHA が必要です/ }, + de: { captcha: "Verlangt ein Captcha; in der eigenen App einchecken · slide the puzzle", tab: "Erweiterungen", sub: /FakeCo-Plugin.*bob soll ein Captcha lösen/ }, +}; + +const scrolls = (page) => page.evaluate(() => [scrollY, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => e.scrollTop)].join(",")); + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh", "ja", "de"]) { + const w = words[lang]; + test(`${engine} ${lang}: a plugin's own check-in on its Usage card`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 900, height: 900 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + const errors = [], asked = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, asked)); + await page.goto("http://magpie.test/?view=usage"); + const card = page.locator(".subscription-card", { hasText: "FakeCo" }); + await card.locator(".wb-checkin").first().waitFor(); + const rows = card.locator(".wb-checkin"); + assert.equal(await rows.count(), 2, "one line an account"); + const says = (await rows.locator(".ci-say").allInnerTexts()).map((s) => s.trim()); + if (w.done) assert.equal(says[0], w.done); + assert.equal(says[1], w.captcha, "a captcha is said as such"); + assert.notEqual(await rows.nth(1).getAttribute("data-state"), "bad", "a captcha isn't a failure"); + assert.match(await rows.first().locator(".ci-say").getAttribute("title"), /FakeCo/); + assert.equal(await page.locator(".subscription-card", { hasText: "Codex" }).locator(".wb-checkin").count(), 0); + const auto = card.locator(".ci-auto"); + assert.equal(await auto.count(), 1); + if (w.auto) assert.equal((await auto.innerText()).trim(), w.auto); + assert.match(await auto.getAttribute("title"), /FakeCo/); + assert.equal(await auto.getAttribute("aria-pressed"), "false"); + + const y = await scrolls(page); + await auto.click(); + await page.waitForFunction(() => document.querySelector(".ci-auto")?.getAttribute("aria-pressed") === "true"); + assert.deepEqual(asked, [["set", { provider: "fakeco", on: true }]]); + assert.equal(await scrolls(page), y, "the toggle moved the page"); + assert.deepEqual(errors, []); + }); + + test(`${engine} ${lang}: Settings has a Plugins check-in tab at 440px`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 440, height: 900 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + const errors = [], asked = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, asked)); + await page.goto("http://magpie.test/?view=settings&tab=usage"); + const tab = page.locator("#warmTab-plugins"); + await tab.waitFor(); + assert.equal(await tab.isHidden(), false); + assert.equal((await tab.textContent()).trim(), w.tab); + assert.equal(await page.locator("#warmTab-qoder").isHidden(), true, "Qoder's tab with no Qoder account"); + // at 440x900 the tabs are below the fold: the reader scrolls to them + await page.mouse.move(220, 400); + for (let i = 0; i < 10; i++) { + const r = await tab.boundingBox(), foot = await page.locator("footer.foot").boundingBox(); + if (r.y + r.height < foot.y - 8) break; + await page.mouse.wheel(0, 200); + await page.waitForTimeout(100); + } + await tab.click(); + const row = page.locator('#pluginCheckinList .row[data-provider="fakeco"]'); + await row.waitFor(); + assert.match(await row.locator(".name").innerText(), /FakeCo/); + assert.match((await row.locator(".sub").innerText()).replace(/\s+/g, " "), w.sub); + const box = await row.boundingBox(); + assert(box.width > 0 && box.x + box.width <= 440, "the row fits at 440px"); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, "no sideways scroll"); + if (process.env.ARTIFACT_DIR) await row.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-checkin-${engine}-${lang}.png`) }); + // and on to its switch, under the foot in a long language + const on = row.locator(".segs button").nth(1); + for (let i = 0; i < 10; i++) { + const r = await on.boundingBox(), foot = await page.locator("footer.foot").boundingBox(); + if (r.y + r.height < foot.y - 8) break; + await page.mouse.wheel(0, 200); + await page.waitForTimeout(100); + } + const saved = page.waitForRequest((r) => new URL(r.url()).pathname === "/api/settings/plugin-checkin"); + await on.click(); + await saved; + assert.deepEqual(asked, [["set", { provider: "fakeco", on: true }]]); + assert.equal(await page.locator("select").count(), 0); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/gui/usage.go b/internal/gui/usage.go index 740fd39b..7f72e3f2 100644 --- a/internal/gui/usage.go +++ b/internal/gui/usage.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -505,6 +506,21 @@ func usageRoutes(mux *http.ServeMux, w Windows) { } writeJSON(rw, rs) }) + // and a plugin's own, for each account of the provider it names + mux.HandleFunc("POST /api/usage/plugin-checkin", func(rw http.ResponseWriter, r *http.Request) { + var in struct{ Provider string } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Provider == "" { + fail(rw, errors.New("no provider")) + return + } + ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + defer cancel() + rs := provider.CheckInPlugins(ctx, in.Provider) + if rs == nil { + rs = []provider.WorkBuddyCheckin{} + } + writeJSON(rw, rs) + }) // what was left of each window over time, for the quota cards' curves // (#651); ?days= back mux.HandleFunc("GET /api/usage/quotas/history", func(rw http.ResponseWriter, r *http.Request) { diff --git a/internal/plugin/host.js b/internal/plugin/host.js index efdd6d26..4fe304df 100644 --- a/internal/plugin/host.js +++ b/internal/plugin/host.js @@ -1039,6 +1039,7 @@ async function providers({ proxies } = {}) { methods: methods(a.auth), icon: iconOf(a), usage: typeof a.auth.usage === "function", + checkin: typeof a.auth.checkin === "function", maxConcurrency: concurrencyOf(a), signedIn: keys.length > 0, authType: first?.type ?? "", @@ -1388,6 +1389,43 @@ async function usageOf(provider, account) { } } +// ---- check-in ---------------------------------------------------------------- + +// checkin presses the vendor's daily check-in (签到) for the account at key, +// as the plugin's auth.checkin does it (magpie's own hook, which OpenCode +// ignores; Lemon on Discord: 签到 belongs in the plugins): +// auth.checkin(getAuth, provider) → { +// outcome: "claimed" (checked in now) | "done" (in already today) | +// "ineligible" (the account can't take part) | "inactive" (no +// check-in running) | "captcha" (the vendor asks for a captcha: the +// user checks in in its own app; never solved) | "failed", +// credit? (what it gave), streak? (days in a row), message? +// } +// magpie asks once a Beijing day per account while the user has it on, and +// again later that day after a failure; a hook that throws is a failure. +// Run in the account's scope, a token it renews is saved to that account. +async function checkin({ provider, account, proxy }) { + return via.run(proxy ?? "", () => checkinOf(provider, account)) +} + +const checkinOutcomes = ["claimed", "done", "ineligible", "inactive", "captcha", "failed"] + +async function checkinOf(provider, account) { + const a = auths().get(provider)?.auth + if (typeof a?.checkin !== "function") throw new Error(`${provider}'s plugin doesn't check in`) + const key = accountKey(provider, account) + if (!readAuth()[key]) throw new Error("not signed in") + await fresh(provider, key) + const p = await info(provider, key) + const r = (await inScope(provider, key, () => a.checkin(async () => readAuth()[key], JSON.parse(JSON.stringify(p))))) ?? {} + const num = (v) => (typeof v === "number" && isFinite(v) ? Math.max(0, v) : 0) + const message = typeof r.message === "string" ? r.message : "" + if (!checkinOutcomes.includes(r.outcome)) { + return { outcome: "failed", credit: 0, streak: 0, message: message || `the plugin answered no outcome (${JSON.stringify(r.outcome ?? null)})` } + } + return { outcome: r.outcome, credit: num(r.credit), streak: Math.round(num(r.streak)), message } +} + // sdkHeaders are what the AI SDK package the model is on sends of the key. function sdkHeaders(npm, key) { if (!key) return {} @@ -1527,6 +1565,7 @@ const handlers = { apiKey, load, usage, + checkin, // check tries one account as a request would: its loader, then its // models as the plugin lists them for it, then its usage, which asks the // vendor of the account itself. refused is the models hook saying the diff --git a/internal/plugin/providers.go b/internal/plugin/providers.go index b41e7886..410ebaf7 100644 --- a/internal/plugin/providers.go +++ b/internal/plugin/providers.go @@ -83,7 +83,10 @@ type Provider struct { // an https URL or a data:image URI (internal/provider keeps it) Icon string `json:"icon,omitempty"` // Usage says the plugin tells each account's allowance (auth.usage) - Usage bool `json:"usage"` + Usage bool `json:"usage"` + // Checkin says the plugin presses its vendor's daily check-in for each + // account (auth.checkin) + Checkin bool `json:"checkin,omitempty"` SignedIn bool `json:"signedIn"` AuthType string `json:"authType"` AccountID string `json:"accountId"` @@ -840,6 +843,24 @@ type UsageWindow struct { Aside bool `json:"aside"` } +// Checkin is what came of an account's daily check-in, as the plugin's +// auth.checkin said: Outcome is one of claimed, done, ineligible, +// inactive, captcha and failed. +type Checkin struct { + Outcome string `json:"outcome"` + Credit float64 `json:"credit"` + Streak int `json:"streak"` + Message string `json:"message"` +} + +// AccountCheckin asks the plugin to check account of provider in for the +// day. +func AccountCheckin(ctx context.Context, provider, account string) (Checkin, error) { + var c Checkin + err := Call(ctx, "checkin", map[string]any{"provider": provider, "account": account, "proxy": proxyOf(ctx)}, &c) + return c, err +} + // AccountUsage asks the plugin for account's usage of provider. func AccountUsage(ctx context.Context, provider, account string) (Usage, error) { var u Usage diff --git a/internal/plugin/testdata/fake/index.js b/internal/plugin/testdata/fake/index.js index fd57fac7..39e35b46 100644 --- a/internal/plugin/testdata/fake/index.js +++ b/internal/plugin/testdata/fake/index.js @@ -115,6 +115,33 @@ export const FakePlugin = async ({ client }) => ({ ], } }, + // $FAKE_CHECKIN: the plugin presses the daily check-in itself, each + // press written as a line of the account's key to that file, and the + // answer said by the key: ck-claim gives 50 on a 3-day streak, ck-done + // is in already, ck-captcha is asked a captcha, ck-throw can't reach + // the vendor, ck-odd answers no outcome magpie knows + ...(process.env.FAKE_CHECKIN + ? { + checkin: async (getAuth) => { + const a = await getAuth() + const fs = await import("node:fs") + fs.appendFileSync(process.env.FAKE_CHECKIN, (a.key ?? a.accountId ?? "") + "\n") + switch (a.key) { + case "ck-claim": + return { outcome: "claimed", credit: 50, streak: 3 } + case "ck-done": + return { outcome: "done", credit: 50 } + case "ck-captcha": + return { outcome: "captcha", message: "slide the puzzle" } + case "ck-throw": + throw new Error("FakeCo's check-in is down") + case "ck-odd": + return { outcome: "yes" } + } + return { outcome: "inactive" } + }, + } + : {}), }, // the models an account has: refused for a dead one; a "rot-" sign-in // spends its refresh token asking, as a rotating one does diff --git a/internal/provider/minimax_checkin.go b/internal/provider/minimax_checkin.go index 172cbd85..07049f26 100644 --- a/internal/provider/minimax_checkin.go +++ b/internal/provider/minimax_checkin.go @@ -276,7 +276,7 @@ func miniMaxCards(accts []miniMaxAccount) []checkinCard { func miniMaxCheckinAccounts() []miniMaxAccount { var out []miniMaxAccount for _, site := range []string{MiniMaxCodeID, MiniMaxCodeGlobalID} { - if pp, ok := PluginOf(site); ok { + if pp, ok := PluginOf(site); ok && !pluginChecksIn(pp) { out = append(out, miniMaxSiteAccounts(site, pp)...) } } diff --git a/internal/provider/plugin_checkin.go b/internal/provider/plugin_checkin.go new file mode 100644 index 00000000..63fda7e1 --- /dev/null +++ b/internal/provider/plugin_checkin.go @@ -0,0 +1,216 @@ +package provider + +// A plugin's own daily check-in (Lemon on Discord: provider 每日签到的方法能 +// 放到插件里面去做吗): a plugin whose auth hook has checkin presses its +// vendor's 签到 itself, and magpie runs it as it runs its own check-ins +// (checkin.go): each account in use once a Beijing day while the user has +// it on, a failure tried again later that day, what came of it on the +// account's Usage card and in Settings. A plugin that checks in takes over +// from magpie's own check-in through its fetch (WorkBuddy, Trae CN, +// MiniMax Code, Qoder), so an account is never checked in twice. +// +// What came of it is kept in plugin-checkin.json by provider and account. + +import ( + "context" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/settings" +) + +// pluginCheckinBy is the WorkBuddyCheckin.By of a plugin's check-in. +func pluginCheckinBy(id string) string { return "plugin:" + id } + +func pluginCheckinPath() string { return filepath.Join(filepath.Dir(Path()), "plugin-checkin.json") } + +var pluginCheckinMu sync.Mutex + +// pluginCheckinAcct is a plugin's account checked in. +type pluginCheckinAcct struct { + User string + On bool + key string // provider|account + card string + do func(context.Context) WorkBuddyCheckin +} + +// pluginCheckinProvider is a plugin's provider that checks in, and its +// accounts. +type pluginCheckinProvider struct { + ID, Name string + accounts []pluginCheckinAcct +} + +// pluginCheckinProviders are the plugins' providers whose plugin checks +// in, those signed in to. +var pluginCheckinProviders = func() []pluginCheckinProvider { + var out []pluginCheckinProvider + for _, pp := range heldPlugins() { + if !pp.Checkin || movingNow(pp.ID) { + continue + } + p := pluginCheckinProvider{ID: pp.ID, Name: pp.Name} + card := PluginID(pp.ID) + for _, l := range pluginLogins(pp) { + acct := l.acct.Key + who := l.acct.AccountID + if who == "" { + who = acct + } + id := pp.ID + p.accounts = append(p.accounts, pluginCheckinAcct{User: l.User, On: l.On, key: id + "|" + who, card: card, + do: func(ctx context.Context) WorkBuddyCheckin { + c, err := plugin.AccountCheckin(ctx, id, acct) + if err != nil { + return wbCheckinFailed(err) + } + return WorkBuddyCheckin{Outcome: c.Outcome, Credit: c.Credit, Streak: c.Streak, Msg: c.Message} + }}) + } + if len(p.accounts) > 0 { + out = append(out, p) + } + } + return out +} + +// pluginChecksIn says whether pp's plugin presses the check-in itself: +// magpie's own check-in through its fetch leaves its accounts to it. +func pluginChecksIn(pp plugin.Provider) bool { return pp.Checkin } + +// PluginCheckinOn says whether a plugin provider's accounts are checked in +// each day: as the user set it for the provider, else as the built-in +// check-in of its vendor is set, which the plugin took over. +func PluginCheckinOn(s settings.Settings, id string) bool { + if on, ok := s.PluginCheckins[id]; ok { + return on + } + switch id { + case wbCN.id: + return s.WorkBuddyCheckin + case TraeCNID: + return s.TraeCheckin + case MiniMaxCodeID, MiniMaxCodeGlobalID: + return s.MiniMaxCheckin + case "qoder", QoderCNID: + return s.QoderCheckin + } + return false +} + +func (p pluginCheckinProvider) checkiner(now func() time.Time) checkiner { + return checkiner{path: pluginCheckinPath(), now: now, mu: &pluginCheckinMu, by: pluginCheckinBy(p.ID), label: p.ID, accounts: func() []checkinAcct { + var out []checkinAcct + for _, a := range p.accounts { + out = append(out, checkinAcct{User: a.User, On: a.On, key: a.key, do: a.do}) + } + return out + }} +} + +// pluginCheckiner checks in the plugins' accounts: of those whose +// provider only says, all of them when only is nil. +type pluginCheckiner struct { + now func() time.Time + only func(id string) bool +} + +func (c pluginCheckiner) clock() time.Time { return c.now() } + +func (c pluginCheckiner) checkinNow(ctx context.Context, soon bool) []WorkBuddyCheckin { + var out []WorkBuddyCheckin + for _, p := range pluginCheckinProviders() { + if c.only != nil && !c.only(p.ID) { + continue + } + for _, r := range p.checkiner(c.now).checkinNow(ctx, soon) { + r.Vendor = p.Name + out = append(out, r) + } + } + return out +} + +// CheckInPlugins checks the accounts of each plugin that checks in in for +// today now, those not in yet; with ids, only those providers'. It says +// how each stands. +func CheckInPlugins(ctx context.Context, ids ...string) []WorkBuddyCheckin { + c := pluginCheckiner{now: time.Now} + if len(ids) > 0 { + c.only = func(id string) bool { + for _, x := range ids { + if x == id { + return true + } + } + return false + } + } + return c.checkinNow(ctx, true) +} + +// PluginCheckin is a plugin's provider that checks in, for Settings: its +// switch and how each account's last check-in went. +type PluginCheckin struct { + ID string `json:"id"` + Name string `json:"name"` + On bool `json:"on"` + Checkins []WorkBuddyCheckin `json:"checkins,omitempty"` +} + +// PluginCheckins are the plugins' providers that check in, each with its +// accounts' last check-ins as kept; asks nothing. +func PluginCheckins() []PluginCheckin { + st := readCheckins(pluginCheckinPath()) + s := settings.Load() + var out []PluginCheckin + for _, p := range pluginCheckinProviders() { + pc := PluginCheckin{ID: p.ID, Name: p.Name, On: PluginCheckinOn(s, p.ID)} + listed := map[string]bool{} + for _, a := range p.accounts { + if r, ok := st[a.key]; ok && a.On && !listed[a.key] { + listed[a.key] = true + r.User, r.By, r.Vendor = a.User, pluginCheckinBy(p.ID), p.Name + pc.Checkins = append(pc.Checkins, r) + } + } + sort.SliceStable(pc.Checkins, func(i, j int) bool { return pc.Checkins[i].User < pc.Checkins[j].User }) + out = append(out, pc) + } + return out +} + +// HasPluginCheckin says whether a plugin that checks in is signed in. +func HasPluginCheckin() bool { return len(pluginCheckinProviders()) > 0 } + +// pluginCheckinMarks is qs with the cards of the plugins' accounts that +// check in marked, as WithCheckins marks the built-ins'. +func pluginCheckinMarks(qs []SubscriptionQuota) []SubscriptionQuota { + ps := pluginCheckinProviders() + if len(ps) == 0 { + return qs + } + st := readCheckins(pluginCheckinPath()) + for _, p := range ps { + var on []checkinCard + for _, a := range p.accounts { + if a.On { + on = append(on, checkinCard{User: a.User, card: a.card, key: a.key}) + } + } + qs = markCheckins(qs, on, st, pluginCheckinBy(p.ID)) + } + return qs +} + +// KeepPluginsCheckedIn checks the plugins' accounts in each day, each +// provider's while settings say to, as KeepWorkBuddyCheckedIn does +// WorkBuddy's. +func KeepPluginsCheckedIn(ctx context.Context) { + c := pluginCheckiner{now: time.Now, only: func(id string) bool { return PluginCheckinOn(settings.Load(), id) }} + keepCheckedIn(ctx, "plugin", c, func() bool { return true }) +} diff --git a/internal/provider/plugin_checkin_test.go b/internal/provider/plugin_checkin_test.go new file mode 100644 index 00000000..dfb8997d --- /dev/null +++ b/internal/provider/plugin_checkin_test.go @@ -0,0 +1,232 @@ +package provider + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/settings" +) + +// pluginCheckinSandbox is the fake plugin with its own check-in, signed in +// with each key; it says the file each press is written to. +func pluginCheckinSandbox(t *testing.T, keys ...string) string { + t.Helper() + bun, err := exec.LookPath("bun") + if err != nil { + t.Skip("no bun on PATH") + } + home := claudeHome(t) + t.Setenv("MAGPIE_BUN", bun) + presses := filepath.Join(home, "presses") + t.Setenv("FAKE_CHECKIN", presses) + t.Cleanup(plugin.Settle) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + abs, _ := filepath.Abs("../plugin/testdata/fake/index.js") + if _, err := plugin.Add(ctx, abs); err != nil { + t.Fatal(err) + } + if _, err := plugin.Providers(ctx); err != nil { + t.Fatal(err) + } + for _, k := range keys { + if _, err := PluginAPIKey(ctx, "fakeco", 0, nil, k); err != nil { + t.Fatal(err) + } + } + return presses +} + +// pressed is the keys the plugin's check-in was pressed for, in order. +func pressed(t *testing.T, file string) []string { + t.Helper() + b, err := os.ReadFile(file) + if os.IsNotExist(err) { + return nil + } + if err != nil { + t.Fatal(err) + } + return strings.Fields(string(b)) +} + +// A plugin's own check-in (auth.checkin; Lemon on Discord) runs as magpie's +// own do: each account once a Beijing day while it is on, a failure asked +// again after wbCheckinRetry, a captcha left for the user (never retried +// that day), and what came of each kept, on its card and in Settings. +func TestPluginCheckinRunsAsTheBuiltinsDo(t *testing.T) { + keys := []string{"ck-claim", "ck-done", "ck-captcha", "ck-throw", "ck-odd"} + presses := pluginCheckinSandbox(t, keys...) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + ps := pluginCheckinProviders() + if len(ps) != 1 || ps[0].ID != "fakeco" || ps[0].Name != "FakeCo" || len(ps[0].accounts) != len(keys) { + t.Fatalf("the plugins that check in: %+v", ps) + } + if !HasPluginCheckin() { + t.Fatal("HasPluginCheckin is false") + } + + now := time.Date(2026, 10, 6, 10, 0, 0, 0, beijing) + loop := pluginCheckiner{now: func() time.Time { return now }, only: func(id string) bool { return PluginCheckinOn(settings.Load(), id) }} + + // off (as it is by default): nothing pressed + if rs := loop.checkinNow(ctx, false); len(rs) != 0 || len(pressed(t, presses)) != 0 { + t.Fatalf("while off: %+v, pressed %q", rs, pressed(t, presses)) + } + s := settings.Load() + s.PluginCheckins = map[string]bool{"fakeco": true} + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + + rs := loop.checkinNow(ctx, false) + if len(rs) != len(keys) { + t.Fatalf("on: %d answers, want %d: %+v", len(rs), len(keys), rs) + } + // by what each answered: one account per key + got := map[string]WorkBuddyCheckin{} + for _, r := range rs { + if r.By != "plugin:fakeco" || r.Vendor != "FakeCo" || !r.Asked || r.Day != "2026-10-06" { + t.Fatalf("an answer: %+v", r) + } + k := r.Outcome + if k == CheckinFailed { + k += ":" + r.Msg + } + got[k] = r + } + if r := got[CheckinClaimed]; r.Credit != 50 || r.Streak != 3 { + t.Fatalf("ck-claim: %+v (%+v)", r, rs) + } + if r := got[CheckinDone]; r.Credit != 50 { + t.Fatalf("ck-done: %+v (%+v)", r, rs) + } + if r := got[CheckinCaptcha]; r.Msg != "slide the puzzle" { + t.Fatalf("ck-captcha: %+v (%+v)", r, rs) + } + failed := 0 + for k := range got { + if strings.Contains(k, "check-in is down") || strings.Contains(k, "no outcome") { + failed++ + } + } + if failed != 2 { + t.Fatalf("the failures (ck-throw, ck-odd): %+v", rs) + } + if p := pressed(t, presses); len(p) != len(keys) { + t.Fatalf("pressed %q", p) + } + + // a minute on: nothing asked again, each answered as kept + now = now.Add(time.Minute) + if rs := loop.checkinNow(ctx, false); len(rs) != len(keys) || len(pressed(t, presses)) != len(keys) { + t.Fatalf("a minute on: %+v, pressed %q", rs, pressed(t, presses)) + } + // past wbCheckinRetry: the failures only, not the captcha + now = now.Add(wbCheckinRetry) + loop.checkinNow(ctx, false) + if p := pressed(t, presses)[len(keys):]; strings.Join(slices.Sorted(slices.Values(p)), ",") != "ck-odd,ck-throw" { + t.Fatalf("past the retry, pressed again %q", p) + } + // the next Beijing day: each again + now = time.Date(2026, 10, 7, 0, 5, 0, 0, beijing) + loop.checkinNow(ctx, false) + if p := pressed(t, presses); len(p) != 2*len(keys)+2 { + t.Fatalf("the next day, pressed %q", p) + } + + // kept, for Settings + pcs := PluginCheckins() + if len(pcs) != 1 || pcs[0].ID != "fakeco" || !pcs[0].On || len(pcs[0].Checkins) != len(keys) { + t.Fatalf("PluginCheckins = %+v", pcs) + } + // and on each account's card + var qs []SubscriptionQuota + for _, a := range ps[0].accounts { + qs = append(qs, SubscriptionQuota{Provider: PluginID("fakeco"), User: a.User}) + } + marked := WithCheckins(qs) + for i, q := range marked { + if !q.Checkins || q.CheckinBy != "plugin:fakeco" || q.Checkin == nil || q.Checkin.Day != "2026-10-07" { + t.Fatalf("card %d: %+v %+v", i, q, q.Checkin) + } + } + if qs[0].Checkins { + t.Fatal("WithCheckins changed the cards it was given") + } + // a press now (Check in now, the CLI) asks the failure again at once, + // whatever the switch; another provider's press asks none of these + before := len(pressed(t, presses)) + if rs := CheckInPlugins(ctx, "someone-else"); len(rs) != 0 || len(pressed(t, presses)) != before { + t.Fatalf("another provider's press: %+v", rs) + } +} + +// A plugin that checks in itself takes over from magpie's own check-in +// through its fetch, so an account isn't checked in twice; and its switch +// follows the vendor's, which it took over, until set on its own. +func TestPluginCheckinTakesOverTheVendors(t *testing.T) { + bun, err := exec.LookPath("bun") + if err != nil { + t.Skip("no bun on PATH") + } + claudeHome(t) + t.Setenv("MAGPIE_BUN", bun) + t.Setenv("FAKE_ID", "qoder") + t.Cleanup(plugin.Settle) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + abs, _ := filepath.Abs("../plugin/testdata/fake/index.js") + if _, err := plugin.Add(ctx, abs); err != nil { + t.Fatal(err) + } + if _, err := plugin.Providers(ctx); err != nil { + t.Fatal(err) + } + if _, err := plugin.APIKey(ctx, "qoder", 0, nil, "ck-claim", plugin.NewAccount); err != nil { + t.Fatal(err) + } + if _, err := plugin.Providers(ctx); err != nil { + t.Fatal(err) + } + // a plugin without auth.checkin: magpie claims Qoder's credits itself + if n := len(qoderCheckinAccounts()); n != 1 || HasPluginCheckin() { + t.Fatalf("without the hook: %d Qoder accounts, plugins that check in %v", n, HasPluginCheckin()) + } + // with it, the plugin does, and magpie's own lists none + t.Setenv("FAKE_CHECKIN", filepath.Join(t.TempDir(), "presses")) + plugin.Restart() + if _, err := plugin.Providers(ctx); err != nil { + t.Fatal(err) + } + if n := len(qoderCheckinAccounts()); n != 0 || !HasPluginCheckin() { + t.Fatalf("with the hook: %d Qoder accounts, plugins that check in %v", n, HasPluginCheckin()) + } + for _, c := range []struct { + s settings.Settings + id string + want bool + }{ + {settings.Settings{QoderCheckin: true}, "qoder", true}, + {settings.Settings{QoderCheckin: true}, QoderCNID, true}, + {settings.Settings{QoderCheckin: true, PluginCheckins: map[string]bool{"qoder": false}}, "qoder", false}, + {settings.Settings{WorkBuddyCheckin: true}, "workbuddy", true}, + {settings.Settings{TraeCheckin: true}, TraeCNID, true}, + {settings.Settings{MiniMaxCheckin: true}, MiniMaxCodeGlobalID, true}, + {settings.Settings{QoderCheckin: true}, "fakeco", false}, + {settings.Settings{PluginCheckins: map[string]bool{"fakeco": true}}, "fakeco", true}, + } { + if got := PluginCheckinOn(c.s, c.id); got != c.want { + t.Errorf("PluginCheckinOn(%+v, %s) = %v, want %v", c.s, c.id, got, c.want) + } + } +} diff --git a/internal/provider/qoder_checkin.go b/internal/provider/qoder_checkin.go index d7ed591f..4a35606d 100644 --- a/internal/provider/qoder_checkin.go +++ b/internal/provider/qoder_checkin.go @@ -245,7 +245,7 @@ func qoderCards(accts []qoderCheckinAcct) []checkinCard { func qoderCheckinAccounts() []qoderCheckinAcct { var out []qoderCheckinAcct for _, pp := range heldPlugins() { - if _, ok := qoderCampaignURLs[pp.ID]; !ok || movingNow(pp.ID) { + if _, ok := qoderCampaignURLs[pp.ID]; !ok || movingNow(pp.ID) || pluginChecksIn(pp) { continue } auths := plugin.Auths(pp.ID) diff --git a/internal/provider/trae_checkin.go b/internal/provider/trae_checkin.go index f410f454..18126531 100644 --- a/internal/provider/trae_checkin.go +++ b/internal/provider/trae_checkin.go @@ -209,7 +209,7 @@ func traeCards(accts []traeAccount) []checkinCard { // the plugin's fetch, which signs it as the account. func traeCheckinAccounts() []traeAccount { pp, ok := PluginOf(TraeCNID) - if !ok { + if !ok || pluginChecksIn(pp) { return nil } auths := plugin.Auths(pp.ID) diff --git a/internal/provider/workbuddy_checkin.go b/internal/provider/workbuddy_checkin.go index 74f4b463..f14538e6 100644 --- a/internal/provider/workbuddy_checkin.go +++ b/internal/provider/workbuddy_checkin.go @@ -69,6 +69,9 @@ const ( CheckinIneligible = "ineligible" // the account can't take part CheckinInactive = "inactive" // no event running, or it has ended CheckinFailed = "failed" // no answer; tried again later that day + // CheckinCaptcha is a plugin's check-in the vendor asked a captcha + // of: the user checks in in its own app, magpie never solves one + CheckinCaptcha = "captcha" ) // wbCheckinSoon is how long a check-in that never reached WorkBuddy waits @@ -103,6 +106,9 @@ type WorkBuddyCheckin struct { // By is the vendor whose check-in it is: "" WorkBuddy's, "trae" // Trae CN's; never kept. By string `json:"by,omitempty"` + // Vendor is a plugin's provider by name, for a check-in By + // "plugin:"; never kept. + Vendor string `json:"vendor,omitempty"` // Asked is true of one checked in on this run, not one read back. Asked bool `json:"-"` } @@ -292,7 +298,8 @@ func WithCheckins(qs []SubscriptionQuota) []SubscriptionQuota { qs = withCheckins(qs, wbCheckinAccounts(), readCheckins(wbCheckinPath())) qs = markCheckins(qs, traeCards(traeCheckinAccounts()), readCheckins(traeCheckinPath()), "trae") qs = markCheckins(qs, miniMaxCards(miniMaxCheckinAccounts()), readCheckins(miniMaxCheckinPath()), "minimax") - return markCheckins(qs, qoderCards(qoderCheckinAccounts()), readCheckins(qoderCheckinPath()), "qoder") + qs = markCheckins(qs, qoderCards(qoderCheckinAccounts()), readCheckins(qoderCheckinPath()), "qoder") + return pluginCheckinMarks(qs) } func withCheckins(qs []SubscriptionQuota, accts []wbAccount, st map[string]WorkBuddyCheckin) []SubscriptionQuota { @@ -361,13 +368,13 @@ func HasWorkBuddy() bool { return len(wbCheckinAccounts()) > 0 } func wbCheckinAccounts() []wbAccount { if Moved(wbCN.id) { pp, ok := PluginOf(wbCN.id) - if !ok { + if !ok || pluginChecksIn(pp) { return nil } return wbPluginAccounts(pp) } out := wbLogins(wbCN) - if pp, ok := PluginOf(PluginID(wbCN.id)); ok && pp.ID == wbCN.id { + if pp, ok := PluginOf(PluginID(wbCN.id)); ok && pp.ID == wbCN.id && !pluginChecksIn(pp) { out = append(out, wbPluginAccounts(pp)...) } return out diff --git a/internal/settings/settings.go b/internal/settings/settings.go index cf4f2ab9..8595098b 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -146,6 +146,10 @@ type Settings struct { // and Qoder CN account (its plugin's) once a Beijing day (ARNO on // Discord). QoderCheckin bool `json:"qoderCheckin,omitempty"` + // PluginCheckins turns a plugin's own daily check-in (auth.checkin) + // on or off, by the plugin's provider id; one not set follows its + // vendor's switch above, which the plugin took over, else is off. + PluginCheckins map[string]bool `json:"pluginCheckins,omitempty"` // MemberModel has a reply's model name the routing group's member // that answered, as magpie's provider/model id (workbuddy/glm-5.3-flash), // rather than the vendor's own name for it, for agents that count diff --git a/internal/tui/checkin_test.go b/internal/tui/checkin_test.go index e5f622fb..d0356524 100644 --- a/internal/tui/checkin_test.go +++ b/internal/tui/checkin_test.go @@ -15,6 +15,7 @@ func stubCheckin(t *testing.T, rs []provider.WorkBuddyCheckin) *int { t.Helper() stubTrae(t, nil) stubQoder(t, nil) + stubPluginCheckin(t, nil) oldHere, oldHas := checkinHere, hasWorkBuddy t.Cleanup(func() { checkinHere, hasWorkBuddy = oldHere, oldHas }) calls := 0 @@ -54,6 +55,21 @@ func stubMiniMax(t *testing.T, rs []provider.WorkBuddyCheckin) *int { return &calls } +// stubPluginCheckin stands in for the accounts of plugins that check in +// themselves (auth.checkin), as stubCheckin does WorkBuddy's. +func stubPluginCheckin(t *testing.T, rs []provider.WorkBuddyCheckin) *int { + t.Helper() + oldHere, oldHas := checkinPl, hasPlugin + t.Cleanup(func() { checkinPl, hasPlugin = oldHere, oldHas }) + calls := 0 + hasPlugin = func() bool { return rs != nil } + checkinPl = func(context.Context) []provider.WorkBuddyCheckin { + calls++ + return rs + } + return &calls +} + func stubQoder(t *testing.T, rs []provider.WorkBuddyCheckin) *int { t.Helper() oldHere, oldHas := checkinQd, hasQoder @@ -78,7 +94,7 @@ func TestTUIChecksWorkBuddyIn(t *testing.T) { // nothing signed in: said so, nothing asked calls := stubCheckin(t, nil) m := press(t, usagePage, "c") - wantFlash(t, m, false, "no WorkBuddy (China), Trae CN, MiniMax Code or Qoder account is signed in") + wantFlash(t, m, false, "no WorkBuddy (China), Trae CN, MiniMax Code, Qoder or check-in plugin account is signed in") if *calls != 0 { t.Fatal("checked in with no account") } @@ -169,3 +185,22 @@ func TestCheckinOnTheUsageLine(t *testing.T) { } } } + +// c checks in the accounts of a plugin that checks in itself (auth.checkin; +// Lemon on Discord) with the built-ins', named by the plugin's vendor; a +// captcha is said as such, not as a failure. +func TestTUIChecksPluginsIn(t *testing.T) { + home(t) + usagePage := model{w: 200, h: 40, page: pageUsage} + stubCheckin(t, nil) + calls := stubPluginCheckin(t, []provider.WorkBuddyCheckin{ + {User: "a@x", By: "plugin:fakeco", Vendor: "FakeCo", Outcome: provider.CheckinClaimed, Credit: 50, Streak: 3}, + {User: "b@x", By: "plugin:fakeco", Vendor: "FakeCo", Outcome: provider.CheckinCaptcha, Msg: "slide the puzzle"}, + }) + m := press(t, usagePage, "c") + wantFlash(t, m, true, "FakeCo a@x") + wantFlash(t, m, true, "FakeCo b@x asks for a captcha: check in in its own app") + if *calls != 1 { + t.Fatalf("checked in %d times", *calls) + } +} diff --git a/internal/tui/pages.go b/internal/tui/pages.go index 1ec9ff90..e84d932b 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -616,6 +616,8 @@ var ( hasMiniMax = provider.HasMiniMax checkinQd = provider.CheckInQoder hasQoder = provider.HasQoder + checkinPl = func(ctx context.Context) []provider.WorkBuddyCheckin { return provider.CheckInPlugins(ctx) } + hasPlugin = provider.HasPluginCheckin ) // checkinCmd presses WorkBuddy's daily check-in (签到) now for every @@ -624,7 +626,8 @@ var ( // the built-in's or the plugin's (akic404 on Discord: the TUI had no way // to); and Trae CN's (每日签到) for each Trae CN account (#694), and // MiniMax Code's for each MiniMax Code account (#811), and Qoder's daily -// credits for each Qoder account. It says +// credits for each Qoder account, and each plugin's own check-in +// (auth.checkin) for its accounts. It says // how each account stands: the credits and streak, in // already today, or why not. A shared magpie's accounts are checked in // on that magpie, from its own app, TUI or CLI. @@ -665,8 +668,16 @@ func checkinCmd() tea.Msg { parts = append(parts, checkinWords(r)) } } + if hasPlugin() { + for _, r := range checkinPl(ctx) { + if r.Outcome == provider.CheckinFailed { + failed++ + } + parts = append(parts, checkinWords(r)) + } + } if len(parts) == 0 { - return checkinMsg{text: "no WorkBuddy (China), Trae CN, MiniMax Code or Qoder account is signed in · only they have the daily check-in"} + return checkinMsg{text: "no WorkBuddy (China), Trae CN, MiniMax Code, Qoder or check-in plugin account is signed in · only they have the daily check-in"} } return checkinMsg{text: strings.Join(parts, "; "), ok: failed == 0} } @@ -687,6 +698,8 @@ func checkinWords(r provider.WorkBuddyCheckin) string { who = "Qoder" case r.By == "qoder": who = "Qoder " + who + case r.Vendor != "": + who = strings.TrimSpace(r.Vendor + " " + who) case who == "": who = "WorkBuddy" } @@ -707,6 +720,8 @@ func checkinWords(r provider.WorkBuddyCheckin) string { return who + " isn't eligible for the check-in" case provider.CheckinInactive: return who + ": no check-in event now" + case provider.CheckinCaptcha: + return who + " asks for a captcha: check in in its own app" } msg := r.Msg if msg == "" { @@ -740,6 +755,8 @@ func checkinCell(q provider.SubscriptionQuota, now time.Time) string { return sMuted.Render("签到 not eligible") case provider.CheckinInactive: return sMuted.Render("签到 no event now") + case provider.CheckinCaptcha: + return sMuted.Render("签到 needs a captcha · check in in its app") } return sBad.Render("签到 failed") + sMuted.Render(" · c tries again") }