Files
xinmi/internal/im/yunzhijia/websocket.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

338 lines
8.0 KiB
Go

package yunzhijia
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
ws "github.com/gorilla/websocket"
)
const (
webSocketHeartbeatInterval = 15 * time.Second
webSocketReadTimeout = 45 * time.Second
webSocketHandshakeTimeout = 10 * time.Second
webSocketMaxMessageSize = 1 << 20
webSocketMaxInvalidFrames = 3
webSocketMessageQueueSize = 64
)
var webSocketReconnectDelays = [...]time.Duration{
1 * time.Second,
2 * time.Second,
5 * time.Second,
10 * time.Second,
30 * time.Second,
60 * time.Second,
}
type webSocketFrame struct {
message *callbackMessage
ack []byte
control string
}
type LongConnClient struct {
url string
handler func(context.Context, *im.IncomingMessage) error
dialer *ws.Dialer
messages chan *im.IncomingMessage
mu sync.Mutex
conn *ws.Conn
closed atomic.Bool
}
func NewLongConnClient(webSocketURL string, handler func(context.Context, *im.IncomingMessage) error) *LongConnClient {
dialer := *ws.DefaultDialer
dialer.HandshakeTimeout = webSocketHandshakeTimeout
dialer.Proxy = nil
dialer.NetDialContext = safeDialContext
return &LongConnClient{
url: webSocketURL,
handler: handler,
dialer: &dialer,
messages: make(chan *im.IncomingMessage, webSocketMessageQueueSize),
}
}
func (c *LongConnClient) Start(ctx context.Context) error {
logger.Infof(ctx, "[IM] Yunzhijia WebSocket connecting")
go c.handleMessages(ctx)
attempt := 0
for {
if ctx.Err() != nil || c.closed.Load() {
return nil
}
connectedAt := time.Now()
err := c.connectAndRun(ctx)
if ctx.Err() != nil || c.closed.Load() {
return nil
}
if time.Since(connectedAt) >= webSocketReconnectDelays[len(webSocketReconnectDelays)-1] {
attempt = 0
}
delay := webSocketReconnectDelay(attempt)
attempt++
logger.Warnf(ctx, "[Yunzhijia] WebSocket connection lost: %v; reconnecting in %v", err, delay)
select {
case <-ctx.Done():
return nil
case <-time.After(delay):
}
}
}
func (c *LongConnClient) Stop() {
c.closed.Store(true)
c.closeConn()
}
func (c *LongConnClient) connectAndRun(ctx context.Context) error {
conn, _, err := c.dialer.DialContext(ctx, c.url, nil)
if err != nil {
return fmt.Errorf("dial websocket: %w", err)
}
c.mu.Lock()
c.conn = conn
c.mu.Unlock()
defer func() {
c.mu.Lock()
if c.conn == conn {
c.conn = nil
}
c.mu.Unlock()
_ = conn.Close()
}()
conn.SetReadLimit(webSocketMaxMessageSize)
_ = conn.SetReadDeadline(time.Now().Add(webSocketReadTimeout))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(webSocketReadTimeout))
})
heartbeatCtx, cancelHeartbeat := context.WithCancel(ctx)
defer cancelHeartbeat()
go c.heartbeatLoop(heartbeatCtx, conn)
logger.Infof(ctx, "[IM] Yunzhijia WebSocket connected")
invalidFrames := 0
for {
messageType, data, err := conn.ReadMessage()
if err != nil {
return fmt.Errorf("read websocket message: %w", err)
}
_ = conn.SetReadDeadline(time.Now().Add(webSocketReadTimeout))
if messageType != ws.TextMessage {
invalidFrames++
if invalidFrames >= webSocketMaxInvalidFrames {
return fmt.Errorf("too many non-text websocket frames")
}
continue
}
frame, err := parseWebSocketFrame(data)
if err != nil {
invalidFrames++
logger.Warnf(ctx, "[Yunzhijia] Invalid WebSocket frame: %v", err)
if invalidFrames >= webSocketMaxInvalidFrames {
return fmt.Errorf("too many invalid websocket frames")
}
continue
}
invalidFrames = 0
if len(frame.ack) > 0 {
if err := c.writeText(conn, frame.ack); err != nil {
return fmt.Errorf("send websocket ack: %w", err)
}
}
if frame.message == nil {
continue
}
incoming := toIncomingMessage(ctx, frame.message)
if incoming == nil {
continue
}
select {
case c.messages <- incoming:
case <-ctx.Done():
return nil
}
}
}
func (c *LongConnClient) handleMessages(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case msg := <-c.messages:
if err := c.handler(ctx, msg); err != nil {
logger.Errorf(ctx, "[Yunzhijia] Handle WebSocket message failed: %v", err)
}
}
}
}
func (c *LongConnClient) heartbeatLoop(ctx context.Context, conn *ws.Conn) {
ticker := time.NewTicker(webSocketHeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := conn.WriteControl(ws.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil {
logger.Warnf(ctx, "[Yunzhijia] WebSocket heartbeat failed: %v", err)
_ = conn.Close()
return
}
}
}
}
func (c *LongConnClient) writeText(conn *ws.Conn, data []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != conn {
return fmt.Errorf("websocket connection changed")
}
return conn.WriteMessage(ws.TextMessage, data)
}
func (c *LongConnClient) closeConn() {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
}
func webSocketReconnectDelay(attempt int) time.Duration {
if attempt < 0 {
attempt = 0
}
if attempt >= len(webSocketReconnectDelays) {
return webSocketReconnectDelays[len(webSocketReconnectDelays)-1]
}
return webSocketReconnectDelays[attempt]
}
func parseWebSocketFrame(data []byte) (*webSocketFrame, error) {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return nil, fmt.Errorf("empty frame")
}
plain := strings.ToLower(string(trimmed))
if plain == "ping" || plain == "pong" {
return &webSocketFrame{control: plain}, nil
}
var stringPayload string
if err := json.Unmarshal(trimmed, &stringPayload); err == nil {
control := strings.ToLower(strings.TrimSpace(stringPayload))
if control == "ping" || control == "pong" {
return &webSocketFrame{control: control}, nil
}
return nil, fmt.Errorf("unknown string frame")
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(trimmed, &fields); err != nil {
return nil, fmt.Errorf("decode frame: %w", err)
}
if msg := decodeBusinessMessage(trimmed); msg != nil {
return &webSocketFrame{message: msg}, nil
}
typeName := rawString(fields["type"])
if strings.EqualFold(typeName, "robotMessage") {
if msg := decodeBusinessMessage(fields["msg"]); msg != nil {
return &webSocketFrame{message: msg}, nil
}
return nil, fmt.Errorf("robotMessage envelope has invalid msg")
}
cmd := strings.ToLower(strings.TrimSpace(rawString(fields["cmd"])))
typeName = strings.ToLower(strings.TrimSpace(typeName))
event := strings.ToLower(strings.TrimSpace(rawString(fields["event"])))
control := cmd
if control == "" {
control = typeName
}
if control == "" {
control = event
}
frame := &webSocketFrame{control: control}
if cmd == "directpush" || typeName == "msgchg" {
var needAck bool
_ = json.Unmarshal(fields["needAck"], &needAck)
var seq int64
if needAck && json.Unmarshal(fields["seq"], &seq) == nil {
frame.ack, _ = json.Marshal(struct {
Cmd string `json:"cmd"`
Seq int64 `json:"seq"`
}{Cmd: "ack", Seq: seq})
}
return frame, nil
}
if control != "" {
return frame, nil
}
return nil, fmt.Errorf("frame has no business message or control type")
}
func decodeBusinessMessage(data []byte) *callbackMessage {
if len(data) == 0 {
return nil
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return nil
}
for _, key := range []string{"robotId", "robotName", "operatorOpenid", "operatorName", "msgId", "content"} {
var value string
if json.Unmarshal(fields[key], &value) != nil {
return nil
}
}
var messageType int
if json.Unmarshal(fields["type"], &messageType) != nil {
return nil
}
var messageTime int64
if json.Unmarshal(fields["time"], &messageTime) != nil {
return nil
}
var msg callbackMessage
if err := json.Unmarshal(data, &msg); err != nil {
return nil
}
return &msg
}
func rawString(raw json.RawMessage) string {
var value string
if len(raw) > 0 && json.Unmarshal(raw, &value) == nil {
return value
}
return ""
}