Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ RUN apk add --no-cache wget ca-certificates tzdata \
USER app
WORKDIR /app
COPY --from=build /out/wb2api /app/wb2api
COPY config.json /app/config.json
COPY config.example.json /app/config.json
EXPOSE 7863
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \
CMD wget -qO- http://127.0.0.1:7863/healthz || exit 1
Expand Down
9 changes: 8 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,20 @@ func main() {
ErrCooldown: cfg.ErrCooldownDur,
})

// 内置 Web 前端控制台:账号总览 + 一键签到 + 扫码登录(WEB_DISABLED=1 关闭)
var handler http.Handler = h
if os.Getenv("WEB_DISABLED") != "1" {
localAPI := server.NewLocalAPI(p, up, func() { go sch.RunCheckinNow() }, cfg.AuthDir, os.Getenv("LOGIN_DISABLED") != "1")
handler = server.WrapWeb(h, true, localAPI)
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go sch.Run(ctx)

srv := &http.Server{
Addr: cfg.Listen,
Handler: h,
Handler: handler,
ReadHeaderTimeout: 30 * time.Second,
}
go func() {
Expand Down
188 changes: 188 additions & 0 deletions internal/login/login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Package login 封装 WorkBuddy CN 的 OAuth 设备流(与 cmd/login 一致),
// 供服务端 Web 登录接口与落盘使用。仅支持 region=cn(与上游 CLI 一致)。
package login

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"os"
"path/filepath"
"time"
)

const (
upstreamBaseCN = "https://copilot.tencent.com"
clientUA = "CLI/2.63.2 CodeBuddy/2.63.2"
originReferer = "https://www.codebuddy.cn"

endpointAuthState = upstreamBaseCN + "/v2/plugin/auth/state?platform=CLI"
endpointAuthToken = upstreamBaseCN + "/v2/plugin/auth/token?state="
endpointLoginAcct = upstreamBaseCN + "/v2/plugin/login/account?state="
)

// Bundle 登录成功后拿到的完整凭证。
type Bundle struct {
AccessToken string
RefreshToken string
ExpiresIn int64
Domain string
UID string
EnterpriseID string
Nickname string
ExpiresAt int64 // Unix 秒,由 ExpiresIn 推导
}

type apiEnvelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}

func commonHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("Origin", originReferer)
req.Header.Set("Referer", originReferer+"/")
req.Header.Set("User-Agent", clientUA)
}

func doJSON(client *http.Client, method, fullURL string, headers func(*http.Request), body io.Reader) (json.RawMessage, int, error) {
req, err := http.NewRequest(method, fullURL, body)
if err != nil {
return nil, 0, err
}
if headers != nil {
headers(req)
} else {
commonHeaders(req)
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode)
}
var env apiEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err)
}
if env.Code != 0 {
return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg)
}
return env.Data, resp.StatusCode, nil
}

// Start 拿授权 URL + state。state 由调用方保管,传给 Poll。
func Start() (state, authURL string, err error) {
jar, _ := cookiejar.New(nil)
client := &http.Client{Timeout: 30 * time.Second, Jar: jar}
data, _, err := doJSON(client, http.MethodPost, endpointAuthState, nil, bytes.NewReader([]byte("{}")))
if err != nil {
return "", "", fmt.Errorf("auth state failed: %w", err)
}
var st struct {
State string `json:"state"`
AuthURL string `json:"authUrl"`
}
if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" {
return "", "", fmt.Errorf("auth state: missing state or authUrl")
}
return st.State, st.AuthURL, nil
}

// Poll 用之前拿到的 state 轮询 token;pending 时返回 err 且 ok=false(调用方应继续轮询)。
// ok=true 表示登录完成,返回完整 Bundle。
func Poll(state string) (b *Bundle, ok bool, err error) {
jar, _ := cookiejar.New(nil)
client := &http.Client{Timeout: 30 * time.Second, Jar: jar}

tokRaw, status, errTok := doJSON(client, http.MethodGet, endpointAuthToken+state, nil, nil)
if errTok != nil {
if status == 0 || status >= 500 {
return nil, false, fmt.Errorf("token endpoint error: %w", errTok)
}
// 4xx / 业务错误(pending):登录尚未完成
return nil, false, nil
}
var tok struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int64 `json:"expiresIn"`
Domain string `json:"domain"`
}
if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" {
return nil, false, nil
}

b = &Bundle{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
ExpiresIn: tok.ExpiresIn,
Domain: tok.Domain,
ExpiresAt: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix(),
}

// login/account 拿 uid/nickname(带 Bearer)
acctHeaders := func(r *http.Request) {
commonHeaders(r)
r.Header.Set("Authorization", "Bearer "+tok.AccessToken)
}
if acctRaw, _, errAcct := doJSON(client, http.MethodGet, endpointLoginAcct+state, acctHeaders, nil); errAcct == nil {
var acct struct {
UID string `json:"uid"`
EnterpriseID string `json:"enterpriseId"`
Nickname string `json:"nickname"`
}
if json.Unmarshal(acctRaw, &acct) == nil {
b.UID = acct.UID
b.EnterpriseID = acct.EnterpriseID
b.Nickname = acct.Nickname
}
}
if b.UID == "" {
return nil, false, fmt.Errorf("login completed but uid empty")
}
return b, true, nil
}

// SaveToFile 将 Bundle 落盘为 auth_dir/workbuddy-<uid>.json(嵌套形,与 auth.Parse 一致)。
// 返回最终文件路径。
func (b *Bundle) SaveToFile(authDir string) (string, error) {
if err := os.MkdirAll(authDir, 0o755); err != nil {
return "", err
}
doc := map[string]any{
"auth": map[string]any{
"accessToken": b.AccessToken,
"refreshToken": b.RefreshToken,
"expiresAt": b.ExpiresAt,
"domain": b.Domain,
},
"account": map[string]any{
"uid": b.UID,
"enterpriseId": b.EnterpriseID,
"nickname": b.Nickname,
},
}
raw, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return "", err
}
fp := filepath.Join(authDir, "workbuddy-"+b.UID+".json")
tmp := fp + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return "", err
}
if err := os.Rename(tmp, fp); err != nil {
return "", err
}
return fp, nil
}
113 changes: 113 additions & 0 deletions internal/server/local_api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package server

import (
"log"
"net/http"

"workbuddy2api/internal/auth"
"workbuddy2api/internal/login"
"workbuddy2api/internal/pool"
"workbuddy2api/internal/upstream"
)

// LocalAPI 提供内置前端需要的本地 API(一键签到、扫码添加账号)。
// 设计为独立于上游 Handler 的结构:不修改 handler.go,全部代码在本文件,
// 由 WrapWeb 挂载路由,main.go 只传依赖。
type LocalAPI struct {
Pool *pool.Pool
Upstream *upstream.Client
RunCheckin func() // 触发一次签到轮(由 main 注入 scheduler.RunCheckinNow)
AuthDir string
LoginEnabled bool
}

func NewLocalAPI(p *pool.Pool, up *upstream.Client, runCheckin func(), authDir string, loginEnabled bool) *LocalAPI {
return &LocalAPI{
Pool: p,
Upstream: up,
RunCheckin: runCheckin,
AuthDir: authDir,
LoginEnabled: loginEnabled,
}
}

// handleCheckin 手动触发一次签到轮(同步执行;结果由 scheduler 推送)。
func (a *LocalAPI) handleCheckin(w http.ResponseWriter, r *http.Request) {
if a.RunCheckin == nil {
writeOpenAIError(w, http.StatusServiceUnavailable, "checkin_unavailable", "scheduler not available")
return
}
a.RunCheckin()
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "签到已触发"})
}

// handleLoginStart 发起 OAuth 设备流,返回授权 URL 与 state。
func (a *LocalAPI) handleLoginStart(w http.ResponseWriter, r *http.Request) {
if !a.LoginEnabled {
writeOpenAIError(w, http.StatusForbidden, "login_disabled", "web login is disabled by config")
return
}
state, authURL, err := login.Start()
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, "login_start_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"state": state,
"auth_url": authURL,
})
}

// handleLoginPoll 用 state 轮询登录结果;pending 时返回 {pending:true}。
func (a *LocalAPI) handleLoginPoll(w http.ResponseWriter, r *http.Request) {
if !a.LoginEnabled {
writeOpenAIError(w, http.StatusForbidden, "login_disabled", "web login is disabled by config")
return
}
state := r.URL.Query().Get("state")
if state == "" {
writeOpenAIError(w, http.StatusBadRequest, "bad_request", "missing state")
return
}
bundle, ok, err := login.Poll(state)
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, "login_poll_failed", err.Error())
return
}
if !ok {
writeJSON(w, http.StatusOK, map[string]any{"pending": true})
return
}

fp, err := bundle.SaveToFile(a.AuthDir)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "save_failed", "save auth: "+err.Error())
return
}

// 加入账号池(带完整凭证,供后续 refresh 写回)
acc := &auth.Auth{
AccessToken: bundle.AccessToken,
RefreshToken: bundle.RefreshToken,
ExpiresAt: bundle.ExpiresAt,
Domain: bundle.Domain,
UID: bundle.UID,
EnterpriseID: bundle.EnterpriseID,
Nickname: bundle.Nickname,
FilePath: fp,
}
a.Pool.Add(acc)

// 立即查询一次积分(非阻塞,失败忽略)
if remain, rerr := a.Upstream.UserResource(acc); rerr == nil {
a.Pool.SetCredits(acc.UID, remain)
}

log.Printf("web login success: uid=%s nickname=%s file=%s", acc.UID, acc.Nickname, fp)
writeJSON(w, http.StatusOK, map[string]any{
"pending": false,
"uid": acc.UID,
"nickname": acc.Nickname,
"file": fp,
})
}
Loading