Files
Cyrene_v2/internal/channel/adapter/httpclient/client.go
T
Aska 764d592859
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channel): KOOK 渠道适配器 + 框架兼容增强(wsutil/httpclient)
- plugins/channels/kook: config/adapter/gateway/websocket/send/message/errors — 实现 PlatformAdapter 13方法
  - WS 有状态网关: 取gateway地址→连WS→信令0/1/2/3/4/6 + 30s心跳/6s超时 + resume(拼接sn/session_id) + 指数退避重连
  - 收事件: 信令0 → UnifiedMessage(word注 channel_type/type/target_id/extra.author→字段)
  - 发消息: /message/create(频道) + /direct-message/create(私聊,修正正确端点,非user-chat) + Bearer鉴权
  - 注册 channels.go + CHANNELS_JSON 多账号 + channelbridge.configured 加 kook(bot_token非空才连)
- 框架增强: internal/channel/adapter/wsutil(连接管理/心跳/重连/resume骨架,复用gorilla,KOOK用上) + httpclient(Bearer+envelope归一,KOOK用)
- 全仓 go build/vet/test 绿, 零新依赖(复用餐gorilla), 未动telegram/qq/weixin/webhook契约
- §4.6 隔离/黑白名单/第三方边界未破坏

注: KOOK WS/WH 双模式待补(ReceiveMode), 实测需主人提供 CHANNEL_KOOK_TOKEN
2026-09-07 12:01:35 +08:00

170 lines
5.4 KiB
Go

// 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
// 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.
package httpclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// ErrAPI is the sentinel returned when the remote API reports a non-zero code or
// a non-2xx HTTP status. Tests compare with errors.Is.
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.
type Client struct {
http *http.Client
baseURL string
token string
log *slog.Logger
}
// Option customizes a Client at construction time.
type Option func(*Client)
// WithHTTPClient injects the *http.Client used for requests. Tests pass the
// client of an httptest.Server.
func WithHTTPClient(h *http.Client) Option {
return func(c *Client) { c.http = h }
}
// WithBaseURL sets the URL root that paths are joined to. A base ending in "/"
// has its trailing slash trimmed.
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.
func WithToken(token string) Option {
return func(c *Client) { c.token = token }
}
// WithLogger injects the logger. A nil logger stays silent.
func WithLogger(l *slog.Logger) Option {
return func(c *Client) {
if l == nil {
c.log = logger.New(logger.Options{Out: io.Discard})
} else {
c.log = l
}
}
}
// New builds a Client. With no base URL the caller must pass absolute URLs (via
// 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}),
}
for _, opt := range opts {
opt(c)
}
return c
}
// APIEnvelope is the common `{code,message,data}` response envelope used by the
// KOOK REST API. Code==0 means success; a non-zero code is mapped to ErrAPI.
type APIEnvelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
// DoJSON performs a request with the given method against path (joined to the
// base URL) and returns the decoded data payload of the envelope. A non-zero
// code or a non-2xx status is wrapped with ErrAPI. A 2xx body that is not an
// envelope (e.g. a raw payload) is returned verbatim as data.
func (c *Client) DoJSON(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
return c.do(ctx, method, c.resolve(path), body)
}
// GetJSON performs a GET against path with the given query parameters and
// returns the decoded data payload. A nil query adds nothing.
func (c *Client) GetJSON(ctx context.Context, path string, query url.Values) (json.RawMessage, error) {
target := c.resolve(path)
if len(query) > 0 {
target += "?" + query.Encode()
}
return c.do(ctx, http.MethodGet, target, nil)
}
// resolve joins path to the base URL, passing through an absolute path as-is.
func (c *Client) resolve(path string) string {
if c.baseURL == "" {
return path
}
return c.baseURL + path
}
// do performs the request, injecting auth and decoding the envelope.
func (c *Client) do(ctx context.Context, method, target string, body any) (json.RawMessage, error) {
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
return nil, fmt.Errorf("httpclient: encode %s request: %w", method, err)
}
}
req, err := http.NewRequestWithContext(ctx, method, target, &buf)
if err != nil {
return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, target, err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("httpclient: %s %s: %w", method, target, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, target, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("httpclient: %s %s: status %d: %s: %w", method, target, resp.StatusCode, truncate(raw), ErrAPI)
}
var envelope APIEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return raw, nil
}
if envelope.Code != 0 {
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w", method, target, envelope.Code, envelope.Message, ErrAPI)
}
return envelope.Data, nil
}
// truncate caps a byte slice to 512 bytes for inclusion in an error message.
func truncate(b []byte) string {
if len(b) > 512 {
return strings.TrimSpace(string(b[:512])) + "…"
}
return strings.TrimSpace(string(b))
}