Files
Cyrene_v2/plugins/channels/kook/websocket.go
T
AskaEth 3be70f8232
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
- 新增 internal/channel/mediafetch:来源白名单(deny-by-default,支持 *.suffix)
  + 逐跳重定向校验 + 边读边限 + 超时;只回来源主机,绝不回完整 URL
- 四渠道各自 media.go 接通入站内联:只从平台载荷给出的引用取回字节
  (telegram file_id→getFile→file_path 形态校验;kook CDN;qq 文件段 url;
   weixin cdnURL + AES-ECB 解密)
- 常量全部复用既有来源:documents.ExtAllowed / DefaultMaxBytes / MaxPerMessage
- channelbridge 诚实提示文案改为对「渠道只给远程引用」与「代取失败」两种成因都成立
- 18 个测试函数(含四渠道「正文里的 URL ⇒ 0 次命中」红线反证、端到端进模型可见视图)
- 变异验证:去掉尺寸上限 ⇒ 四渠道红(含修正后不再是假绿的 weixin 超量用例)
2026-10-04 22:13:17 +08:00

266 lines
7.2 KiB
Go

package kook
// This file wires the stateful KOOK gateway WebSocket protocol onto the shared
// wsutil client. KOOK is a signal-driven gateway: after connecting, the server
// sends s=1 (hello) or s=6 (resume ack); the client heartbeats every ~30s with
// s=2 and expects s=3; inbound events arrive as s=0; the server may force a
// restart with s=5 (reconnect). The reconnect loop, heartbeat scheduling, and
// read-loop skeleton are provided by wsutil; only the signaling lives here.
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter/wsutil"
)
// KOOK gateway signal numbers.
const (
signalEvent = 0
signalHello = 1
signalPing = 2
signalPong = 3
signalResume = 4
signalReconnect = 5
signalResumeAck = 6
)
// kookSignal is the envelope of every gateway frame.
type kookSignal struct {
S int `json:"s"`
D json.RawMessage `json:"d"`
SN int64 `json:"sn"`
}
// helloData is the "d" payload of an s=1 hello frame.
type helloData struct {
Code int `json:"code"`
SessionID string `json:"session_id"`
}
// parseSignal decodes a gateway text frame.
func parseSignal(payload []byte) (kookSignal, error) {
var sig kookSignal
if err := json.Unmarshal(payload, &sig); err != nil {
return kookSignal{}, fmt.Errorf("kook: decode signal: %w", err)
}
return sig, nil
}
// heartbeatFrame builds the s=2 ping payload carrying the current sn.
func heartbeatFrame(sn int64) ([]byte, error) {
return json.Marshal(map[string]any{"s": signalPing, "sn": sn})
}
// runWS starts the gateway client goroutine. It is idempotent: a second call
// while one is running is a no-op. The client tears itself down when ctx ends.
func (a *Adapter) runWS(ctx context.Context) {
a.wsMu.Lock()
if a.wsc != nil {
a.wsMu.Unlock()
return
}
client := wsutil.New(
a.gatewayURL,
wsutil.Handler{
OnConnect: a.onConnect,
OnMessage: a.onMessage,
Heartbeat: a.heartbeat,
},
wsutil.WithHeartbeat(heartbeatInterval),
wsutil.WithPongTimeout(helloTimeout),
wsutil.WithLogger(a.log),
)
a.wsc = client
runCtx, cancel := context.WithCancel(ctx)
client.BindCancel(cancel)
a.wsMu.Unlock()
_ = client.Run(runCtx)
a.wsMu.Lock()
a.wsc = nil
a.wsMu.Unlock()
}
// gatewayURL returns the gateway address to dial. On a first connect (or after a
// session reset) it returns a fresh URL; on a drop with an established session it
// appends the resume parameters so the gateway can replay missed messages.
func (a *Adapter) gatewayURL(ctx context.Context) (string, error) {
base, err := a.fetchGatewayURL(ctx)
if err != nil {
return "", err
}
if !a.canResume() {
return base, nil
}
return wsutil.AppendQuery(base, map[string]string{
"resume": "1",
"sn": strconv.FormatInt(a.currentSN(), 10),
"session_id": a.currentSession(),
}), nil
}
// onConnect performs the application handshake. It accepts either an s=1 hello
// (fresh or restarted session) or an s=6 resume ack (reconnected with an intact
// session) as the first frame within helloTimeout.
func (a *Adapter) onConnect(ctx context.Context, c *wsutil.Conn) error {
_ = c.SetReadDeadline(time.Now().Add(helloTimeout))
payload, err := c.ReadMessage()
if err != nil {
return fmt.Errorf("kook: hello read: %w", err)
}
sig, err := parseSignal(payload)
if err != nil {
return err
}
switch sig.S {
case signalHello:
var hd helloData
if err := json.Unmarshal(sig.D, &hd); err != nil {
return fmt.Errorf("kook: hello decode: %w", err)
}
if hd.Code != 0 {
// Rejected (e.g. invalid/expired token or expired session): drop the
// whole session state so the next dial is fresh.
a.resetSession()
return fmt.Errorf("kook: hello rejected code=%d", hd.Code)
}
a.setSession(hd.SessionID, 0)
case signalResumeAck:
// Resume accepted; keep the existing session id and sn.
a.markResumeOK()
default:
return fmt.Errorf("kook: expected hello or resume ack, got s=%d", sig.S)
}
_ = c.SetReadDeadline(time.Time{})
return nil
}
// onMessage dispatches every inbound application frame. s=0 events are converted
// to UnifiedMessage and delivered; control signals update session state or force
// a reconnect.
func (a *Adapter) onMessage(ctx context.Context, payload []byte) error {
sig, err := parseSignal(payload)
if err != nil {
return err
}
switch sig.S {
case signalEvent:
if err := a.handleEvent(ctx, sig.D, payload); err != nil {
return err
}
a.advanceSN(sig.SN)
return nil
case signalPong, signalResumeAck:
return nil
case signalReconnect:
// Server invalidated the session: clear sn/session and re-dial fresh.
a.resetSession()
return errors.New("kook: gateway session invalid, reconnecting")
default:
return nil
}
}
// heartbeat sends the s=2 ping carrying the current sn.
func (a *Adapter) heartbeat(ctx context.Context, c *wsutil.Conn) error {
data, err := heartbeatFrame(a.currentSN())
if err != nil {
return err
}
return c.WriteMessage(data)
}
// handleEvent converts a single s=0 event into a UnifiedMessage and dispatches
// it. Malformed or non-chat events are logged and skipped, never fatal.
func (a *Adapter) handleEvent(ctx context.Context, data json.RawMessage, frame []byte) error {
msg, ok := a.toUnifiedMessage(data, frame)
if !ok {
return nil
}
if err := msg.Validate(); err != nil {
a.log.Warn("invalid message dropped", "err", err)
return nil
}
a.dispatch(ctx, msg)
return nil
}
// dispatch routes an inbound message to the push handler, falling back to the
// ReadMessages sink. 附件字节在**路由之前**内联(切片 IM-1):网关 WS 与 webhook
// 两条入站路径共用这一个决策点,下游因此永远拿到「已带字节 / 明确没有字节」的消息。
func (a *Adapter) dispatch(ctx context.Context, msg adapter.UnifiedMessage) {
a.inlineInboundMedia(ctx, &msg)
a.handlerMu.RLock()
fn := a.handler
a.handlerMu.RUnlock()
if fn != nil {
fn(ctx, msg)
return
}
a.emitMu.RLock()
emit := a.emit
a.emitMu.RUnlock()
if emit != nil {
emit(msg)
}
}
// setSession records a fresh session (new id, sn reset) and marks it resumable.
func (a *Adapter) setSession(sessionID string, sn int64) {
a.sessMu.Lock()
defer a.sessMu.Unlock()
a.sessionID = sessionID
a.lastSN = sn
a.resumeOK = sessionID != ""
}
// markResumeOK marks the current session as resumable after a resume ack.
func (a *Adapter) markResumeOK() {
a.sessMu.Lock()
defer a.sessMu.Unlock()
a.resumeOK = a.sessionID != ""
}
// resetSession clears all session state so the next connect is a fresh one.
func (a *Adapter) resetSession() {
a.sessMu.Lock()
defer a.sessMu.Unlock()
a.sessionID = ""
a.lastSN = 0
a.resumeOK = false
}
// advanceSN raises the tracked sn when a newer event is seen.
func (a *Adapter) advanceSN(sn int64) {
a.sessMu.Lock()
defer a.sessMu.Unlock()
if sn > a.lastSN {
a.lastSN = sn
}
}
func (a *Adapter) currentSession() string {
a.sessMu.RLock()
defer a.sessMu.RUnlock()
return a.sessionID
}
func (a *Adapter) currentSN() int64 {
a.sessMu.RLock()
defer a.sessMu.RUnlock()
return a.lastSN
}
func (a *Adapter) canResume() bool {
a.sessMu.RLock()
defer a.sessMu.RUnlock()
return a.sessionID != "" && a.resumeOK
}