From c7e965c4a470d53e6027616603ff3800154ecf23 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Sun, 18 Oct 2026 00:32:18 +0800 Subject: [PATCH] lane: the WorkBuddy lane speaks the CN publishment too, and checks in daily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel could only ever log into workbuddy.ai — wbAccountFromToken refused any other issuer outright, so a codebuddy.cn account could not be added at all. The two publishments now ride a realm table: chat on copilot.tencent.com, the billing meter on www.codebuddy.cn (a different host, which is why the meter 404'd when asked on the chat one), each with its own Origin/X-Domain/UA family. The realm is the login the user opened, unless the token's issuer names one — a silent issuer must not silently downgrade a CN account to intl. CN also earns its daily differently. intl claims it with a tiny conversation; CN has a real endpoint for it, /v2/billing/meter/ daily-checkin, which answers HTTP 400 with code 10001 once the day is claimed — so the day closes on that code too, instead of retrying hourly against a wall. The hourly tick runs whichever daily the account's realm has; the card grows a 版本 picker and a 签到 button for CN. The static free-model catalog is untouched: hy4-preview-f, hy3 and deepseek-v4.1-flash exist on both publishments. go vet ./... and go test ./... are green. --- cmd/zen-gate/main.go | 32 +++--- internal/gateway/admin.go | 4 +- internal/gateway/web/index.html | 34 +++++- internal/gateway/workbuddy.go | 62 ++++++++++- internal/lane/client.go | 4 + internal/lane/workbuddy.go | 127 ++++++++++++++++++--- internal/lane/workbuddy_account.go | 172 +++++++++++++++++++++++++---- internal/lane/workbuddy_test.go | 159 ++++++++++++++++++++++++++ 8 files changed, 534 insertions(+), 60 deletions(-) diff --git a/cmd/zen-gate/main.go b/cmd/zen-gate/main.go index ca25fdf..001e7be 100644 --- a/cmd/zen-gate/main.go +++ b/cmd/zen-gate/main.go @@ -236,23 +236,29 @@ func main() { ln.OnChange = syncEndpoints gw.SetModelsSync(syncEndpoints) ln.StartLoops(ctx, time.Duration(cfg.ProbeIntervalMinutes)*time.Minute) - // WorkBuddy 每日活跃:本地日历日一次,用免费模型发一条极小对话领取活跃 - // 积分。启动 90s 后先查一次(覆盖"当天已登录"的场景),之后每小时补查; - // 已完成/未登录的检查是纯日期比较,重复触发没有成本。 + // WorkBuddy 每日任务:本地日历日一次,按版本各走各的路 —— 国际版用免费 + // 模型发一条极小对话领活跃积分,国内版直接调 daily-checkin 签到(国际版 + // 没有这个端点)。启动 90s 后先查一次(覆盖"当天已登录"的场景),之后每 + // 小时补查;已完成/未登录的判断是纯日期比较,重复触发没有成本。 go func() { time.Sleep(90 * time.Second) - runWBActivity := func() { - ran, err := ln.WorkBuddyDailyActivity(ctx) - if !ran { - return + runWBDaily := func() { + if ran, err := ln.WorkBuddyDailyActivity(ctx); ran { + if err != nil { + logger.Infof("WorkBuddy 每日活跃未完成(下个小时重试): %v", err) + } else { + logger.Infof("WorkBuddy 每日活跃已完成") + } } - if err != nil { - logger.Infof("WorkBuddy 每日活跃未完成(下个小时重试): %v", err) - return + if ran, err := ln.WorkBuddyDailyCheckin(ctx); ran { + if err != nil { + logger.Infof("WorkBuddy 国内版签到未完成(下个小时重试): %v", err) + } else { + logger.Infof("WorkBuddy 国内版每日签到已完成") + } } - logger.Infof("WorkBuddy 每日活跃已完成") } - runWBActivity() + runWBDaily() t := time.NewTicker(time.Hour) defer t.Stop() for { @@ -260,7 +266,7 @@ func main() { case <-ctx.Done(): return case <-t.C: - runWBActivity() + runWBDaily() } } }() diff --git a/internal/gateway/admin.go b/internal/gateway/admin.go index cdbfead..a66035e 100644 --- a/internal/gateway/admin.go +++ b/internal/gateway/admin.go @@ -118,9 +118,11 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request, rest string case rest == "workbuddy/status" && r.Method == http.MethodGet: s.adminWorkBuddyStatus(w, r) case rest == "workbuddy/login/start" && r.Method == http.MethodPost: - s.adminWorkBuddyLoginStart(w) + s.adminWorkBuddyLoginStart(w, r) case rest == "workbuddy/login/poll" && r.Method == http.MethodGet: s.adminWorkBuddyLoginPoll(w) + case rest == "workbuddy/checkin" && r.Method == http.MethodPost: + s.adminWorkBuddyCheckin(w) case rest == "workbuddy/logout" && r.Method == http.MethodPost: s.adminWorkBuddyLogout(w) case rest == "providers" && r.Method == http.MethodPost: diff --git a/internal/gateway/web/index.html b/internal/gateway/web/index.html index e529cfc..195044d 100644 --- a/internal/gateway/web/index.html +++ b/internal/gateway/web/index.html @@ -1192,10 +1192,15 @@

CodeArts 账号渠道(华为云) -

WorkBuddy 账号渠道(国际版)

+

WorkBuddy 账号渠道

加载中…
+ +
@@ -1349,15 +1354,24 @@

添加供应商

setToggle("#tg-workbuddy", !!st.enabled); const lg = $("#wb-logout"); if(lg) lg.style.display = st.loggedIn ? "" : "none"; + const realmSel = $("#wb-realm"); + if(realmSel) realmSel.style.display = st.loggedIn ? "none" : ""; + const ck = $("#wb-checkin"); + if(ck) ck.style.display = st.canCheckin ? "" : "none"; if(!st.loggedIn){ state.textContent = "未登录"; - body.innerHTML = '用 WorkBuddy(www.workbuddy.ai)账号授权后,三个免费模型(hy4-preview-f / hy3 / deepseek-v4.1-flash)直接进入「模型」页和所有 Agent 选择器,每日自动活跃领取积分。免费模型走 24h 窗口限量;提示词可能被上游记录,别交机密内容。'; + body.innerHTML = '用 WorkBuddy / CodeBuddy 账号授权后,免费模型(hy4-preview-f / hy3 / deepseek-v4.1-flash)直接进入「模型」页和所有 Agent 选择器。国际版每日自动发一条极小对话领活跃积分,国内版每日自动签到。免费模型走 24h 窗口限量;提示词可能被上游记录,别交机密内容。'; return; } state.textContent = st.enabled ? "已启用" : "已停用"; const exp = st.expiresAtMs ? new Date(st.expiresAtMs).toLocaleString() : "未知"; - body.innerHTML = '账号 ' + (st.nickname || st.uid || "-") + ' · 登录有效期至 ' + exp + - ' · 积分 ' + (st.credits === "" ? "—" : st.credits) + ' · 上次活跃 ' + (st.lastActivityDate || "—") + + const realmLabel = st.realm === "cn" ? "国内版 codebuddy.cn" : "国际版 workbuddy.ai"; + const dailyLabel = st.realm === "cn" + ? ' · 上次签到 ' + (st.lastCheckinDate || "—") + : ' · 上次活跃 ' + (st.lastActivityDate || "—"); + body.innerHTML = '账号 ' + (st.nickname || st.uid || "-") + ' · ' + realmLabel + + ' · 登录有效期至 ' + exp + + ' · 积分 ' + (st.credits === "" ? "—" : st.credits) + '' + dailyLabel + (st.detail ? '
' + st.detail + '' : ''); } function bindWorkBuddyCard(){ @@ -1367,7 +1381,8 @@

添加供应商

$("#wb-login").onclick = async () => { const b = $("#wb-login"); b.disabled = true; - const j = await api("/workbuddy/login/start", {}); + const realm = ($("#wb-realm") && $("#wb-realm").value) || "intl"; + const j = await api("/workbuddy/login/start", { realm }); b.disabled = false; if (!j || !j.authUrl) return; // 服务端已用系统默认浏览器打开授权页(复用你已有的 workbuddy.ai 登录态); @@ -1393,6 +1408,15 @@

添加供应商

} }, 2000); }; + $("#wb-checkin").onclick = async () => { + const b = $("#wb-checkin"); + b.disabled = true; + const j = await api("/workbuddy/checkin", {}); + b.disabled = false; + if (!j) { renderWorkBuddyCard(); return; } + toast(j.skipped ? "今日已签到(每日一次)" : "国内版每日签到已完成"); + renderWorkBuddyCard(); + }; $("#wb-logout").onclick = async () => { const j = await api("/workbuddy/logout", {}); if (j) toast("已退出 WorkBuddy 账号"); diff --git a/internal/gateway/workbuddy.go b/internal/gateway/workbuddy.go index 5095757..d2865c7 100644 --- a/internal/gateway/workbuddy.go +++ b/internal/gateway/workbuddy.go @@ -2,7 +2,10 @@ package gateway import ( "context" + "encoding/json" + "io" "net/http" + "strings" "sync" "time" @@ -55,15 +58,58 @@ func (s *Server) adminWorkBuddyStatus(w http.ResponseWriter, r *http.Request) { "nickname": acct.Nickname, "expiresAtMs": acct.ExpiresAtMS, "lastActivityDate": acct.LastActivityDate, - "credits": credits, - "detail": detail, + "lastCheckinDate": acct.LastCheckinDate, + "realm": acct.Realm, + // canCheckin is the card's cue to show the 签到 button: only the CN + // publishment publishes the endpoint, and only for non-enterprise + // accounts. False never hides a button that would have worked — the + // lane re-checks on the click itself. + "canCheckin": acct.Realm == lane.WBRealmCN && acct.AccessToken != "", + "credits": credits, + "detail": detail, }) } +// adminWorkBuddyCheckin runs the CN daily checkin on demand. The scheduler +// claims it once a day on its own; this is the "did it actually land?" button. +func (s *Server) adminWorkBuddyCheckin(w http.ResponseWriter) { + ran, err := s.Lane.WorkBuddyDailyCheckin(context.Background()) + if err != nil { + writeJSON(w, 502, map[string]any{"ok": false, "error": err.Error()}) + return + } + if !ran { + // Either not logged in, not a CN account, or already claimed today. + writeJSON(w, 200, map[string]any{"ok": true, "skipped": true}) + return + } + if s.logger != nil { + s.logger.Infof("WorkBuddy 国内版每日签到已完成") + } + writeJSON(w, 200, map[string]any{"ok": true}) +} + // adminWorkBuddyLoginStart mints a login state and hands the auth URL back; -// the card opens it in a new tab and starts polling. -func (s *Server) adminWorkBuddyLoginStart(w http.ResponseWriter) { - authURL, err := s.Lane.WorkBuddyLoginStart() +// the card opens it in a new tab and starts polling. ?realm=cn opens the +// login against the CN publishment instead — the two do not share a login +// state, so the choice has to be made before the browser is sent anywhere. +// the card posts it (api() sends a JSON body); both accepted so the endpoint +// does not care which way the caller talks. +func (s *Server) adminWorkBuddyLoginStart(w http.ResponseWriter, r *http.Request) { + realm := strings.TrimSpace(r.URL.Query().Get("realm")) + if realm == "" { + var in struct { + Realm string `json:"realm"` + } + if data, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)); err == nil { + _ = json.Unmarshal(data, &in) + } + realm = strings.TrimSpace(in.Realm) + } + if realm != lane.WBRealmCN { + realm = lane.WBRealmIntl + } + authURL, err := s.Lane.WorkBuddyLoginStart(realm) if err != nil { writeJSON(w, 502, map[string]any{"ok": false, "error": err.Error()}) return @@ -74,7 +120,11 @@ func (s *Server) adminWorkBuddyLoginStart(w http.ResponseWriter) { // mechanism as the tray's 打开管理页. tray.Open(authURL) if s.logger != nil { - s.logger.Infof("WorkBuddy 登录已开始,授权页已在默认浏览器打开") + if realm == lane.WBRealmCN { + s.logger.Infof("WorkBuddy 国内版登录已开始,授权页已在默认浏览器打开") + } else { + s.logger.Infof("WorkBuddy 登录已开始,授权页已在默认浏览器打开") + } } writeJSON(w, 200, map[string]any{"ok": true, "authUrl": authURL}) } diff --git a/internal/lane/client.go b/internal/lane/client.go index 38bf75c..1b136cf 100644 --- a/internal/lane/client.go +++ b/internal/lane/client.go @@ -111,6 +111,10 @@ type Lane struct { wbRefreshedAt time.Time wbLoginState string wbLoginAt time.Time + // wbLoginRealm is the region the in-flight login was opened against. The + // browser finishes the login on that publishment, so the state poll and + // the token it mints must both go back to the same one. + wbLoginRealm string probing atomicFlag lastProbeAt time.Time diff --git a/internal/lane/workbuddy.go b/internal/lane/workbuddy.go index 569d59e..9ac310b 100644 --- a/internal/lane/workbuddy.go +++ b/internal/lane/workbuddy.go @@ -40,6 +40,72 @@ import ( // WorkBuddyBase is the intl gateway root; override for tests. var WorkBuddyBase = "https://www.workbuddy.ai" +// The CN publishment does not live on one host: it chats on +// copilot.tencent.com but bills and checks in on www.codebuddy.cn, so a +// single "base per realm" cannot express it. Both are vars for the same +// reason WorkBuddyBase is — tests point them at a stub. +var ( + WorkBuddyCNBase = "https://copilot.tencent.com" + WorkBuddyCNBilling = "https://www.codebuddy.cn" +) + +// The two publishments this lane can log into. Everything that used to be a +// hard-coded intl constant now funnels through wbRealmConfig, so adding a +// region is one table entry rather than a hunt through every call site. +const ( + WBRealmIntl = "intl" + WBRealmCN = "cn" +) + +// wbRealm is one region's endpoints and desktop identity. The WAF reads the +// Origin/X-Domain/UA family as a fingerprint, so a CN token presented with +// intl headers is refused on shape alone — the fields travel together or not +// at all. +type wbRealm struct { + ChatBase string + BillingBase string + Origin string + Domain string + IDEVersion string + DesktopUA string + // HasCheckin: only CN publishes /v2/billing/meter/daily-checkin. The + // intl daily is an activity conversation instead (WorkBuddyDailyActivity). + HasCheckin bool +} + +func wbRealmConfig(realm string) wbRealm { + if realm == WBRealmCN { + return wbRealm{ + ChatBase: WorkBuddyCNBase, + BillingBase: WorkBuddyCNBilling, + Origin: "https://www.codebuddy.cn", + Domain: "copilot.tencent.com", + IDEVersion: wbIDEVersionCN, + DesktopUA: wbDesktopUACN, + HasCheckin: true, + } + } + return wbRealm{ + ChatBase: WorkBuddyBase, + BillingBase: WorkBuddyBase, + Origin: wbOrigin, + Domain: wbDomain, + IDEVersion: wbIDEVersion, + DesktopUA: wbDesktopUA, + HasCheckin: false, + } +} + +// wbRealmOf resolves an account's region. Accounts minted before the CN lane +// existed carry no realm at all; they are intl, which is what the field would +// have said. +func wbRealmOf(acct WBAccount) string { + if acct.Realm == WBRealmCN { + return WBRealmCN + } + return WBRealmIntl +} + const ( wbTurnTimeout = streamIdleTimeout // shares the lane-wide silence bound (PR #40) @@ -50,6 +116,14 @@ const ( wbAuthRefreshPath = "/v2/plugin/auth/token/refresh" wbLoginAccountPath = "/v2/plugin/login/account" wbUserResourcePath = "/v2/billing/meter/get-user-resource" + // wbCheckinPath is CN-only: the daily reward endpoint. Answering + // code 0 means claimed; 10001 (or any 已签到 text) means it was already + // claimed today, which is the same outcome for our purposes. + wbCheckinPath = "/v2/billing/meter/daily-checkin" + // wbCheckinAlready is the meter's "you already claimed today" code. It + // arrives as an HTTP 400 with a 已签到 message, so it reads as a failure + // to any caller that only checks the status line. + wbCheckinAlready = 10001 // The desktop identity the WAF fingerprints: version constants move with // the client, so they live here as one edit-point. @@ -58,6 +132,12 @@ const ( wbOrigin = "https://www.workbuddy.ai" wbDomain = "www.workbuddy.ai" + // The CN client's own identity. No packet capture of the CN build was + // available, so these mirror the intl shape with the CN product name — + // wrong-but-plausible beats intl headers against a CN token. + wbIDEVersionCN = "5.5.2" + wbDesktopUACN = "WorkBuddy/5.5.2 WorkBuddy AI/5.5.2 CLI/5.5.2" + wbLoginPending = 11217 // upstream code: the browser login is not finished yet wbLoginTTL = 10 * time.Minute wbRefreshEarly = 2 * time.Hour // refresh proactively this close to expiry @@ -83,6 +163,13 @@ type WBAccount struct { ExpiresAtMS int64 `json:"expiresAtMs,omitempty"` Enabled bool `json:"enabled"` LastActivityDate string `json:"lastActivityDate,omitempty"` + // Realm is which publishment issued this token: "intl" or "cn". Empty + // means intl — the field did not exist when intl was the only lane. + Realm string `json:"realm,omitempty"` + // LastCheckinDate is the local calendar day the CN daily-checkin last + // succeeded; CN only, and independent of LastActivityDate (which is the + // intl activity conversation's day). + LastCheckinDate string `json:"lastCheckinDate,omitempty"` } // wbDeriveID derives the machine/session/request ids the upstream expects: @@ -187,26 +274,34 @@ func buildWorkBuddyCatalog() []ModelInfo { // request: the base web layer, the identity layer, and the uid-derived // machine/session ids. No cookies, no signatures — the JWT is the credential. func workBuddyHeaders(acct WBAccount, requestID string) map[string]string { + rc := wbRealmConfig(wbRealmOf(acct)) return map[string]string{ "content-type": "application/json", "accept": "application/json, text/plain, */*", "x-requested-with": "XMLHttpRequest", - "origin": wbOrigin, - "referer": wbOrigin + "/", + "origin": rc.Origin, + "referer": rc.Origin + "/", "x-codebuddy-request": "1", - "accept-language": "en-US", - "x-machine-id": wbDeriveID(acct.UID, "machine"), - "x-session-id": wbDeriveID(acct.UID, "session"), - "x-agent-purpose": "conversation", - "x-ide-name": "WorkBuddy", - "x-ide-type": "WorkBuddy", - "x-ide-version": wbIDEVersion, - "x-product": "WorkBuddy", - "x-domain": wbDomain, - "user-agent": wbDesktopUA, - "x-user-id": acct.UID, - "authorization": "Bearer " + acct.AccessToken, - "x-request-id": requestID, + // CN answers zh-CN; the intl build sends en-US. Wrong locale is not + // fatal but is one more mismatch in a fingerprint the WAF scores. + "accept-language": func() string { + if wbRealmOf(acct) == WBRealmCN { + return "zh-CN" + } + return "en-US" + }(), + "x-machine-id": wbDeriveID(acct.UID, "machine"), + "x-session-id": wbDeriveID(acct.UID, "session"), + "x-agent-purpose": "conversation", + "x-ide-name": "WorkBuddy", + "x-ide-type": "WorkBuddy", + "x-ide-version": rc.IDEVersion, + "x-product": "WorkBuddy", + "x-domain": rc.Domain, + "user-agent": rc.DesktopUA, + "x-user-id": acct.UID, + "authorization": "Bearer " + acct.AccessToken, + "x-request-id": requestID, } } @@ -342,7 +437,7 @@ func wbPostOnce(ctx context.Context, body map[string]any, acct WBAccount, conver if err != nil { return nil, &UpstreamError{Code: CodeTransport, Message: err.Error()} } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, WorkBuddyBase+wbChatPath, bytes.NewReader(payload)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, wbRealmConfig(wbRealmOf(acct)).ChatBase+wbChatPath, bytes.NewReader(payload)) if err != nil { return nil, &UpstreamError{Code: CodeTransport, Message: err.Error()} } diff --git a/internal/lane/workbuddy_account.go b/internal/lane/workbuddy_account.go index 6db647e..2b78906 100644 --- a/internal/lane/workbuddy_account.go +++ b/internal/lane/workbuddy_account.go @@ -114,12 +114,13 @@ func (l *Lane) refreshWorkBuddy(ctx context.Context) error { if !wbTokenNearExpiry(acct) && time.Since(l.wbRefreshedAt) < time.Minute { return nil // someone just refreshed; the pair is current } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, WorkBuddyBase+wbAuthRefreshPath, strings.NewReader("{}")) + rc := wbRealmConfig(wbRealmOf(acct)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rc.ChatBase+wbAuthRefreshPath, strings.NewReader("{}")) if err != nil { return err } req.Header.Set("content-type", "application/json") - req.Header.Set("user-agent", wbDesktopUA) + req.Header.Set("user-agent", rc.DesktopUA) req.Header.Set("x-codebuddy-request", "1") req.Header.Set("x-refresh-token", acct.RefreshToken) req.Header.Set("x-auth-refresh-source", "plugin") @@ -170,14 +171,19 @@ func (l *Lane) refreshWorkBuddy(ctx context.Context) error { // WorkBuddyLoginStart opens a login: the upstream mints a state and an // authUrl, and the user finishes the login in their browser. There is no // callback — completion is observed by polling with the state. -func (l *Lane) WorkBuddyLoginStart() (string, error) { +// +// realm picks the publishment: "cn" for codebuddy (copilot.tencent.com), +// anything else for the intl workbuddy.ai flow. The choice is remembered for +// the poll, because the two publishments do not share a login state. +func (l *Lane) WorkBuddyLoginStart(realm string) (string, error) { + rc := wbRealmConfig(realm) req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, - WorkBuddyBase+wbAuthStatePath+"?platform=CLI", strings.NewReader("{}")) + rc.ChatBase+wbAuthStatePath+"?platform=CLI", strings.NewReader("{}")) if err != nil { return "", err } req.Header.Set("content-type", "application/json") - req.Header.Set("user-agent", wbDesktopUA) + req.Header.Set("user-agent", rc.DesktopUA) req.Header.Set("x-codebuddy-request", "1") client := &http.Client{Timeout: 15 * time.Second, Transport: laneHTTP.Transport} resp, err := client.Do(req) @@ -207,6 +213,7 @@ func (l *Lane) WorkBuddyLoginStart() (string, error) { } l.wbMu.Lock() l.wbLoginState = payload.Data.State + l.wbLoginRealm = realm l.wbLoginAt = time.Now() l.wbMu.Unlock() return payload.Data.AuthURL, nil @@ -218,6 +225,7 @@ func (l *Lane) WorkBuddyLoginStart() (string, error) { func (l *Lane) WorkBuddyLoginPoll() (string, error) { l.wbMu.Lock() state := l.wbLoginState + realm := l.wbLoginRealm startedAt := l.wbLoginAt l.wbMu.Unlock() if state == "" { @@ -226,10 +234,11 @@ func (l *Lane) WorkBuddyLoginPoll() (string, error) { if time.Since(startedAt) > wbLoginTTL { l.wbMu.Lock() l.wbLoginState = "" + l.wbLoginRealm = "" l.wbMu.Unlock() return "", errors.New("登录已超时,请重新开始") } - httpStatus, body, err := wbGet(wbAuthTokenPath+"?state="+state, WBAccount{}) + httpStatus, body, err := wbGet(wbAuthTokenPath+"?state="+state, WBAccount{Realm: realm}) if err != nil { return "", err } @@ -250,13 +259,14 @@ func (l *Lane) WorkBuddyLoginPoll() (string, error) { if payload.Code != 0 || payload.Data.AccessToken == "" { return "", fmt.Errorf("登录未完成(code %d)%s", payload.Code, strings.TrimSpace(payload.Msg)) } - acct, err := wbAccountFromToken(payload.Data) + acct, err := wbAccountFromToken(payload.Data, realm) if err != nil { return "", err } l.wbMu.Lock() l.wbAcct = acct l.wbLoginState = "" + l.wbLoginRealm = "" l.wbMu.Unlock() l.wbPersist(acct) go l.RefreshCatalog(context.Background()) @@ -270,15 +280,40 @@ type wbTokenResponse struct { ExpiresAt any `json:"expiresAt"` } -// wbAccountFromToken validates the minted pair: the JWT must name the intl -// realm (this build only speaks workbuddy.ai) and carries uid/expiry itself. -func wbAccountFromToken(t wbTokenResponse) (WBAccount, error) { +// wbRealmFromIss reads the publishment out of a token's issuer. Empty when +// the issuer names neither realm — plenty of mints carry a bare or internal +// issuer, and in that case the caller's own knowledge (which login did we +// open?) is the better witness. +func wbRealmFromIss(iss string) string { + i := strings.ToLower(iss) + switch { + case strings.Contains(i, "workbuddy.ai"): + return WBRealmIntl + case strings.Contains(i, "codebuddy"), strings.Contains(i, "copilot.tencent.com"), strings.Contains(i, "tencent"): + return WBRealmCN + } + return "" +} + +// wbAccountFromToken validates the minted pair and stamps the account with +// the realm it belongs to. hint is the region whose login produced it; an +// issuer that names a realm outranks the hint, and a silent issuer defers to +// it. The intl path keeps its assertion — a token that is neither clearly CN +// nor issued by workbuddy.ai is not an intl account. +func wbAccountFromToken(t wbTokenResponse, hint string) (WBAccount, error) { sub, iss, expMS, ok := wbJWTClaims(t.AccessToken) if !ok { return WBAccount{}, errors.New("返回的令牌无法解析") } - if !strings.Contains(iss, "workbuddy.ai") { - return WBAccount{}, errors.New("令牌不属于国际版(workbuddy.ai),本渠道暂只支持国际版") + realm := hint + if ev := wbRealmFromIss(iss); ev != "" { + realm = ev + } + if realm != WBRealmCN && !strings.Contains(iss, "workbuddy.ai") { + return WBAccount{}, errors.New("令牌不属于国际版(workbuddy.ai);国内版请在登录时选择「国内版」") + } + if realm == "" { + realm = WBRealmIntl } if sub == "" { sub = "anonymous" @@ -289,6 +324,7 @@ func wbAccountFromToken(t wbTokenResponse) (WBAccount, error) { UID: sub, ExpiresAtMS: expMS, Enabled: true, + Realm: realm, } if acct.ExpiresAtMS == 0 { acct.ExpiresAtMS = wbExpToMS(t.ExpiresAt) @@ -307,11 +343,20 @@ func wbAccountFromToken(t wbTokenResponse) (WBAccount, error) { return acct, nil } -// wbGet performs one identity-bearing GET against the WorkBuddy root and -// returns (upstream code, body) without treating non-2xx as a Go error — the -// callers read the envelope's own codes. +// wbGet performs one identity-bearing GET against the account's publishment +// root and returns (upstream code, body) without treating non-2xx as a Go +// error — the callers read the envelope's own codes. Billing paths want +// wbBillingGet: on CN the meter lives on a different host than the chat API. func wbGet(path string, acct WBAccount) (int, []byte, error) { - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, WorkBuddyBase+path, nil) + return wbGetOn(wbRealmConfig(wbRealmOf(acct)).ChatBase, path, acct) +} + +func wbBillingGet(path string, acct WBAccount) (int, []byte, error) { + return wbGetOn(wbRealmConfig(wbRealmOf(acct)).BillingBase, path, acct) +} + +func wbGetOn(base, path string, acct WBAccount) (int, []byte, error) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, base+path, nil) if err != nil { return 0, nil, err } @@ -374,6 +419,84 @@ func (l *Lane) wbMarkActivity(date string) { } } +// WorkBuddyDailyCheckin claims the CN publishment's daily reward on its own +// endpoint. intl has no such endpoint — it earns its daily by conversing +// (WorkBuddyDailyActivity) — so a CN account left on the activity chat would +// collect nothing all week while still looking busy in the logs. +// +// The meter answers code 0 on a fresh claim and 10001 (or any 已签到 wording, +// sometimes behind an HTTP 400) once the day is claimed. Both mean the day is +// finished, so both mark it; only a genuinely different code leaves the day +// open for the next tick. +func (l *Lane) WorkBuddyDailyCheckin(ctx context.Context) (bool, error) { + acct, ok := l.workBuddyReady() + if !ok || wbRealmOf(acct) != WBRealmCN { + return false, nil + } + today := time.Now().Format("2006-01-02") + if acct.LastCheckinDate == today { + return false, nil + } + if wbTokenNearExpiry(acct) { + if rerr := l.refreshWorkBuddy(ctx); rerr == nil { + if fresh, ok := l.workBuddyReady(); ok { + acct = fresh + } + } + } + status, body, err := wbBillingPost(ctx, wbCheckinPath, acct, "{}") + if err != nil { + // Transport trouble only; the day stays open for the next tick. + return true, err + } + var resp struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + _ = json.Unmarshal(body, &resp) + if resp.Code != 0 && resp.Code != wbCheckinAlready && !strings.Contains(resp.Msg, "已签到") { + return true, fmt.Errorf("国内版签到未成功(HTTP %d,code %d)%s", + status, resp.Code, strings.TrimSpace(resp.Msg)) + } + l.wbMarkCheckin(today) + return true, nil +} + +func (l *Lane) wbMarkCheckin(date string) { + l.wbMu.Lock() + l.wbAcct.LastCheckinDate = date + acct := l.wbAcct + fn := l.wbPersist + l.wbMu.Unlock() + if fn != nil { + fn(acct) + } +} + +// wbBillingPost is the POST twin of wbBillingGet: same meter host, same +// desktop identity headers the chat path presents. +func wbBillingPost(ctx context.Context, path string, acct WBAccount, payload string) (int, []byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + wbRealmConfig(wbRealmOf(acct)).BillingBase+path, strings.NewReader(payload)) + if err != nil { + return 0, nil, err + } + for k, v := range workBuddyHeaders(acct, wbRequestID()) { + req.Header.Set(k, v) + } + client := &http.Client{Timeout: 15 * time.Second, Transport: laneHTTP.Transport} + resp, err := client.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return 0, nil, err + } + return resp.StatusCode, data, nil +} + // WorkBuddyCredits reads the billing resource summary for the status card. // The response's numeric layout is not contractual, so the reader accepts any // numeric field named like a balance anywhere under data and reports the @@ -395,7 +518,7 @@ func (l *Lane) WorkBuddyCredits(ctx context.Context) (string, error) { } } } - outerCode, body, err := wbGetCancellable(ctx, wbUserResourcePath, acct) + outerCode, body, err := wbBillingGetCancellable(ctx, wbUserResourcePath, acct) if err != nil { return "", err } @@ -409,7 +532,7 @@ func (l *Lane) WorkBuddyCredits(ctx context.Context) (string, error) { if !ok { return "", errors.New("WorkBuddy 账号未登录") } - if outerCode, body, err = wbGetCancellable(ctx, wbUserResourcePath, fresh); err != nil { + if outerCode, body, err = wbBillingGetCancellable(ctx, wbUserResourcePath, fresh); err != nil { return "", err } } @@ -430,6 +553,17 @@ func (l *Lane) WorkBuddyCredits(ctx context.Context) (string, error) { // wbGetCancellable runs one wbGet but honours ctx cancellation instead of the // bare 15s client timeout, so a status-card refresh cannot outlive its request. func wbGetCancellable(ctx context.Context, path string, acct WBAccount) (int, []byte, error) { + return wbGetOnCancellable(ctx, wbRealmConfig(wbRealmOf(acct)).ChatBase, path, acct) +} + +// wbBillingGetCancellable is wbGetCancellable against the meter host. On intl +// the two hosts are the same, so this only matters for CN — where asking +// copilot.tencent.com for a billing meter is what answers 404. +func wbBillingGetCancellable(ctx context.Context, path string, acct WBAccount) (int, []byte, error) { + return wbGetOnCancellable(ctx, wbRealmConfig(wbRealmOf(acct)).BillingBase, path, acct) +} + +func wbGetOnCancellable(ctx context.Context, base, path string, acct WBAccount) (int, []byte, error) { done := make(chan struct{}) var ( code int @@ -437,7 +571,7 @@ func wbGetCancellable(ctx context.Context, path string, acct WBAccount) (int, [] gerr error ) go func() { - code, body, gerr = wbGet(path, acct) + code, body, gerr = wbGetOn(base, path, acct) close(done) }() select { diff --git a/internal/lane/workbuddy_test.go b/internal/lane/workbuddy_test.go index 05a7b9e..84a6b76 100644 --- a/internal/lane/workbuddy_test.go +++ b/internal/lane/workbuddy_test.go @@ -306,6 +306,165 @@ func TestWorkBuddyDailyActivityRunsOncePerDay(t *testing.T) { } } +// The CN daily reward is a real endpoint rather than a conversation, and it +// lives on the meter host — asking the chat host for it is what answers 404. +func TestWorkBuddyDailyCheckinClaimsOnTheCNMeter(t *testing.T) { + var gotPath, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + data, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + gotBody = strings.TrimSpace(string(data)) + fmt.Fprint(w, `{"code":0,"msg":"ok"}`) + })) + defer srv.Close() + old := WorkBuddyCNBilling + WorkBuddyCNBilling = srv.URL + t.Cleanup(func() { WorkBuddyCNBilling = old }) + + acct := wbAcct() + acct.Realm = WBRealmCN + l := NewLane() + l.SetWorkBuddyAccount(acct) + l.SetWorkBuddyPersist(func(WBAccount) {}) + + ran, err := l.WorkBuddyDailyCheckin(context.Background()) + if err != nil || !ran { + t.Fatalf("run = (%v, %v), want one claim", ran, err) + } + if gotPath != wbCheckinPath { + t.Fatalf("path = %q, want %q", gotPath, wbCheckinPath) + } + if gotBody != "{}" { + t.Fatalf("body = %q, want the empty object the meter expects", gotBody) + } + if got := l.WorkBuddySnapshot().LastCheckinDate; got != time.Now().Format("2006-01-02") { + t.Fatalf("LastCheckinDate = %q, want today", got) + } +} + +// "Already claimed today" arrives as an HTTP 400 carrying code 10001. It is +// the same outcome as a fresh claim — reading the status line alone would +// leave the day open and retry hourly against a wall. +func TestWorkBuddyDailyCheckinAlreadyClaimedClosesTheDay(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"code":10001,"msg":"今天已签到"}`) + })) + defer srv.Close() + old := WorkBuddyCNBilling + WorkBuddyCNBilling = srv.URL + t.Cleanup(func() { WorkBuddyCNBilling = old }) + + acct := wbAcct() + acct.Realm = WBRealmCN + l := NewLane() + l.SetWorkBuddyAccount(acct) + l.SetWorkBuddyPersist(func(WBAccount) {}) + + ran, err := l.WorkBuddyDailyCheckin(context.Background()) + if err != nil || !ran { + t.Fatalf("run = (%v, %v), want the day closed, not an error", ran, err) + } + // The day is closed, so the hourly tick must not ask again. + if ran2, err2 := l.WorkBuddyDailyCheckin(context.Background()); ran2 || err2 != nil { + t.Fatalf("second run = (%v, %v), want a silent skip", ran2, err2) + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Fatalf("meter hits = %d, want the one claim", got) + } +} + +// intl has no checkin endpoint; its daily is the activity conversation. A CN +// checkin fired at an intl account would be a request to a path that does not +// exist there. +func TestWorkBuddyDailyCheckinSkipsIntlAccounts(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + fmt.Fprint(w, `{"code":0}`) + })) + defer srv.Close() + old := WorkBuddyBase + WorkBuddyBase = srv.URL + t.Cleanup(func() { WorkBuddyBase = old }) + + l := NewLane() + l.SetWorkBuddyAccount(wbAcct()) // no Realm: intl + l.SetWorkBuddyPersist(func(WBAccount) {}) + + ran, err := l.WorkBuddyDailyCheckin(context.Background()) + if ran || err != nil { + t.Fatalf("run = (%v, %v), want a silent skip on intl", ran, err) + } + if got := atomic.LoadInt32(&hits); got != 0 { + t.Fatalf("upstream hits = %d, want none", got) + } +} + +// A CN token must chat on the CN host. Sending it to workbuddy.ai is not a +// slower route, it is a refused one: the WAF reads the pair of token and +// headers as one fingerprint. +func TestWorkBuddyCNChatGoesToTheCNHost(t *testing.T) { + var cnHits, intlHits int32 + sse := func(w http.ResponseWriter) { + w.Header().Set("content-type", "text/event-stream") + fmt.Fprint(w, wbChatSSE(`{"choices":[{"delta":{},"finish_reason":"stop"}]}`)) + } + cn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&cnHits, 1) + sse(w) + })) + defer cn.Close() + intl := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&intlHits, 1) + sse(w) + })) + defer intl.Close() + oldCN, oldIntl := WorkBuddyCNBase, WorkBuddyBase + WorkBuddyCNBase, WorkBuddyBase = cn.URL, intl.URL + t.Cleanup(func() { WorkBuddyCNBase, WorkBuddyBase = oldCN, oldIntl }) + + acct := wbAcct() + acct.Realm = WBRealmCN + l := NewLane() + l.SetWorkBuddyAccount(acct) + l.SetWorkBuddyPersist(func(WBAccount) {}) + + _, _ = l.wbPost(context.Background(), map[string]any{ + "model": "deepseek-v4.1-flash", "stream": true, + "messages": []map[string]any{{"role": RoleUser, "content": "hi"}}, + }, "conv-1", func([]byte) error { return nil }) + + if got := atomic.LoadInt32(&intlHits); got != 0 { + t.Fatalf("intl hits = %d, want 0 — a CN account must not land on workbuddy.ai", got) + } + if got := atomic.LoadInt32(&cnHits); got != 1 { + t.Fatalf("CN hits = %d, want 1", got) + } +} + +// The realm comes from the issuer when it names one, and from the login the +// user opened when it does not — a silent issuer must not silently downgrade +// a CN account to intl. +func TestWBRealmFromIss(t *testing.T) { + cases := []struct { + iss string + want string + }{ + {"https://www.workbuddy.ai", WBRealmIntl}, + {"codebuddy", WBRealmCN}, + {"https://copilot.tencent.com", WBRealmCN}, + {"internal-issuer", ""}, + } + for _, c := range cases { + if got := wbRealmFromIss(c.iss); got != c.want { + t.Errorf("wbRealmFromIss(%q) = %q, want %q", c.iss, got, c.want) + } + } +} + func TestWorkBuddyDailyActivitySpentWindowClosesTheDay(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(429)