Files
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

360 lines
10 KiB
Go

package wsutil
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// defaultHeartbeatInterval is used when a client enables heartbeat without a
// configured interval.
defaultHeartbeatInterval = 30 * time.Second
// defaultPongTimeout is the read deadline margin added to the heartbeat
// interval; if the peer sends nothing within interval+pongTimeout the socket
// is considered dead and the client reconnects.
defaultPongTimeout = 6 * time.Second
// writeWait bounds a single control-frame write.
writeWait = 10 * time.Second
)
// DialFunc returns the gateway URL to connect to. On a reconnect the platform may
// return a resume-augmented URL (splice session/sn via AppendQuery) so the
// gateway can resynchronize the offline message window.
type DialFunc func(ctx context.Context) (string, error)
// Handler carries the platform-specific protocol hooks the Client invokes. A
// nil field disables that hook. The hooks run on the Client's read goroutine
// (OnConnect / OnMessage) or a dedicated heartbeat goroutine (Heartbeat).
type Handler struct {
// OnConnect is called after the WebSocket connection is established and the
// application handshake (for example KOOK's hello) succeeds. Returning an
// error aborts the session and triggers a reconnect with backoff.
OnConnect func(ctx context.Context, c *Conn) error
// OnMessage is called for every application text frame received. Returning an
// error forces a reconnect.
OnMessage func(ctx context.Context, payload []byte) error
// Heartbeat is called on the heartbeat ticker (when HeartbeatInterval > 0) to
// send an application-level keepalive frame. A nil Heartbeat disables it.
Heartbeat func(ctx context.Context, c *Conn) error
}
// Client is a reusable WebSocket gateway connection manager. It dials a gateway,
// runs the read loop, maintains an optional application-level heartbeat with a
// liveness read deadline, and reconnects with exponential backoff until the
// context is canceled. Only the platform signaling is left to the Handler.
type Client struct {
dial DialFunc
handler Handler
dialer *websocket.Dialer
header http.Header
interval time.Duration
pongWait time.Duration
backoff *Backoff
log *slog.Logger
mu sync.Mutex
conn *Conn
cancel context.CancelFunc
closed bool
}
// Option customizes a Client at construction time.
type Option func(*Client)
// WithDialer injects the WebSocket dialer used for the handshake. Tests pass a
// dialer whose net dial is redirected to a fake server.
func WithDialer(d *websocket.Dialer) Option {
return func(c *Client) { c.dialer = d }
}
// WithHeader injects extra HTTP headers on the handshake (for example an
// Authorization: Bearer token).
func WithHeader(h http.Header) Option {
return func(c *Client) { c.header = h }
}
// WithHeartbeat enables an application-level heartbeat at the given interval.
// A nil interval disables the heartbeat ticker (default off).
func WithHeartbeat(interval time.Duration) Option {
return func(c *Client) { c.interval = interval }
}
// WithPongTimeout sets the read deadline margin added to the heartbeat interval.
// A non-positive value uses the default (6s).
func WithPongTimeout(d time.Duration) Option {
return func(c *Client) { c.pongWait = d }
}
// WithBackoff injects the reconnect backoff scheduler.
func WithBackoff(b *Backoff) Option {
return func(c *Client) { c.backoff = b }
}
// WithLogger injects the logger used by the client. 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. dial must be non-nil.
func New(dial DialFunc, h Handler, opts ...Option) *Client {
if dial == nil {
dial = func(context.Context) (string, error) { return "", nil }
}
c := &Client{
dial: dial,
handler: h,
dialer: &websocket.Dialer{HandshakeTimeout: 15 * time.Second},
pongWait: defaultPongTimeout,
backoff: NewBackoff(2*time.Second, 60*time.Second),
log: logger.New(logger.Options{Out: io.Discard}),
}
for _, opt := range opts {
opt(c)
}
return c
}
// Run executes the connect/reconnect loop until ctx is canceled. It returns
// nil when ctx is canceled and a non-nil error on a non-context termination. It
// is intended to be run in its own goroutine by the adapter's Connect.
func (c *Client) Run(ctx context.Context) error {
for {
if ctx.Err() != nil {
return ctx.Err()
}
url, err := c.dial(ctx)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
c.log.Warn("ws gateway dial failed", "err", err)
if !c.sleepBackoff(ctx) {
return ctx.Err()
}
continue
}
if err := c.connectOnce(ctx, url); err != nil {
c.log.Warn("ws session ended", "err", err)
}
if ctx.Err() != nil {
return ctx.Err()
}
}
}
// connectOnce dials one URL and runs the connect hook plus the read loop until
// the connection drops, the read loop errors, or ctx is canceled.
func (c *Client) connectOnce(ctx context.Context, url string) error {
if c.header == nil {
c.header = http.Header{}
}
ws, _, err := c.dialer.DialContext(ctx, url, c.header)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
c.log.Warn("ws dial failed", "err", err)
c.sleepBackoff(ctx)
return err
}
conn := &Conn{ws: ws}
defer conn.Close()
// A watcher closes the connection when ctx ends so a pending read (including
// the OnConnect handshake) unblocks and Run can exit promptly.
stop := make(chan struct{})
defer close(stop)
go c.watchCtx(ctx, conn, stop)
if c.handler.OnConnect != nil {
if cerr := c.handler.OnConnect(ctx, conn); cerr != nil {
if ctx.Err() != nil {
return ctx.Err()
}
c.log.Warn("ws connect hook failed", "err", cerr)
c.sleepBackoff(ctx)
return cerr
}
}
c.setConn(conn)
c.backoff.Reset()
c.runReadLoop(ctx, conn)
c.setConn(nil)
return nil
}
// watchCtx closes the connection when ctx is canceled so a pending read
// unblocks, letting Run exit promptly.
func (c *Client) watchCtx(ctx context.Context, conn *Conn, stop <-chan struct{}) {
select {
case <-ctx.Done():
_ = conn.Close()
case <-stop:
}
}
// runReadLoop consumes text frames until the read errors, the platform handler
// returns an error, or ctx is canceled. When heartbeat is enabled it also runs a
// heartbeat ticker and refreshes a read deadline so a silent-but-live socket
// stays open while a dead one is detected.
func (c *Client) runReadLoop(ctx context.Context, conn *Conn) {
hbCtx, hbCancel := context.WithCancel(ctx)
defer hbCancel()
if c.handler.Heartbeat != nil && c.interval > 0 {
go c.heartbeatLoop(hbCtx, conn)
}
// A liveness read deadline is only meaningful when an application heartbeat
// keeps the socket active; without one, an idle-but-open connection must stay
// open indefinitely.
deadline := time.Duration(0)
if c.handler.Heartbeat != nil && c.interval > 0 {
deadline = c.interval + c.pongWait
}
for {
if ctx.Err() != nil {
return
}
if deadline > 0 {
_ = conn.SetReadDeadline(time.Now().Add(deadline))
} else {
_ = conn.SetReadDeadline(time.Time{})
}
payload, err := conn.ReadMessage()
if err != nil {
return
}
if c.handler.OnMessage != nil {
if err := c.handler.OnMessage(ctx, payload); err != nil {
if ctx.Err() == nil {
c.log.Warn("ws on-message failed", "err", err)
}
return
}
}
}
}
// heartbeatLoop sends an application-level keepalive on every interval. A
// failing heartbeat closes the connection to force a reconnect.
func (c *Client) heartbeatLoop(ctx context.Context, conn *Conn) {
t := time.NewTicker(c.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := c.handler.Heartbeat(ctx, conn); err != nil {
c.log.Warn("ws heartbeat failed", "err", err)
_ = conn.Close()
return
}
}
}
}
// sleepBackoff sleeps the current backoff delay or until ctx is canceled,
// returning false (and leaving the connection for Run to exit) when ctx fires.
func (c *Client) sleepBackoff(ctx context.Context) bool {
d := c.backoff.Next()
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// setConn installs the live connection so Close can tear it down.
func (c *Client) setConn(conn *Conn) {
c.mu.Lock()
defer c.mu.Unlock()
c.conn = conn
}
// Close tears down the client, closing the live connection and its context.
func (c *Client) Close() error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
conn := c.conn
cancel := c.cancel
c.mu.Unlock()
if conn != nil {
_ = conn.Close()
}
if cancel != nil {
cancel()
}
return nil
}
// BindCancel associates a context cancel func so Close can stop a running Run.
func (c *Client) BindCancel(cancel context.CancelFunc) {
c.mu.Lock()
defer c.mu.Unlock()
c.cancel = cancel
}
// Conn is a thin, concurrency-safe wrapper over one *websocket.Conn. It
// serializes writes (gorilla allows one reader plus one writer) and exposes only
// the JSON text-frame surface the gateway clients need.
type Conn struct {
ws *websocket.Conn
writeMu sync.Mutex
}
// ReadMessage reads the next complete message, returning its text payload. It
// honors any read deadline set via SetReadDeadline.
func (c *Conn) ReadMessage() ([]byte, error) {
_, p, err := c.ws.ReadMessage()
if err != nil {
return nil, err
}
return p, nil
}
// WriteMessage writes a text frame with the given payload.
func (c *Conn) WriteMessage(payload []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
return c.ws.WriteMessage(websocket.TextMessage, payload)
}
// WriteJSON marshals v and writes it as a text frame.
func (c *Conn) WriteJSON(v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
return c.WriteMessage(data)
}
// SetReadDeadline bounds the next ReadMessage by the given absolute time.
func (c *Conn) SetReadDeadline(t time.Time) error { return c.ws.SetReadDeadline(t) }
// Close closes the underlying connection exactly once.
func (c *Conn) Close() error { return c.ws.Close() }