fix(qq): harden startup token fetch timeout
This commit is contained in:
parent
e70928cc6f
commit
bf2d9a86df
3 changed files with 247 additions and 5 deletions
|
|
@ -110,11 +110,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
c.stopOnce = sync.Once{}
|
c.stopOnce = sync.Once{}
|
||||||
|
|
||||||
// create token source
|
// create token source
|
||||||
credentials := &token.QQBotCredentials{
|
c.tokenSource = newQQTokenSource(c.config.AppID, c.config.AppSecret.String())
|
||||||
AppID: c.config.AppID,
|
|
||||||
AppSecret: c.config.AppSecret.String(),
|
|
||||||
}
|
|
||||||
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
|
||||||
|
|
||||||
// create child context
|
// create child context
|
||||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
|
||||||
147
pkg/channels/qq/token_source.go
Normal file
147
pkg/channels/qq/token_source.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
99
pkg/channels/qq/token_source_test.go
Normal file
99
pkg/channels/qq/token_source_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue