diff --git a/internal/channel/adapter/httpclient/client.go b/internal/channel/adapter/httpclient/client.go index 6a57fa9..f6bb32a 100644 --- a/internal/channel/adapter/httpclient/client.go +++ b/internal/channel/adapter/httpclient/client.go @@ -1,12 +1,13 @@ // Package httpclient provides a small, reusable HTTP (/JSON) client for IM -// channel adapters that talk to a REST API with bearer-token auth and a +// channel adapters that talk to a REST API with token auth and a // common `{code,message,data}` response envelope (KOOK, and similarly shaped // platforms). It is used by the KOOK adapter and is intended to be shared by // future platforms; existing adapters (Telegram, OneBot) that already carry // their own transport are left untouched. // -// It depends only on the standard library and internal/logger. The bearer token -// is never logged or surfaced in errors. +// It depends only on the standard library and internal/logger. The auth token is +// never logged or surfaced in errors. The Authorization scheme is configurable +// (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot"). package httpclient import ( @@ -31,12 +32,13 @@ var ErrAPI = errors.New("httpclient: api error") // maxBodyBytes caps how large a response body the client will read. const maxBodyBytes = 1 << 20 // 1 MiB -// Client is a bearer-auth JSON HTTP client bound to a base URL. +// Client is a token-auth JSON HTTP client bound to a base URL. type Client struct { - http *http.Client - baseURL string - token string - log *slog.Logger + http *http.Client + baseURL string + token string + authScheme string + log *slog.Logger } // Option customizes a Client at construction time. @@ -54,12 +56,19 @@ func WithBaseURL(base string) Option { return func(c *Client) { c.baseURL = strings.TrimRight(base, "/") } } -// WithToken permits an Authorization: Bearer header on every request. The token -// is stored in memory only and never logged. +// WithToken permits an Authorization header on every request. The token is +// stored in memory only and never logged. func WithToken(token string) Option { return func(c *Client) { c.token = token } } +// WithAuthScheme sets the scheme used in the `Authorization: ` +// header. The default is "Bearer" (OAuth2-style tokens). KOOK robots require +// "Bot". Passing an empty string keeps the default. +func WithAuthScheme(scheme string) Option { + return func(c *Client) { c.authScheme = strings.TrimSpace(scheme) } +} + // WithLogger injects the logger. A nil logger stays silent. func WithLogger(l *slog.Logger) Option { return func(c *Client) { @@ -75,8 +84,9 @@ func WithLogger(l *slog.Logger) Option { // the Do/Get helpers' path being an absolute URL). func New(opts ...Option) *Client { c := &Client{ - http: &http.Client{}, - log: logger.New(logger.Options{Out: io.Discard}), + http: &http.Client{}, + authScheme: "Bearer", + log: logger.New(logger.Options{Out: io.Discard}), } for _, opt := range opts { opt(c) @@ -133,7 +143,11 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json. req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") if c.token != "" { - req.Header.Set("Authorization", "Bearer "+c.token) + scheme := c.authScheme + if scheme == "" { + scheme = "Bearer" + } + req.Header.Set("Authorization", scheme+" "+c.token) } resp, err := c.http.Do(req) diff --git a/plugins/channels/kook/adapter.go b/plugins/channels/kook/adapter.go index 805af7c..fdf0fdd 100644 --- a/plugins/channels/kook/adapter.go +++ b/plugins/channels/kook/adapter.go @@ -105,9 +105,13 @@ func New(cfg Config, opts ...Option) *Adapter { a.baseURL = defaultBaseURL } } + // KOOK authenticates bots with `Authorization: Bot ` (not "Bearer", + // which is reserved for OAuth2 user tokens). This is mandated by the KOOK + // reference: /doc/reference §鉴权. a.http = httpclient.New( httpclient.WithBaseURL(a.baseURL), httpclient.WithToken(cfg.BotToken), + httpclient.WithAuthScheme("Bot"), httpclient.WithHTTPClient(a.client), httpclient.WithLogger(a.log), ) diff --git a/plugins/channels/kook/config.go b/plugins/channels/kook/config.go index 86761dd..e1e2cc5 100644 --- a/plugins/channels/kook/config.go +++ b/plugins/channels/kook/config.go @@ -3,7 +3,7 @@ // modes: a WebSocket-gateway mode (events over a gateway WebSocket with // hello/heartbeat/event/resume signaling) and a Webhook mode (KOOK POSTs event // callbacks to a Cyrene gateway route). Outbound messages are always sent over a -// bearer-authenticated REST API. +// bot-token-authenticated REST API (`Authorization: Bot `). // // The adapter conforms to internal/channel/adapter.PlatformAdapter, translating // native KOOK events into the normalized adapter.UnifiedMessage model and diff --git a/plugins/channels/kook/identity_test.go b/plugins/channels/kook/identity_test.go index e853d45..2c962da 100644 --- a/plugins/channels/kook/identity_test.go +++ b/plugins/channels/kook/identity_test.go @@ -15,8 +15,8 @@ func TestFetchBotIdentity(t *testing.T) { http.NotFound(w, r) return } - if got := r.Header.Get("Authorization"); got != "Bearer testtoken" { - t.Errorf("Authorization = %q, want Bearer testtoken", got) + if got := r.Header.Get("Authorization"); got != "Bot testtoken" { + t.Errorf("Authorization = %q, want Bot testtoken", got) } kookOK(w, map[string]any{"id": "bot-1", "username": "bot", "nickname": "小K"}) })) diff --git a/plugins/channels/kook/send_test.go b/plugins/channels/kook/send_test.go index 351947e..d791928 100644 --- a/plugins/channels/kook/send_test.go +++ b/plugins/channels/kook/send_test.go @@ -38,8 +38,8 @@ func TestSendMessage_GroupText(t *testing.T) { } mu.Lock() defer mu.Unlock() - if auth != "Bearer testtoken" { - t.Errorf("Authorization = %q, want Bearer testtoken", auth) + if auth != "Bot testtoken" { + t.Errorf("Authorization = %q, want Bot testtoken", auth) } if body["target_id"] != "chan-1" { t.Errorf("target_id = %v, want chan-1", body["target_id"])