plugin, provider: a plugin can press its vendor's daily check-in itself (auth.checkin), and magpie runs it as its own check-ins (Lemon on Discord #feedback: provider 每日签到的方法能放到插件里面去做吗; ARNO earlier asked for Qoder's) — the check-ins magpie had (WorkBuddy, Trae CN, MiniMax Code, Qoder) are each written in magpie against the vendor's endpoint, even for a subscription that moved to its plugin, so a plugin author had no way to give their provider one. A plugin's auth hook may now have checkin(getAuth, provider) beside usage: the host lists the provider with checkin: true (plugin.Provider.Checkin) and answers checkin({provider, account}) (plugin.AccountCheckin), refreshing the account as usage does, with {outcome, credit, streak, message}; outcome is claimed, done, ineligible, inactive, captcha or failed, and a throw or an outcome outside these is failed with the reason. internal/provider/plugin_checkin.go runs these through checkin.go's checkiner, as the built-ins are: each signed-in account in use once a Beijing day (first look 2 minutes after start, then every 30), a failure asked again after wbCheckinRetry, every other outcome settled for the day, results kept in plugin-checkin.json by provider|account; a failure is recorded as failed and resets nothing. captcha is new (CheckinCaptcha): the vendor wants a captcha, which magpie never solves, so it is said as "check in in its own app", not as a failure, and not asked again that day. The results mark the account's Usage card (checkinBy "plugin:<provider>", WithCheckins), so the card's check-in row, its Auto check-in and Check in now work for it (POST /api/settings/plugin-checkin {provider, on}, POST /api/usage/plugin-checkin {provider}); Settings → Usage gains a Plugins tab, shown while such a plugin is signed in, a Daily check-in row per provider with Off/On and how each account went; magpie accounts checkin and c on the TUI's Usage page include them (the TUI's "no account" line names check-in plugins). The switch (settings.PluginCheckins, by provider) is off by default; for WorkBuddy, Trae CN, MiniMax Code and Qoder it follows that vendor's existing switch until set on its own, and magpie's own check-in through the plugin's fetch leaves a provider whose plugin checks in to it (pluginChecksIn), so no account is checked in twice. The Settings page's save carries PluginCheckins over (TestSettingsSaveKeepsWhatItDoesNotSend's fixture names it). No plugin declares the hook yet; Qoder's check-in stays magpie's own here. TestPluginCheckinRunsAsTheBuiltinsDo (the fake plugin with FAKE_CHECKIN, five accounts answering claimed, done, captcha, a throw and an unknown outcome: off presses nothing; on, each outcome kept with its vendor; a minute on nothing pressed again; past the retry only the two failures; the next day all five; PluginCheckins and the cards marked; another provider's press asks none) and TestPluginCheckinTakesOverTheVendors (a qoder plugin without the hook leaves magpie's Qoder check-in its account, with it none; PluginCheckinOn's fallbacks) pass -race -count=10, and the latter fails with pluginChecksIn stubbed false; TestTUIChecksPluginsIn passes -race -count=20. plugin-checkin.test.cjs (the card's row with the captcha said, Auto check-in posting {provider, on}, no page move; the Settings Plugins tab at 440px with the row fitting, no sideways scroll, On posting) passes on Chromium and WebKit in en, zh, ja and de; usage-checkin, i18n-keys, gui-ja, gui-de and settings-* pass. go test -tags nogui ./internal/..., go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried with a real plugin or account: no published plugin has the hook yet. docs/subsystems/provider-plugins.md (the hook's contract) and docs/reference.md say it.
This commit is contained in:
1 parent
b56bfb5a94
commit
87487c5ef0
23 files changed
+919
-20
No files matched your search
+10
-2
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:<provider>"`), 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+34
-2
@@ -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 "<provider>/<model>" — 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
|
||||
|
||||
@@ -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 = '<svg viewBox="0 0 24 24" width="13" height="13" fill="curre
|
||||
// Codex's when it is not. A click on a tab leaves the page where it is, as
|
||||
// every click does (see "where the reader is"): a shorter card under it at
|
||||
// the page's end gets room kept at the view's foot.
|
||||
const WARM_TABS = { codex: "codexWarmList", claude: "claudeWarmList", wb: "wbList", trae: "traeList", minimax: "minimaxList", qoder: "qoderList" };
|
||||
const WARM_TABS = { codex: "codexWarmList", claude: "claudeWarmList", wb: "wbList", trae: "traeList", minimax: "minimaxList", qoder: "qoderList", plugins: "pluginCheckinList" };
|
||||
let warmTab = "codex";
|
||||
try { const k = localStorage.getItem("magpie.warmTab"); if (k in WARM_TABS) warmTab = k; } catch {}
|
||||
function setWarmTab(tab, remember) {
|
||||
@@ -17122,6 +17139,9 @@ function renderSettings() {
|
||||
$("#qoderCheckinSub").textContent = [t("Claims each signed-in Qoder account's daily credits once a day"),
|
||||
...(s.qoderCheckins || []).map(wbCheckinLine)].filter(Boolean).join(" · ");
|
||||
$("#qoderCheckinSub").title = t("As claiming the daily credits in Qoder does");
|
||||
// and the plugins that check in themselves, a row each, their tab shown
|
||||
// while one is signed in
|
||||
renderPluginCheckins(s);
|
||||
renderTrayUsage(s, keep);
|
||||
renderProxy(s, keep);
|
||||
renderGitHubToken(s);
|
||||
@@ -18745,6 +18765,34 @@ async function renderUpdate(r, u) {
|
||||
}
|
||||
}
|
||||
|
||||
// renderPluginCheckins draws a Daily check-in row for each plugin's
|
||||
// provider that checks in itself (auth.checkin), under the Plugins tab.
|
||||
function renderPluginCheckins(s) {
|
||||
const ps = s.checkinPlugins || [];
|
||||
$("#warmTab-plugins").hidden = !ps.length;
|
||||
setWarmTab(warmTab);
|
||||
$("#pluginCheckinList").replaceChildren(...ps.map((p) => {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -357,6 +357,7 @@
|
||||
<button type="button" role="tab" id="warmTab-trae" data-warm="trae" aria-controls="traeList" hidden>Trae CN</button>
|
||||
<button type="button" role="tab" id="warmTab-minimax" data-warm="minimax" aria-controls="minimaxList" hidden>MiniMax Code</button>
|
||||
<button type="button" role="tab" id="warmTab-qoder" data-warm="qoder" aria-controls="qoderList" hidden>Qoder</button>
|
||||
<button type="button" role="tab" id="warmTab-plugins" data-warm="plugins" aria-controls="pluginCheckinList" data-t hidden>Plugins</button>
|
||||
</div></div>
|
||||
<div class="warm-panes">
|
||||
<div class="list prefs" id="codexWarmList" role="tabpanel" aria-labelledby="warmTab-codex">
|
||||
@@ -403,6 +404,7 @@
|
||||
<div id="qoderCheckinSegs"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list prefs" id="pluginCheckinList" role="tabpanel" aria-labelledby="warmTab-plugins"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="set-page" id="setPage-network" role="tabpanel" aria-labelledby="setTab-network">
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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:<provider>"), 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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+27
@@ -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
|
||||
|
||||
@@ -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)...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:<id>"; 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -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")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user