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)