Files
magpie/internal/provider/qoder_checkin.go
T
yetone 87487c5ef0 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.
2026-10-06 18:40:45 +08:00

277 lines
8.8 KiB
Go

package provider
// Qoder's daily credits (ARNO on Discord: 100 Credits a day, claimed as
// wallechfox/qoder-checkin claims them): Qoder and Qoder CN list an
// account's campaigns on their openapi host, the day's credits among them
// (actionType CLAIM_BENEFIT), and claim one with a POST. While the setting
// is on, whichever magpie runs the gateway claims each claimable one for
// each account of @magpie-community/opencode-qoder-auth once a Beijing day.
// Both go through the plugin's fetch (0.2.7 and later), which sends them
// as the account, on its device token. The built-in's accounts, not moved
// onto the plugin, aren't checked in.
//
// What came of it is kept in qoder-checkin.json by site and account id, as
// WorkBuddy's and Trae CN's are (checkin.go).
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/plugin"
"github.com/yetone/magpie/internal/settings"
)
// qoderCampaignURLs are the campaigns of each site, by the plugin's
// provider; a claim is …/{id}/claim.
var qoderCampaignURLs = map[string]string{
"qoder": "https://openapi.qoder.sh/sash/api/v1/me/campaigns",
QoderCNID: "https://openapi.qoder.com.cn/sash/api/v1/me/campaigns",
}
// qoderCampaign is one of an account's campaigns. startAt and endAt bound
// the day it is for.
type qoderCampaign struct {
ID string `json:"campaignId"`
ActionType string `json:"actionType"`
ClaimStatus string `json:"claimStatus"`
StartAt float64 `json:"startAt"`
EndAt float64 `json:"endAt"`
Benefit struct {
Amount float64 `json:"amount"`
} `json:"benefit"`
}
type qoderClaim struct {
Status string `json:"status"`
Benefit struct {
Amount float64 `json:"amount"`
} `json:"benefit"`
Data *qoderClaim `json:"data"`
}
// qoderCheckinAcct is a Qoder account checked in: site is the plugin's
// provider it is signed in to, via sends a request as it.
type qoderCheckinAcct struct {
User string
On bool
site string
uid string
card string
via func(*http.Request) (*http.Response, error)
}
func qoderCheckinPath() string { return filepath.Join(filepath.Dir(Path()), "qoder-checkin.json") }
func qoderCheckinKey(a qoderCheckinAcct) string {
if a.uid == "" || a.site == "" {
return ""
}
return a.site + "|" + a.uid
}
var qoderCheckinMu sync.Mutex
func newQoderCheckiner(accounts func() []qoderCheckinAcct) checkiner {
return checkiner{path: qoderCheckinPath(), now: time.Now, mu: &qoderCheckinMu, by: "qoder", label: "qoder", accounts: func() []checkinAcct {
var out []checkinAcct
for _, a := range accounts() {
out = append(out, checkinAcct{User: a.User, On: a.On, key: qoderCheckinKey(a), do: func(ctx context.Context) WorkBuddyCheckin { return qoderCheckin(ctx, a, time.Now()) }})
}
return out
}}
}
// qoderCheckin claims a's claimable daily credits. None claimable: one
// claimed for the day now is a check-in done, no daily campaign at all is
// none running, and one neither is a failure, tried again later.
func qoderCheckin(ctx context.Context, a qoderCheckinAcct, now time.Time) WorkBuddyCheckin {
var list struct {
Campaigns []qoderCampaign `json:"campaigns"`
}
if err := qoderCall(ctx, a, http.MethodGet, qoderCampaignURLs[a.site], &list); err != nil {
return wbCheckinFailed(err)
}
var daily []qoderCampaign
for _, c := range list.Campaigns {
if c.ActionType == "CLAIM_BENEFIT" {
daily = append(daily, c)
}
}
var credit float64
claimed, failed := 0, 0
var why string
for _, c := range daily {
if c.ClaimStatus != "CLAIMABLE" || c.ID == "" {
continue
}
var got qoderClaim
err := qoderCall(ctx, a, http.MethodPost, qoderCampaignURLs[a.site]+"/"+url.PathEscape(c.ID)+"/claim", &got)
if got.Data != nil {
got = *got.Data
}
if err == nil && got.Status != "CLAIMED" {
err = fmt.Errorf("the claim answered %q", got.Status)
}
if err != nil {
failed++
why = err.Error()
continue
}
claimed++
if got.Benefit.Amount > 0 {
credit += got.Benefit.Amount
} else {
credit += c.Benefit.Amount
}
}
switch {
case failed > 0:
return WorkBuddyCheckin{Outcome: CheckinFailed, Msg: why}
case claimed > 0:
return WorkBuddyCheckin{Outcome: CheckinClaimed, Credit: credit}
case len(daily) == 0:
return WorkBuddyCheckin{Outcome: CheckinInactive}
}
for _, c := range daily {
if c.ClaimStatus == "CLAIMED" && qoderWhen(c.StartAt) <= now.Unix() && now.Unix() < qoderWhen(c.EndAt) {
return WorkBuddyCheckin{Outcome: CheckinDone, Credit: c.Benefit.Amount}
}
}
return WorkBuddyCheckin{Outcome: CheckinFailed, Msg: "today's credits aren't offered yet"}
}
// qoderWhen is a time Qoder gives, in seconds or milliseconds, as seconds.
func qoderWhen(v float64) int64 {
if v > 1e12 {
return int64(v / 1000)
}
return int64(v)
}
// qoderCall asks u as a and reads its answer.
func qoderCall(ctx context.Context, a qoderCheckinAcct, method, u string, dst any) error {
var rd io.Reader
if method == http.MethodPost {
rd = strings.NewReader("{}")
}
req, err := http.NewRequestWithContext(ctx, method, u, rd)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
res, err := a.via(req)
if err != nil {
return err
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if strings.EqualFold(res.Header.Get("X-Magpie-Sign-In"), "expired") || res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden {
return errors.New("Qoder's sign-in has expired — sign in again")
}
if res.StatusCode == http.StatusNotFound && strings.Contains(string(b), "only chat completions") {
// a plugin before 0.2.7 serves chat completions only
return errors.New("the Qoder plugin can't check in: update it to 0.2.7 or later")
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
var e struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
Message string `json:"message"`
}
if json.Unmarshal(b, &e) == nil {
if m := orStr(e.Error.Message, e.Message); strings.TrimSpace(m) != "" {
return fmt.Errorf("HTTP %d: %s", res.StatusCode, m)
}
}
return &accountStatusError{status: res.StatusCode}
}
return json.Unmarshal(b, dst)
}
// CheckInQoder checks each Qoder account in use in for today now, those
// not in yet, and says how each stands.
func CheckInQoder(ctx context.Context) []WorkBuddyCheckin {
return newQoderCheckiner(qoderCheckinAccounts).checkinNow(ctx, true)
}
// QoderCheckins is each Qoder account's last check-in, as kept, by its
// name now; asks nothing.
func QoderCheckins() []WorkBuddyCheckin {
st := readCheckins(qoderCheckinPath())
var out []WorkBuddyCheckin
listed := map[string]bool{}
for _, a := range qoderCheckinAccounts() {
key := qoderCheckinKey(a)
if r, ok := st[key]; ok && a.On && key != "" && !listed[key] {
listed[key] = true
r.User, r.By = a.User, "qoder"
out = append(out, r)
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].User < out[j].User })
return out
}
// HasQoder says whether a Qoder account of the plugin is signed in, on
// either site.
func HasQoder() bool { return len(qoderCheckinAccounts()) > 0 }
// qoderCards are the usage cards of accts, for WithCheckins.
func qoderCards(accts []qoderCheckinAcct) []checkinCard {
var out []checkinCard
for _, a := range accts {
if key := qoderCheckinKey(a); a.On && key != "" {
out = append(out, checkinCard{User: a.User, card: a.card, key: key})
}
}
return out
}
// qoderCheckinAccounts are the Qoder plugin's accounts on both sites,
// moved ones and ones signed in on the plugin beside the built-in, each
// sent through the plugin's fetch, which signs it as the account.
func qoderCheckinAccounts() []qoderCheckinAcct {
var out []qoderCheckinAcct
for _, pp := range heldPlugins() {
if _, ok := qoderCampaignURLs[pp.ID]; !ok || movingNow(pp.ID) || pluginChecksIn(pp) {
continue
}
auths := plugin.Auths(pp.ID)
card := PluginID(pp.ID)
for _, l := range pluginLogins(pp) {
key := l.acct.Key
out = append(out, qoderCheckinAcct{User: l.User, On: l.On, site: pp.ID, uid: str(auths[key]["uid"]), card: card,
via: func(req *http.Request) (*http.Response, error) {
h := map[string]string{}
for k, vs := range req.Header {
h[strings.ToLower(k)] = vs[0]
}
var body []byte
if req.Body != nil {
body, _ = io.ReadAll(req.Body)
}
return plugin.Fetch(req.Context(), plugin.FetchRequest{Provider: pp.ID, Account: key, URL: req.URL.String(), Method: req.Method, Headers: h, Body: body})
}})
}
}
return out
}
// KeepQoderCheckedIn checks the Qoder accounts in each day while settings
// say to, as KeepTraeCheckedIn does Trae CN's.
func KeepQoderCheckedIn(ctx context.Context) {
keepCheckedIn(ctx, "qoder", newQoderCheckiner(qoderCheckinAccounts), func() bool { return settings.Load().QoderCheckin })
}