diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index dfea85ba3..1d420f9ec 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -110,11 +110,7 @@ func (c *QQChannel) Start(ctx context.Context) error { c.stopOnce = sync.Once{} // create token source - credentials := &token.QQBotCredentials{ - AppID: c.config.AppID, - AppSecret: c.config.AppSecret.String(), - } - c.tokenSource = token.NewQQBotTokenSource(credentials) + c.tokenSource = newQQTokenSource(c.config.AppID, c.config.AppSecret.String()) // create child context c.ctx, c.cancel = context.WithCancel(ctx) diff --git a/pkg/channels/qq/token_source.go b/pkg/channels/qq/token_source.go new file mode 100644 index 000000000..56405eef0 --- /dev/null +++ b/pkg/channels/qq/token_source.go @@ -0,0 +1,147 @@ +package qq + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/tencent-connect/botgo/constant" + "github.com/tencent-connect/botgo/token" + "golang.org/x/oauth2" + "golang.org/x/sync/singleflight" +) + +const defaultQQTokenRequestTimeout = 30 * time.Second + +type qqTokenSource struct { + appID string + appSecret string + tokenURL string + client *http.Client + + cachedToken atomic.Value + sg singleflight.Group +} + +type qqTokenResponse struct { + Code int `json:"code"` + Message string `json:"message"` + AccessToken string `json:"access_token"` + ExpiresIn json.RawMessage `json:"expires_in"` +} + +func newQQTokenSource(appID, appSecret string) oauth2.TokenSource { + return &qqTokenSource{ + appID: appID, + appSecret: appSecret, + tokenURL: fmt.Sprintf("%s/app/getAppAccessToken", constant.TokenDomain), + client: &http.Client{ + Timeout: defaultQQTokenRequestTimeout, + }, + } +} + +func (s *qqTokenSource) Token() (*oauth2.Token, error) { + raw := s.cachedToken.Load() + if raw != nil { + if tk, ok := raw.(*oauth2.Token); ok && tk != nil && tk.Valid() { + return tk, nil + } + } + + fresh, err, _ := s.sg.Do("qq_access_token", func() (any, error) { + return s.getNewToken() + }) + if err != nil { + return nil, err + } + + tk, ok := fresh.(*oauth2.Token) + if !ok || tk == nil { + return nil, fmt.Errorf("qq token source returned unexpected token type") + } + s.cachedToken.Store(tk) + return tk, nil +} + +func (s *qqTokenSource) getNewToken() (*oauth2.Token, error) { + payload, err := json.Marshal(map[string]string{ + "appId": s.appID, + "clientSecret": s.appSecret, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, s.tokenURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + rsp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = rsp.Body.Close() }() + + body, err := io.ReadAll(rsp.Body) + if err != nil { + return nil, err + } + + if rsp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("qq token request failed: status=%d body=%s", rsp.StatusCode, strings.TrimSpace(string(body))) + } + + parsed := &qqTokenResponse{} + if err := json.Unmarshal(body, parsed); err != nil { + return nil, fmt.Errorf("qq token response decode failed: %w", err) + } + + if parsed.Code != 0 { + return nil, fmt.Errorf("qq token request failed: %d.%s", parsed.Code, parsed.Message) + } + if parsed.AccessToken == "" { + return nil, fmt.Errorf("qq token request failed: empty access token") + } + + expiresIn, err := parseQQTokenExpiresIn(parsed.ExpiresIn) + if err != nil { + return nil, fmt.Errorf("qq token request failed: %w", err) + } + if expiresIn <= 0 { + return nil, fmt.Errorf("qq token request failed: invalid expires_in=%d", expiresIn) + } + + return &oauth2.Token{ + AccessToken: parsed.AccessToken, + TokenType: token.TypeQQBot, + Expiry: time.Now().Add(time.Duration(expiresIn) * time.Second), + ExpiresIn: expiresIn, + }, nil +} + +func parseQQTokenExpiresIn(raw json.RawMessage) (int64, error) { + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + parsed, parseErr := strconv.ParseInt(asString, 10, 64) + if parseErr != nil { + return 0, fmt.Errorf("invalid expires_in string %q: %w", asString, parseErr) + } + return parsed, nil + } + + var asInt int64 + if err := json.Unmarshal(raw, &asInt); err == nil { + return asInt, nil + } + + return 0, fmt.Errorf("invalid expires_in payload %s", string(raw)) +} diff --git a/pkg/channels/qq/token_source_test.go b/pkg/channels/qq/token_source_test.go new file mode 100644 index 000000000..3a65a2822 --- /dev/null +++ b/pkg/channels/qq/token_source_test.go @@ -0,0 +1,99 @@ +package qq + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestQQTokenSource_CachesValidToken(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"message":"","access_token":"test-token","expires_in":"3600"}`)) + })) + defer srv.Close() + + source := &qqTokenSource{ + appID: "appid", + appSecret: "secret", + tokenURL: srv.URL, + client: &http.Client{ + Timeout: 500 * time.Millisecond, + }, + } + + first, err := source.Token() + if err != nil { + t.Fatalf("Token() first call error = %v", err) + } + second, err := source.Token() + if err != nil { + t.Fatalf("Token() second call error = %v", err) + } + + if first.AccessToken != "test-token" || second.AccessToken != "test-token" { + t.Fatalf("unexpected access token values: first=%q second=%q", first.AccessToken, second.AccessToken) + } + if calls.Load() != 1 { + t.Fatalf("HTTP call count = %d, want 1", calls.Load()) + } +} + +func TestQQTokenSource_ParsesNumericExpiresIn(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"message":"","access_token":"num-exp","expires_in":3600}`)) + })) + defer srv.Close() + + source := &qqTokenSource{ + appID: "appid", + appSecret: "secret", + tokenURL: srv.URL, + client: &http.Client{ + Timeout: 500 * time.Millisecond, + }, + } + + tokenValue, err := source.Token() + if err != nil { + t.Fatalf("Token() error = %v", err) + } + + if tokenValue.AccessToken != "num-exp" { + t.Fatalf("AccessToken = %q, want num-exp", tokenValue.AccessToken) + } + if tokenValue.TokenType != "QQBot" { + t.Fatalf("TokenType = %q, want QQBot", tokenValue.TokenType) + } +} + +func TestQQTokenSource_ReturnsErrorForNonZeroCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":400,"message":"invalid app","access_token":"","expires_in":"0"}`)) + })) + defer srv.Close() + + source := &qqTokenSource{ + appID: "appid", + appSecret: "bad-secret", + tokenURL: srv.URL, + client: &http.Client{ + Timeout: 500 * time.Millisecond, + }, + } + + tokenValue, err := source.Token() + if err == nil { + t.Fatalf("Token() expected error, got token=%+v", tokenValue) + } + if !strings.Contains(err.Error(), "400.invalid app") { + t.Fatalf("Token() error = %q, want contains %q", err.Error(), "400.invalid app") + } +}