WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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
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
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
)
|
||||
|
||||
// NewFactory returns an im.AdapterFactory for WeCom channels.
|
||||
// Supports two modes: "webhook" (HTTP callback) and "websocket" (long connection, default).
|
||||
func NewFactory() im.AdapterFactory {
|
||||
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
|
||||
creds, err := im.ParseCredentials(channel.Credentials)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse wecom credentials: %w", err)
|
||||
}
|
||||
|
||||
mode := im.ResolveMode(channel, "websocket")
|
||||
|
||||
switch mode {
|
||||
case "webhook":
|
||||
corpAgentID := 0
|
||||
if v, ok := creds["corp_agent_id"]; ok {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
corpAgentID = int(val)
|
||||
case int:
|
||||
corpAgentID = val
|
||||
}
|
||||
}
|
||||
adapter, err := NewWebhookAdapter(
|
||||
im.GetString(creds, "corp_id"),
|
||||
im.GetString(creds, "agent_secret"),
|
||||
im.GetString(creds, "token"),
|
||||
im.GetString(creds, "encoding_aes_key"),
|
||||
corpAgentID,
|
||||
im.GetString(creds, "api_base_url"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return adapter, nil, nil
|
||||
|
||||
case "websocket":
|
||||
client, err := NewLongConnClient(
|
||||
im.GetString(creds, "bot_id"),
|
||||
im.GetString(creds, "bot_secret"),
|
||||
im.GetString(creds, "ws_endpoint"),
|
||||
im.GetString(creds, "bot_name"),
|
||||
msgHandler,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
wsCtx, wsCancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
if err := client.Start(wsCtx); err != nil && wsCtx.Err() == nil {
|
||||
logger.Errorf(context.Background(), "[IM] WeCom long connection stopped for channel %s: %v", channel.ID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
adapter := NewWSAdapter(client)
|
||||
// Cancelling the context alone does not unblock gorilla/websocket's
|
||||
// ReadMessage. Close the socket first so disabling, reconfiguring, or
|
||||
// shutting down a channel stops message delivery synchronously.
|
||||
stop := func() {
|
||||
client.Stop()
|
||||
wsCancel()
|
||||
}
|
||||
return adapter, stop, nil
|
||||
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unknown WeCom mode: %s", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,835 @@
|
||||
// WeCom Intelligent Bot long connection client.
|
||||
//
|
||||
// Protocol reference: https://developer.work.weixin.qq.com/document/path/101463
|
||||
// Node.js SDK reference: https://github.com/WecomTeam/aibot-node-sdk
|
||||
//
|
||||
// Flow:
|
||||
// 1. Connect to wss://openws.work.weixin.qq.com
|
||||
// 2. Send aibot_subscribe with bot_id + secret
|
||||
// 3. Receive aibot_msg_callback / aibot_event_callback frames
|
||||
// 4. Reply via aibot_respond_msg on the same WebSocket
|
||||
// 5. Heartbeat via ping/pong every 30s
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
ws "github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWSEndpoint = "wss://openws.work.weixin.qq.com"
|
||||
|
||||
cmdSubscribe = "aibot_subscribe"
|
||||
cmdPing = "ping"
|
||||
cmdMsgCallback = "aibot_msg_callback"
|
||||
cmdEventCallback = "aibot_event_callback"
|
||||
cmdResponse = "aibot_respond_msg"
|
||||
|
||||
defaultHeartbeatInterval = 30 * time.Second
|
||||
defaultReconnectBaseDelay = 1 * time.Second
|
||||
defaultReconnectMaxDelay = 30 * time.Second
|
||||
defaultMaxReconnectAttempts = -1 // infinite
|
||||
|
||||
// readTimeout is how long the receive loop waits for any message (including
|
||||
// heartbeat pong) before treating the connection as dead. Set to 3× heartbeat
|
||||
// interval so a single missed pong does not cause a spurious reconnect.
|
||||
readTimeout = 3 * defaultHeartbeatInterval
|
||||
|
||||
// writeTimeout bounds a single frame write. Without it a stalled peer can
|
||||
// block a write while holding c.mu, which in turn blocks Stop() and — since
|
||||
// the IM service tears channels down while holding its own channel-map lock
|
||||
// — every other channel operation in the process.
|
||||
writeTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// wsFrame is the JSON frame exchanged over the WeCom bot WebSocket.
|
||||
type wsFrame struct {
|
||||
Cmd string `json:"cmd,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
}
|
||||
|
||||
// botMessage is the body of an aibot_msg_callback frame.
|
||||
// Supports text, image, file, voice, and mixed message types.
|
||||
// Reference: https://developer.work.weixin.qq.com/document/path/100719
|
||||
type botMessage struct {
|
||||
MsgID string `json:"msgid"`
|
||||
AiBotID string `json:"aibotid"`
|
||||
ChatID string `json:"chatid"`
|
||||
ChatType string `json:"chattype"` // "single" or "group"
|
||||
MsgType string `json:"msgtype"` // "text", "image", "file", "voice", "video", "mixed", "stream"
|
||||
CreateTime int64 `json:"create_time"`
|
||||
From struct {
|
||||
UserID string `json:"userid"`
|
||||
} `json:"from"`
|
||||
Text struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text"`
|
||||
Image struct {
|
||||
URL string `json:"url"` // encrypted download URL, valid for 5 minutes
|
||||
AESKey string `json:"aeskey"` // per-message AES key for decrypting downloaded content
|
||||
} `json:"image"`
|
||||
File struct {
|
||||
URL string `json:"url"` // encrypted download URL, valid for 5 minutes
|
||||
AESKey string `json:"aeskey"` // per-message AES key for decrypting downloaded content
|
||||
} `json:"file"`
|
||||
Voice struct {
|
||||
Content string `json:"content"` // speech-to-text result
|
||||
} `json:"voice"`
|
||||
Video struct {
|
||||
URL string `json:"url"` // encrypted download URL, valid for 5 minutes
|
||||
AESKey string `json:"aeskey"` // per-message AES key for decrypting downloaded content
|
||||
} `json:"video"`
|
||||
Mixed struct {
|
||||
MsgItem []botMixedItem `json:"msg_item"`
|
||||
} `json:"mixed"`
|
||||
Quote *botMessage `json:"quote,omitempty"` // quoted message (optional)
|
||||
Event struct {
|
||||
EventType string `json:"eventtype"`
|
||||
} `json:"event"`
|
||||
}
|
||||
|
||||
// botMixedItem is one element in a mixed (text+image) message.
|
||||
type botMixedItem struct {
|
||||
MsgType string `json:"msgtype"` // "text" or "image"
|
||||
Text struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text"`
|
||||
Image struct {
|
||||
URL string `json:"url"`
|
||||
AESKey string `json:"aeskey"`
|
||||
} `json:"image"`
|
||||
}
|
||||
|
||||
// streamReplyBody is the body for a streaming text reply.
|
||||
type streamReplyBody struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Stream struct {
|
||||
ID string `json:"id"`
|
||||
Finish bool `json:"finish"`
|
||||
Content string `json:"content"`
|
||||
} `json:"stream"`
|
||||
}
|
||||
|
||||
// MessageHandler is called when an IM message is received via long connection.
|
||||
type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
|
||||
|
||||
// LongConnClient manages a WeCom intelligent bot WebSocket long connection.
|
||||
type LongConnClient struct {
|
||||
botID string
|
||||
secret string
|
||||
endpoint string
|
||||
extraAllowedHost string // hostname from custom endpoint for SSRF allowlist
|
||||
handler MessageHandler
|
||||
|
||||
conn *ws.Conn
|
||||
mu sync.Mutex
|
||||
closed atomic.Bool
|
||||
reqSeq atomic.Int64
|
||||
|
||||
// streamBufs tracks accumulated content per stream ID.
|
||||
// WeCom stream protocol is replace-based: each frame's content replaces
|
||||
// the previously displayed text, so we must send the full accumulated text.
|
||||
streamBufsMu sync.Mutex
|
||||
streamBufs map[string]*strings.Builder
|
||||
|
||||
// botDisplayName caches the bot's display name for @mention stripping.
|
||||
// Set from credentials "bot_name", or learned from double-space messages.
|
||||
botDisplayName atomic.Value // string
|
||||
}
|
||||
|
||||
// NewLongConnClient creates a WeCom long connection client.
|
||||
// wsEndpoint overrides the default WebSocket URL; empty uses the public cloud endpoint.
|
||||
// botName is the bot's display name for @mention stripping; empty to auto-detect.
|
||||
func NewLongConnClient(botID, secret, wsEndpoint, botName string, handler MessageHandler) (*LongConnClient, error) {
|
||||
if wsEndpoint == "" {
|
||||
wsEndpoint = defaultWSEndpoint
|
||||
}
|
||||
wsEndpoint = strings.TrimRight(wsEndpoint, "/")
|
||||
if err := validateEndpointURL(wsEndpoint, defaultWSEndpoint, "wss"); err != nil {
|
||||
return nil, fmt.Errorf("invalid ws_endpoint: %w", err)
|
||||
}
|
||||
c := &LongConnClient{
|
||||
botID: botID,
|
||||
secret: secret,
|
||||
endpoint: wsEndpoint,
|
||||
extraAllowedHost: extraHostFromEndpoint(wsEndpoint, defaultWSEndpoint),
|
||||
handler: handler,
|
||||
}
|
||||
if botName != "" {
|
||||
c.botDisplayName.Store(botName)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Start connects and runs the long connection loop. It reconnects automatically on failure.
|
||||
func (c *LongConnClient) Start(ctx context.Context) error {
|
||||
logger.Infof(ctx, "[IM] WeCom WebSocket connecting (bot_id=%s)...", c.botID)
|
||||
|
||||
attempts := 0
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
connectedAt := time.Now()
|
||||
err := c.connectAndRun(ctx)
|
||||
if c.closed.Load() {
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// If the connection was up for longer than the max backoff window,
|
||||
// the disconnect is likely transient — reset so we retry quickly.
|
||||
if time.Since(connectedAt) > defaultReconnectMaxDelay {
|
||||
attempts = 0
|
||||
}
|
||||
|
||||
attempts++
|
||||
if defaultMaxReconnectAttempts >= 0 && attempts >= defaultMaxReconnectAttempts {
|
||||
return fmt.Errorf("max reconnect attempts reached: %w", err)
|
||||
}
|
||||
|
||||
delay := reconnectDelay(attempts)
|
||||
logger.Warnf(ctx, "[WeCom] Connection lost (%v), reconnecting in %v (attempt %d)...", err, delay, attempts)
|
||||
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop gracefully closes the connection.
|
||||
func (c *LongConnClient) Stop() {
|
||||
c.closed.Store(true)
|
||||
c.closeConn()
|
||||
}
|
||||
|
||||
// SendReply sends a text reply through the WebSocket connection.
|
||||
// This is used by the IM service to reply to messages in long connection mode.
|
||||
func (c *LongConnClient) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
|
||||
var reqID string
|
||||
if incoming.Extra != nil {
|
||||
reqID = incoming.Extra["req_id"]
|
||||
}
|
||||
if reqID == "" {
|
||||
return fmt.Errorf("missing req_id in incoming message extra")
|
||||
}
|
||||
|
||||
// Generate a unique stream ID for this reply
|
||||
streamID := fmt.Sprintf("stream_%d", c.reqSeq.Add(1))
|
||||
|
||||
body := streamReplyBody{MsgType: "stream"}
|
||||
body.Stream.ID = streamID
|
||||
body.Stream.Finish = true
|
||||
body.Stream.Content = reply.Content
|
||||
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal reply body: %w", err)
|
||||
}
|
||||
|
||||
frame := wsFrame{
|
||||
Cmd: cmdResponse,
|
||||
Headers: map[string]string{"req_id": reqID},
|
||||
Body: bodyBytes,
|
||||
}
|
||||
|
||||
return c.writeJSON(frame)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Streaming support: send answer chunks over WebSocket in real-time
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// StartStream begins a streaming reply session.
|
||||
// Returns a stream ID that must be used in subsequent chunk/end calls.
|
||||
func (c *LongConnClient) StartStream(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
|
||||
if incoming.Extra == nil || incoming.Extra["req_id"] == "" {
|
||||
return "", fmt.Errorf("missing req_id in incoming message extra")
|
||||
}
|
||||
streamID := fmt.Sprintf("stream_%d", c.reqSeq.Add(1))
|
||||
|
||||
// Initialize the accumulation buffer for this stream
|
||||
c.streamBufsMu.Lock()
|
||||
if c.streamBufs == nil {
|
||||
c.streamBufs = make(map[string]*strings.Builder)
|
||||
}
|
||||
c.streamBufs[streamID] = &strings.Builder{}
|
||||
c.streamBufsMu.Unlock()
|
||||
|
||||
return streamID, nil
|
||||
}
|
||||
|
||||
// UpdateStreamContent replaces the user-visible stream text (WeCom replace protocol).
|
||||
func (c *LongConnClient) UpdateStreamContent(ctx context.Context, incoming *im.IncomingMessage, streamID string, fullContent string) error {
|
||||
if fullContent == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.streamBufsMu.Lock()
|
||||
buf, ok := c.streamBufs[streamID]
|
||||
if !ok {
|
||||
c.streamBufsMu.Unlock()
|
||||
return fmt.Errorf("unknown stream ID: %s", streamID)
|
||||
}
|
||||
buf.Reset()
|
||||
buf.WriteString(fullContent)
|
||||
c.streamBufsMu.Unlock()
|
||||
|
||||
return c.sendStreamFrame(incoming, streamID, fullContent, false)
|
||||
}
|
||||
|
||||
// FinalizeStream replaces the display with answer-only content before the stream ends.
|
||||
func (c *LongConnClient) FinalizeStream(ctx context.Context, incoming *im.IncomingMessage, streamID string, finalContent string) error {
|
||||
return c.UpdateStreamContent(ctx, incoming, streamID, finalContent)
|
||||
}
|
||||
|
||||
// SendStreamChunk is deprecated; kept as an alias for UpdateStreamContent.
|
||||
func (c *LongConnClient) SendStreamChunk(ctx context.Context, incoming *im.IncomingMessage, streamID string, content string) error {
|
||||
return c.UpdateStreamContent(ctx, incoming, streamID, content)
|
||||
}
|
||||
|
||||
// EndStream sends the final frame with the full accumulated content and cleans up.
|
||||
// It retries briefly if the connection is temporarily unavailable during a reconnect.
|
||||
func (c *LongConnClient) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
|
||||
c.streamBufsMu.Lock()
|
||||
buf, ok := c.streamBufs[streamID]
|
||||
var fullContent string
|
||||
if ok {
|
||||
fullContent = buf.String()
|
||||
delete(c.streamBufs, streamID)
|
||||
}
|
||||
c.streamBufsMu.Unlock()
|
||||
|
||||
err := c.sendStreamFrame(incoming, streamID, fullContent, true)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retry up to 3 times with short delays to ride out a reconnection window.
|
||||
for i := 0; i < 3; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
if retryErr := c.sendStreamFrame(incoming, streamID, fullContent, true); retryErr == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *LongConnClient) sendStreamFrame(incoming *im.IncomingMessage, streamID, content string, finish bool) error {
|
||||
var reqID string
|
||||
if incoming.Extra != nil {
|
||||
reqID = incoming.Extra["req_id"]
|
||||
}
|
||||
if reqID == "" {
|
||||
return fmt.Errorf("missing req_id in incoming message extra")
|
||||
}
|
||||
|
||||
body := streamReplyBody{MsgType: "stream"}
|
||||
body.Stream.ID = streamID
|
||||
body.Stream.Finish = finish
|
||||
body.Stream.Content = content
|
||||
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal stream body: %w", err)
|
||||
}
|
||||
|
||||
frame := wsFrame{
|
||||
Cmd: cmdResponse,
|
||||
Headers: map[string]string{"req_id": reqID},
|
||||
Body: bodyBytes,
|
||||
}
|
||||
|
||||
return c.writeJSON(frame)
|
||||
}
|
||||
|
||||
func (c *LongConnClient) connectAndRun(ctx context.Context) error {
|
||||
dialer := *ws.DefaultDialer
|
||||
dialer.NetDialContext = secutils.SSRFSafeDialContext
|
||||
conn, _, err := dialer.DialContext(ctx, c.endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.closeConnIf(conn)
|
||||
// NOTE: streamBufs is intentionally NOT cleared on reconnect.
|
||||
// Active streams survive reconnections — the WeCom replace-based
|
||||
// protocol means the next UpdateStreamContent will resend the full
|
||||
// accumulated content on the new connection. EndStream always
|
||||
// cleans up the buffer, so there is no memory leak.
|
||||
}()
|
||||
|
||||
// A Stop() that lands between the dial and the assignment above finds no
|
||||
// connection to close, so re-check here to avoid running a receive loop
|
||||
// that nobody will tear down until the read deadline expires.
|
||||
if c.closed.Load() {
|
||||
return fmt.Errorf("client stopped")
|
||||
}
|
||||
|
||||
// Authenticate
|
||||
if err := c.authenticate(ctx); err != nil {
|
||||
return fmt.Errorf("authenticate: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "[IM] WeCom WebSocket connected successfully (bot_id=%s)", c.botID)
|
||||
|
||||
// Start heartbeat
|
||||
heartbeatCtx, heartbeatCancel := context.WithCancel(ctx)
|
||||
defer heartbeatCancel()
|
||||
go c.heartbeatLoop(heartbeatCtx, conn)
|
||||
|
||||
// Message receive loop with read deadline.
|
||||
// The deadline is reset on every successful read; if no message arrives
|
||||
// within readTimeout (including heartbeat pong frames), the connection
|
||||
// is considered dead and we fall through to reconnect.
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read message: %w", err)
|
||||
}
|
||||
|
||||
var frame wsFrame
|
||||
if err := json.Unmarshal(message, &frame); err != nil {
|
||||
logger.Warnf(ctx, "[WeCom] Failed to unmarshal frame: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch frame.Cmd {
|
||||
case cmdMsgCallback, cmdEventCallback:
|
||||
// Detach from connection ctx so in-flight messages survive reconnects.
|
||||
go c.handleCallback(context.WithoutCancel(ctx), conn, frame)
|
||||
default:
|
||||
// pong or other control frames — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) authenticate(ctx context.Context) error {
|
||||
authBody, _ := json.Marshal(map[string]string{
|
||||
"bot_id": c.botID,
|
||||
"secret": c.secret,
|
||||
})
|
||||
|
||||
reqID := fmt.Sprintf("%s_%d", cmdSubscribe, time.Now().UnixNano())
|
||||
frame := wsFrame{
|
||||
Cmd: cmdSubscribe,
|
||||
Headers: map[string]string{"req_id": reqID},
|
||||
Body: authBody,
|
||||
}
|
||||
|
||||
if err := c.writeJSON(frame); err != nil {
|
||||
return fmt.Errorf("send subscribe: %w", err)
|
||||
}
|
||||
|
||||
// Read auth response
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
if conn == nil {
|
||||
return fmt.Errorf("connection closed")
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
_, msg, err := conn.ReadMessage()
|
||||
_ = conn.SetReadDeadline(time.Time{}) // clear deadline
|
||||
if err != nil {
|
||||
return fmt.Errorf("read auth response: %w", err)
|
||||
}
|
||||
|
||||
var resp wsFrame
|
||||
if err := json.Unmarshal(msg, &resp); err != nil {
|
||||
return fmt.Errorf("unmarshal auth response: %w", err)
|
||||
}
|
||||
|
||||
if resp.ErrCode != 0 {
|
||||
return fmt.Errorf("auth failed: code=%d msg=%s", resp.ErrCode, resp.ErrMsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *LongConnClient) heartbeatLoop(ctx context.Context, conn *ws.Conn) {
|
||||
ticker := time.NewTicker(defaultHeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
reqID := fmt.Sprintf("%s_%d", cmdPing, time.Now().UnixNano())
|
||||
frame := wsFrame{
|
||||
Cmd: cmdPing,
|
||||
Headers: map[string]string{"req_id": reqID},
|
||||
}
|
||||
if err := c.writeJSON(frame); err != nil {
|
||||
logger.Warnf(ctx, "[WeCom] Heartbeat failed: %v, closing connection to trigger reconnect", err)
|
||||
c.closeConnIf(conn)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) handleCallback(ctx context.Context, conn *ws.Conn, frame wsFrame) {
|
||||
// Log raw message body for debugging
|
||||
logger.Debugf(ctx, "[WeCom] Raw callback body: %s", string(frame.Body))
|
||||
|
||||
var msg botMessage
|
||||
if err := json.Unmarshal(frame.Body, &msg); err != nil {
|
||||
logger.Warnf(ctx, "[WeCom] Failed to unmarshal callback body: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debugf(ctx, "[WeCom] Parsed message: msgid=%s msgtype=%s from=%s chattype=%s text=%q image_url=%q file_url=%q voice=%q mixed_items=%d",
|
||||
msg.MsgID, msg.MsgType, msg.From.UserID, msg.ChatType,
|
||||
msg.Text.Content, msg.Image.URL, msg.File.URL, msg.Voice.Content, len(msg.Mixed.MsgItem))
|
||||
|
||||
// Handle server-side events (e.g. disconnected_event) before normal messages.
|
||||
if msg.MsgType == "event" {
|
||||
switch msg.Event.EventType {
|
||||
case "disconnected_event":
|
||||
logger.Warnf(ctx, "[WeCom] Server sent disconnected_event, closing connection to trigger reconnect")
|
||||
c.closeConnIf(conn)
|
||||
default:
|
||||
logger.Infof(ctx, "[WeCom] Ignoring event type: %s", msg.Event.EventType)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
chatType := im.ChatTypeDirect
|
||||
chatID := ""
|
||||
isGroup := msg.ChatType == "group"
|
||||
if isGroup {
|
||||
chatType = im.ChatTypeGroup
|
||||
chatID = msg.ChatID
|
||||
}
|
||||
|
||||
// Preserve req_id in Extra for reply routing
|
||||
reqID := ""
|
||||
if frame.Headers != nil {
|
||||
reqID = frame.Headers["req_id"]
|
||||
}
|
||||
|
||||
var incoming *im.IncomingMessage
|
||||
|
||||
switch msg.MsgType {
|
||||
case "text":
|
||||
// WeCom does not strip @mention in group chat; strip it so slash
|
||||
// commands (/stop, /clear) are recognized.
|
||||
textContent := msg.Text.Content
|
||||
if isGroup {
|
||||
textContent = c.stripAtMention(textContent)
|
||||
}
|
||||
incoming = &im.IncomingMessage{
|
||||
Platform: im.PlatformWeCom,
|
||||
MessageType: im.MessageTypeText,
|
||||
UserID: msg.From.UserID,
|
||||
UserName: msg.From.UserID,
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
Content: strings.TrimSpace(textContent),
|
||||
MessageID: msg.MsgID,
|
||||
Extra: map[string]string{"req_id": reqID},
|
||||
}
|
||||
|
||||
case "voice":
|
||||
// WeCom returns speech-to-text content directly — treat as text query
|
||||
if msg.Voice.Content == "" {
|
||||
logger.Infof(ctx, "[WeCom] Ignoring voice message with empty content")
|
||||
return
|
||||
}
|
||||
incoming = &im.IncomingMessage{
|
||||
Platform: im.PlatformWeCom,
|
||||
MessageType: im.MessageTypeText,
|
||||
UserID: msg.From.UserID,
|
||||
UserName: msg.From.UserID,
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
Content: strings.TrimSpace(msg.Voice.Content),
|
||||
MessageID: msg.MsgID,
|
||||
Extra: map[string]string{"req_id": reqID},
|
||||
}
|
||||
|
||||
case "image":
|
||||
if msg.Image.URL == "" {
|
||||
logger.Infof(ctx, "[WeCom] Ignoring image message with empty URL")
|
||||
return
|
||||
}
|
||||
incoming = &im.IncomingMessage{
|
||||
Platform: im.PlatformWeCom,
|
||||
MessageType: im.MessageTypeImage,
|
||||
UserID: msg.From.UserID,
|
||||
UserName: msg.From.UserID,
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
MessageID: msg.MsgID,
|
||||
FileKey: msg.Image.URL, // store encrypted URL in FileKey
|
||||
FileName: msg.MsgID + ".png",
|
||||
Extra: map[string]string{"req_id": reqID, "aes_key": msg.Image.AESKey},
|
||||
}
|
||||
|
||||
case "file":
|
||||
if msg.File.URL == "" {
|
||||
logger.Infof(ctx, "[WeCom] Ignoring file message with empty URL")
|
||||
return
|
||||
}
|
||||
incoming = &im.IncomingMessage{
|
||||
Platform: im.PlatformWeCom,
|
||||
MessageType: im.MessageTypeFile,
|
||||
UserID: msg.From.UserID,
|
||||
UserName: msg.From.UserID,
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
MessageID: msg.MsgID,
|
||||
FileKey: msg.File.URL, // store encrypted URL in FileKey
|
||||
FileName: msg.MsgID, // WeCom doesn't provide file name directly
|
||||
Extra: map[string]string{"req_id": reqID, "aes_key": msg.File.AESKey},
|
||||
}
|
||||
|
||||
case "mixed":
|
||||
// Extract text parts for QA content, and detect if any images are present
|
||||
incoming = c.convertMixedMessage(&msg, chatID, chatType, reqID)
|
||||
if incoming == nil {
|
||||
logger.Infof(ctx, "[WeCom] Ignoring empty mixed message")
|
||||
return
|
||||
}
|
||||
|
||||
default:
|
||||
logger.Infof(ctx, "[WeCom] Ignoring unsupported message type: %s", msg.MsgType)
|
||||
return
|
||||
}
|
||||
|
||||
// Populate quote context if the incoming message has a quoted/replied message.
|
||||
if incoming != nil && msg.Quote != nil {
|
||||
incoming.Quote = buildQuotedMessage(msg.Quote, msg.AiBotID)
|
||||
if incoming.Quote != nil {
|
||||
logger.Infof(ctx, "[WeCom] Quote detected: msgid=%s sender=%s is_bot=%v content_len=%d non_text_type=%s",
|
||||
msg.Quote.MsgID, msg.Quote.From.UserID, incoming.Quote.IsBotMessage, len(incoming.Quote.Content), incoming.Quote.NonTextType)
|
||||
// Debug: log raw IDs for bot identity verification during initial rollout
|
||||
logger.Debugf(ctx, "[WeCom] Quote identity debug: quote.from.userid=%q quote.aibotid=%q msg.aibotid=%q",
|
||||
msg.Quote.From.UserID, msg.Quote.AiBotID, msg.AiBotID)
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.handler(ctx, incoming); err != nil {
|
||||
logger.Errorf(ctx, "[WeCom] Handle message error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// convertMixedMessage converts a WeCom mixed (text+image) message.
|
||||
// Extracts all text content for QA; if there's only images, treat as image message.
|
||||
func (c *LongConnClient) convertMixedMessage(msg *botMessage, chatID string, chatType im.ChatType, reqID string) *im.IncomingMessage {
|
||||
isGroup := chatType == im.ChatTypeGroup
|
||||
var textParts []string
|
||||
var firstImageURL string
|
||||
var firstImageAESKey string
|
||||
|
||||
for _, item := range msg.Mixed.MsgItem {
|
||||
switch item.MsgType {
|
||||
case "text":
|
||||
t := strings.TrimSpace(item.Text.Content)
|
||||
if isGroup {
|
||||
t = c.stripAtMention(t)
|
||||
}
|
||||
if t != "" {
|
||||
textParts = append(textParts, t)
|
||||
}
|
||||
case "image":
|
||||
if firstImageURL == "" && item.Image.URL != "" {
|
||||
firstImageURL = item.Image.URL
|
||||
firstImageAESKey = item.Image.AESKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's text content, treat as text message (QA query)
|
||||
if len(textParts) > 0 {
|
||||
return &im.IncomingMessage{
|
||||
Platform: im.PlatformWeCom,
|
||||
MessageType: im.MessageTypeText,
|
||||
UserID: msg.From.UserID,
|
||||
UserName: msg.From.UserID,
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
Content: strings.Join(textParts, "\n"),
|
||||
MessageID: msg.MsgID,
|
||||
Extra: map[string]string{"req_id": reqID},
|
||||
}
|
||||
}
|
||||
|
||||
// Only images, treat as image message (save to KB)
|
||||
if firstImageURL != "" {
|
||||
return &im.IncomingMessage{
|
||||
Platform: im.PlatformWeCom,
|
||||
MessageType: im.MessageTypeImage,
|
||||
UserID: msg.From.UserID,
|
||||
UserName: msg.From.UserID,
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
MessageID: msg.MsgID,
|
||||
FileKey: firstImageURL,
|
||||
FileName: msg.MsgID + ".png",
|
||||
Extra: map[string]string{"req_id": reqID, "aes_key": firstImageAESKey},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// closeConn forcibly closes the active WebSocket, which unblocks any pending
|
||||
// ReadMessage call in the receive loop and triggers a reconnection.
|
||||
//
|
||||
// c.mu is released before Close because closing a TLS connection writes a
|
||||
// close_notify alert and can block for seconds on a stalled peer. Holding c.mu
|
||||
// across that write stalls Stop(), and the IM service tears channels down while
|
||||
// holding its own channel-map lock, so the delay spreads to every other channel
|
||||
// operation in the process.
|
||||
func (c *LongConnClient) closeConn() {
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.conn = nil
|
||||
c.mu.Unlock()
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// closeConnIf closes conn only while it is still the active connection.
|
||||
// Callers bound to a single connection generation — the heartbeat loop and the
|
||||
// detached callback handlers — must use this instead of closeConn: their
|
||||
// goroutines can outlive the connection that spawned them, and an unconditional
|
||||
// close would tear down a healthy connection opened by a later reconnect.
|
||||
func (c *LongConnClient) closeConnIf(conn *ws.Conn) {
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
active := c.conn == conn
|
||||
if active {
|
||||
c.conn = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if active {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// stripAtMentionBasic removes a leading "@Name" prefix from group chat content.
|
||||
// Strategies: double-space split → heuristic (space + "/" or CJK) → first @word.
|
||||
// Used directly by the webhook adapter (stateless) and as the base for
|
||||
// LongConnClient.stripAtMention (stateful).
|
||||
func stripAtMentionBasic(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if !strings.HasPrefix(content, "@") {
|
||||
return content
|
||||
}
|
||||
// Some WeCom clients insert two spaces between @mention and user text.
|
||||
if idx := strings.Index(content, " "); idx > 0 {
|
||||
return strings.TrimSpace(content[idx+2:])
|
||||
}
|
||||
// Heuristic: bot names are ASCII words; user content starts with "/"
|
||||
// or non-ASCII (CJK). Scan for the transition.
|
||||
for i := 1; i < len(content); i++ {
|
||||
if content[i] == ' ' && i+1 < len(content) {
|
||||
if next := content[i+1]; next == '/' || next >= 0x80 {
|
||||
return strings.TrimSpace(content[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: strip first @word.
|
||||
if idx := strings.Index(content, " "); idx > 0 {
|
||||
return strings.TrimSpace(content[idx+1:])
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// stripAtMention removes the leading "@BotName" prefix from group chat messages.
|
||||
// Bot names may contain spaces (e.g., "WeKnora Bot"), so this adds two strategies
|
||||
// on top of stripAtMentionBasic: (1) double-space split with bot-name learning,
|
||||
// (2) cached/configured bot name prefix match.
|
||||
// Concurrent calls are safe; atomic.Value races are benign (same bot name).
|
||||
func (c *LongConnClient) stripAtMention(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if !strings.HasPrefix(content, "@") {
|
||||
return content
|
||||
}
|
||||
|
||||
// Strategy 1: double-space separator. Learn bot name on first occurrence
|
||||
// (skip if already cached to avoid false positives from user double-spaces).
|
||||
if idx := strings.Index(content, " "); idx > 0 {
|
||||
botName := content[1:idx] // between "@" and " "
|
||||
if cached, _ := c.botDisplayName.Load().(string); cached == "" && botName != "" {
|
||||
c.botDisplayName.Store(botName)
|
||||
}
|
||||
return strings.TrimSpace(content[idx+2:])
|
||||
}
|
||||
|
||||
// Strategy 2: cached/configured bot name with word-boundary check.
|
||||
if name, _ := c.botDisplayName.Load().(string); name != "" {
|
||||
prefix := "@" + name
|
||||
if strings.HasPrefix(content, prefix) && (len(content) == len(prefix) || content[len(prefix)] == ' ') {
|
||||
return strings.TrimSpace(content[len(prefix):])
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: delegate to stateless helper (heuristic scan + first-@word fallback).
|
||||
return stripAtMentionBasic(content)
|
||||
}
|
||||
|
||||
func (c *LongConnClient) writeJSON(v interface{}) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("connection closed")
|
||||
}
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||
return c.conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
func reconnectDelay(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
return defaultReconnectBaseDelay
|
||||
}
|
||||
// Cap the exponent to avoid int64 overflow: base (1e9 ns) * 2^shift
|
||||
// overflows when shift ≥ 34, producing a negative duration that would
|
||||
// bypass the max-delay check and cause a busy reconnect loop.
|
||||
// Any shift ≥ 5 already exceeds the 30s max, so 30 is a safe ceiling.
|
||||
shift := attempt - 1
|
||||
if shift > 30 {
|
||||
return defaultReconnectMaxDelay
|
||||
}
|
||||
delay := defaultReconnectBaseDelay * (1 << shift)
|
||||
if delay > defaultReconnectMaxDelay {
|
||||
delay = defaultReconnectMaxDelay
|
||||
}
|
||||
return delay
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ws "github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// newTestConn dials a throwaway WebSocket server and returns the client side.
|
||||
// The server drains frames until the client goes away.
|
||||
func newTestConn(t *testing.T) *ws.Conn {
|
||||
t.Helper()
|
||||
|
||||
upgrader := ws.Upgrader{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
serverConn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = serverConn.Close() }()
|
||||
for {
|
||||
if _, _, err := serverConn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
conn, _, err := ws.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial test websocket: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
// connUsable reports whether conn still accepts writes.
|
||||
func connUsable(t *testing.T, conn *ws.Conn) bool {
|
||||
t.Helper()
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
return conn.WriteMessage(ws.TextMessage, []byte("probe")) == nil
|
||||
}
|
||||
|
||||
func TestCloseConnClosesAndClearsActiveConnection(t *testing.T) {
|
||||
conn := newTestConn(t)
|
||||
c := &LongConnClient{conn: conn}
|
||||
|
||||
c.closeConn()
|
||||
|
||||
if c.conn != nil {
|
||||
t.Error("closeConn left c.conn set")
|
||||
}
|
||||
if connUsable(t, conn) {
|
||||
t.Error("closeConn did not close the connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopClosesActiveConnection(t *testing.T) {
|
||||
conn := newTestConn(t)
|
||||
c := &LongConnClient{conn: conn}
|
||||
|
||||
c.Stop()
|
||||
|
||||
if !c.closed.Load() {
|
||||
t.Error("Stop did not mark the client closed")
|
||||
}
|
||||
if c.conn != nil {
|
||||
t.Error("Stop left c.conn set")
|
||||
}
|
||||
if connUsable(t, conn) {
|
||||
t.Error("Stop did not close the connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseConnIfClosesActiveConnection(t *testing.T) {
|
||||
conn := newTestConn(t)
|
||||
c := &LongConnClient{conn: conn}
|
||||
|
||||
c.closeConnIf(conn)
|
||||
|
||||
if c.conn != nil {
|
||||
t.Error("closeConnIf left c.conn set")
|
||||
}
|
||||
if connUsable(t, conn) {
|
||||
t.Error("closeConnIf did not close the active connection")
|
||||
}
|
||||
}
|
||||
|
||||
// A heartbeat loop or detached callback handler from a previous connection
|
||||
// generation must not tear down the connection opened by a later reconnect.
|
||||
func TestCloseConnIfIgnoresSupersededConnection(t *testing.T) {
|
||||
stale := newTestConn(t)
|
||||
active := newTestConn(t)
|
||||
c := &LongConnClient{conn: active}
|
||||
|
||||
c.closeConnIf(stale)
|
||||
|
||||
if c.conn != active {
|
||||
t.Fatal("closeConnIf cleared the active connection on behalf of a stale caller")
|
||||
}
|
||||
if !connUsable(t, active) {
|
||||
t.Fatal("closeConnIf closed the active connection on behalf of a stale caller")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseConnIfIgnoresNilConnection(t *testing.T) {
|
||||
conn := newTestConn(t)
|
||||
c := &LongConnClient{conn: conn}
|
||||
|
||||
c.closeConnIf(nil)
|
||||
|
||||
if c.conn != conn {
|
||||
t.Error("closeConnIf(nil) cleared the active connection")
|
||||
}
|
||||
if !connUsable(t, conn) {
|
||||
t.Error("closeConnIf(nil) closed the active connection")
|
||||
}
|
||||
}
|
||||
|
||||
// Exercises the close and write paths together so the race detector can catch
|
||||
// any regression that reintroduces unsynchronized access to c.conn.
|
||||
func TestCloseConnRacesWithWriteJSON(t *testing.T) {
|
||||
for i := 0; i < 20; i++ {
|
||||
conn := newTestConn(t)
|
||||
c := &LongConnClient{conn: conn}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = c.writeJSON(wsFrame{Cmd: cmdPing})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.closeConnIf(conn)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.closeConn()
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
if c.conn != nil {
|
||||
t.Fatal("connection pointer still set after concurrent closes")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStripAtMentionBasic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{"no mention", "hello world", "hello world"},
|
||||
{"empty string", "", ""},
|
||||
{"mention only no content", "@Bot", "@Bot"},
|
||||
{"single-word bot double-space", "@Bot /stop", "/stop"},
|
||||
{"single-word bot single-space", "@Bot /stop", "/stop"},
|
||||
{"single-word bot double-space chinese", "@Bot 你好世界", "你好世界"},
|
||||
{"multi-word bot double-space", "@WeKnora Bot /stop", "/stop"},
|
||||
{"multi-word bot double-space chinese", "@WeKnora Bot 什么是上下文工程", "什么是上下文工程"},
|
||||
{"multi-word bot single-space command", "@WeKnora Bot /stop", "/stop"},
|
||||
{"multi-word bot single-space chinese", "@WeKnora Bot 什么是老登", "什么是老登"},
|
||||
{"leading whitespace", " @Bot hello ", "hello"},
|
||||
{"double-space in user content", "@Bot hello world", "hello world"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := stripAtMentionBasic(tt.content)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripAtMentionBasic(%q) = %q, want %q", tt.content, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongConnClient_StripAtMention(t *testing.T) {
|
||||
t.Run("learns bot name from double-space then handles single-space", func(t *testing.T) {
|
||||
c := &LongConnClient{}
|
||||
|
||||
// First message: double-space → learn "WeKnora Bot"
|
||||
got := c.stripAtMention("@WeKnora Bot 什么是上下文工程")
|
||||
if got != "什么是上下文工程" {
|
||||
t.Errorf("first message: got %q, want %q", got, "什么是上下文工程")
|
||||
}
|
||||
|
||||
// Verify bot name was cached
|
||||
if name, _ := c.botDisplayName.Load().(string); name != "WeKnora Bot" {
|
||||
t.Errorf("cached bot name = %q, want %q", name, "WeKnora Bot")
|
||||
}
|
||||
|
||||
// Second message: single-space → should use cached name
|
||||
got = c.stripAtMention("@WeKnora Bot /stop")
|
||||
if got != "/stop" {
|
||||
t.Errorf("second message: got %q, want %q", got, "/stop")
|
||||
}
|
||||
|
||||
// Single-space with chinese content
|
||||
got = c.stripAtMention("@WeKnora Bot 什么是B+树")
|
||||
if got != "什么是B+树" {
|
||||
t.Errorf("chinese single-space: got %q, want %q", got, "什么是B+树")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preconfigured bot name", func(t *testing.T) {
|
||||
c := &LongConnClient{}
|
||||
c.botDisplayName.Store("WeKnora Bot")
|
||||
|
||||
// Single-space should work immediately without learning
|
||||
got := c.stripAtMention("@WeKnora Bot /stop")
|
||||
if got != "/stop" {
|
||||
t.Errorf("got %q, want %q", got, "/stop")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cached name must not partial-match a longer bot name", func(t *testing.T) {
|
||||
c := &LongConnClient{}
|
||||
c.botDisplayName.Store("Bot")
|
||||
|
||||
// "@BotX /stop" should NOT match cached "Bot" — "BotX" is a different name.
|
||||
// Falls through to Strategy 3 (strip first @word).
|
||||
got := c.stripAtMention("@BotX /stop")
|
||||
if got != "/stop" {
|
||||
t.Errorf("got %q, want %q", got, "/stop")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NewLongConnClient with bot name", func(t *testing.T) {
|
||||
c, err := NewLongConnClient("id", "secret", "", "My Bot", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := c.stripAtMention("@My Bot /help")
|
||||
if got != "/help" {
|
||||
t.Errorf("got %q, want %q", got, "/help")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not overwrite cached name from user double-spaces", func(t *testing.T) {
|
||||
c := &LongConnClient{}
|
||||
c.botDisplayName.Store("Bot")
|
||||
|
||||
// User content has double space — should NOT overwrite "Bot" with "Bot hello"
|
||||
got := c.stripAtMention("@Bot hello world")
|
||||
if got != "hello world" {
|
||||
t.Errorf("got %q, want %q", got, "hello world")
|
||||
}
|
||||
|
||||
// Cached name should still be "Bot"
|
||||
if name, _ := c.botDisplayName.Load().(string); name != "Bot" {
|
||||
t.Errorf("cached bot name = %q, want %q", name, "Bot")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no mention passthrough", func(t *testing.T) {
|
||||
c := &LongConnClient{}
|
||||
got := c.stripAtMention("hello world")
|
||||
if got != "hello world" {
|
||||
t.Errorf("got %q, want %q", got, "hello world")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
c := &LongConnClient{}
|
||||
got := c.stripAtMention("")
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want %q", got, "")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cold start single-space command", func(t *testing.T) {
|
||||
c := &LongConnClient{}
|
||||
// No cached name, single space → heuristic detects " /" boundary
|
||||
got := c.stripAtMention("@WeKnora Bot /stop")
|
||||
if got != "/stop" {
|
||||
t.Errorf("got %q, want %q", got, "/stop")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cold start single-space chinese", func(t *testing.T) {
|
||||
c := &LongConnClient{}
|
||||
// No cached name, single space → heuristic detects non-ASCII boundary
|
||||
got := c.stripAtMention("@WeKnora Bot 什么是老登")
|
||||
if got != "什么是老登" {
|
||||
t.Errorf("got %q, want %q", got, "什么是老登")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cold start all-ascii content falls back to first word", func(t *testing.T) {
|
||||
c := &LongConnClient{}
|
||||
// No cached name, all ASCII → heuristic can't find boundary, strips first @word
|
||||
got := c.stripAtMention("@WeKnora Bot hello")
|
||||
if got != "Bot hello" {
|
||||
t.Errorf("got %q, want %q", got, "Bot hello")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
)
|
||||
|
||||
// extractQuoteContent extracts text content from a quoted botMessage.
|
||||
// Only returns actual textual content. Non-text types (image, file, video, etc.)
|
||||
// return empty string so that buildQuotedMessage discards them — injecting
|
||||
// "cannot see image" placeholders causes LLM hallucination from conversation history.
|
||||
func extractQuoteContent(quote *botMessage) string {
|
||||
if quote == nil {
|
||||
return ""
|
||||
}
|
||||
switch quote.MsgType {
|
||||
case "text":
|
||||
return quote.Text.Content
|
||||
case "voice":
|
||||
return quote.Voice.Content // STT result; empty if recognition failed
|
||||
case "mixed":
|
||||
// Only keep text parts; skip images/other non-text items entirely.
|
||||
var parts []string
|
||||
for _, item := range quote.Mixed.MsgItem {
|
||||
if item.MsgType == "text" && item.Text.Content != "" {
|
||||
parts = append(parts, item.Text.Content)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
default:
|
||||
// image, file, video, unknown — no textual content available, discard.
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// isQuoteFromBot determines whether a quoted message was sent by the bot.
|
||||
// Uses two comparison strategies since WeCom's field mapping is undocumented.
|
||||
func isQuoteFromBot(quote *botMessage, aiBotID string) bool {
|
||||
if quote == nil {
|
||||
return false
|
||||
}
|
||||
if quote.From.UserID != "" && aiBotID != "" && quote.From.UserID == aiBotID {
|
||||
return true
|
||||
}
|
||||
if quote.AiBotID != "" && quote.AiBotID == aiBotID {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildQuotedMessage constructs a QuotedMessage from a botMessage quote.
|
||||
// Returns nil only if quote itself is nil.
|
||||
// For non-text quotes (image, file, video), Content is empty and NonTextType
|
||||
// is set so downstream can generate an LLM instruction instead of a placeholder.
|
||||
func buildQuotedMessage(quote *botMessage, aiBotID string) *im.QuotedMessage {
|
||||
if quote == nil {
|
||||
return nil
|
||||
}
|
||||
content := extractQuoteContent(quote)
|
||||
result := &im.QuotedMessage{
|
||||
MessageID: quote.MsgID,
|
||||
Content: content,
|
||||
SenderID: quote.From.UserID,
|
||||
IsBotMessage: isQuoteFromBot(quote, aiBotID),
|
||||
}
|
||||
if content == "" {
|
||||
result.NonTextType = quote.MsgType
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractQuoteContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg *botMessage
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil quote",
|
||||
msg: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "text message",
|
||||
msg: &botMessage{MsgType: "text", Text: struct {
|
||||
Content string `json:"content"`
|
||||
}{Content: "hello world"}},
|
||||
want: "hello world",
|
||||
},
|
||||
{
|
||||
name: "voice with STT content",
|
||||
msg: &botMessage{MsgType: "voice", Voice: struct {
|
||||
Content string `json:"content"`
|
||||
}{Content: "speech text"}},
|
||||
want: "speech text",
|
||||
},
|
||||
{
|
||||
name: "voice without STT content",
|
||||
msg: &botMessage{MsgType: "voice"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "image returns empty (discarded to prevent hallucination)",
|
||||
msg: &botMessage{MsgType: "image"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "file returns empty",
|
||||
msg: &botMessage{MsgType: "file"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "video returns empty",
|
||||
msg: &botMessage{MsgType: "video"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "unknown type returns empty",
|
||||
msg: &botMessage{MsgType: "location"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "mixed text and image keeps only text",
|
||||
msg: &botMessage{
|
||||
MsgType: "mixed",
|
||||
Mixed: struct {
|
||||
MsgItem []botMixedItem `json:"msg_item"`
|
||||
}{
|
||||
MsgItem: []botMixedItem{
|
||||
{MsgType: "text", Text: struct {
|
||||
Content string `json:"content"`
|
||||
}{Content: "part1"}},
|
||||
{MsgType: "image"},
|
||||
{MsgType: "text", Text: struct {
|
||||
Content string `json:"content"`
|
||||
}{Content: "part2"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: "part1\npart2",
|
||||
},
|
||||
{
|
||||
name: "mixed with only images returns empty",
|
||||
msg: &botMessage{
|
||||
MsgType: "mixed",
|
||||
Mixed: struct {
|
||||
MsgItem []botMixedItem `json:"msg_item"`
|
||||
}{
|
||||
MsgItem: []botMixedItem{
|
||||
{MsgType: "image"},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractQuoteContent(tt.msg)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractQuoteContent() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQuoteFromBot(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
quote *botMessage
|
||||
aiBotID string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil quote",
|
||||
quote: nil,
|
||||
aiBotID: "bot123",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "matching from.userid",
|
||||
quote: &botMessage{From: struct {
|
||||
UserID string `json:"userid"`
|
||||
}{UserID: "bot123"}, AiBotID: ""},
|
||||
aiBotID: "bot123",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "matching aibotid",
|
||||
quote: &botMessage{From: struct {
|
||||
UserID string `json:"userid"`
|
||||
}{UserID: "other"}, AiBotID: "bot123"},
|
||||
aiBotID: "bot123",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
quote: &botMessage{From: struct {
|
||||
UserID string `json:"userid"`
|
||||
}{UserID: "user456"}, AiBotID: ""},
|
||||
aiBotID: "bot123",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty aiBotID",
|
||||
quote: &botMessage{From: struct {
|
||||
UserID string `json:"userid"`
|
||||
}{UserID: "bot123"}},
|
||||
aiBotID: "",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isQuoteFromBot(tt.quote, tt.aiBotID)
|
||||
if got != tt.want {
|
||||
t.Errorf("isQuoteFromBot() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuotedMessage(t *testing.T) {
|
||||
t.Run("nil quote returns nil", func(t *testing.T) {
|
||||
if got := buildQuotedMessage(nil, "bot1"); got != nil {
|
||||
t.Errorf("expected nil, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("text quote builds correctly", func(t *testing.T) {
|
||||
msg := &botMessage{
|
||||
MsgID: "msg-001",
|
||||
MsgType: "text",
|
||||
AiBotID: "bot1",
|
||||
From: struct {
|
||||
UserID string `json:"userid"`
|
||||
}{UserID: "bot1"},
|
||||
Text: struct {
|
||||
Content string `json:"content"`
|
||||
}{Content: "hello"},
|
||||
}
|
||||
got := buildQuotedMessage(msg, "bot1")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if got.MessageID != "msg-001" {
|
||||
t.Errorf("MessageID = %q, want %q", got.MessageID, "msg-001")
|
||||
}
|
||||
if got.Content != "hello" {
|
||||
t.Errorf("Content = %q, want %q", got.Content, "hello")
|
||||
}
|
||||
if got.SenderID != "bot1" {
|
||||
t.Errorf("SenderID = %q, want %q", got.SenderID, "bot1")
|
||||
}
|
||||
if !got.IsBotMessage {
|
||||
t.Error("IsBotMessage = false, want true")
|
||||
}
|
||||
if got.NonTextType != "" {
|
||||
t.Errorf("NonTextType = %q, want empty for text quote", got.NonTextType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("image quote sets NonTextType", func(t *testing.T) {
|
||||
msg := &botMessage{
|
||||
MsgID: "msg-img",
|
||||
MsgType: "image",
|
||||
From: struct {
|
||||
UserID string `json:"userid"`
|
||||
}{UserID: "user456"},
|
||||
}
|
||||
got := buildQuotedMessage(msg, "bot1")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil for image quote")
|
||||
}
|
||||
if got.Content != "" {
|
||||
t.Errorf("Content = %q, want empty", got.Content)
|
||||
}
|
||||
if got.NonTextType != "image" {
|
||||
t.Errorf("NonTextType = %q, want %q", got.NonTextType, "image")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("video quote sets NonTextType", func(t *testing.T) {
|
||||
msg := &botMessage{
|
||||
MsgID: "msg-vid",
|
||||
MsgType: "video",
|
||||
From: struct {
|
||||
UserID string `json:"userid"`
|
||||
}{UserID: "user456"},
|
||||
}
|
||||
got := buildQuotedMessage(msg, "bot1")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil for video quote")
|
||||
}
|
||||
if got.NonTextType != "video" {
|
||||
t.Errorf("NonTextType = %q, want %q", got.NonTextType, "video")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user quote is not bot", func(t *testing.T) {
|
||||
msg := &botMessage{
|
||||
MsgID: "msg-002",
|
||||
MsgType: "text",
|
||||
From: struct {
|
||||
UserID string `json:"userid"`
|
||||
}{UserID: "user456"},
|
||||
Text: struct {
|
||||
Content string `json:"content"`
|
||||
}{Content: "question"},
|
||||
}
|
||||
got := buildQuotedMessage(msg, "bot1")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if got.IsBotMessage {
|
||||
t.Error("IsBotMessage = true, want false")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestReconnectDelayOverflow guards against the bug where large attempt
|
||||
// counters produced a negative time.Duration, bypassing the max-delay
|
||||
// cap and causing a busy reconnect loop (e.g. "reconnecting in -1s").
|
||||
func TestReconnectDelayOverflow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attempt int
|
||||
want time.Duration
|
||||
}{
|
||||
{"zero attempt clamps to base", 0, defaultReconnectBaseDelay},
|
||||
{"negative attempt clamps to base", -5, defaultReconnectBaseDelay},
|
||||
{"attempt 1 = base", 1, defaultReconnectBaseDelay},
|
||||
{"attempt 2 = 2× base", 2, 2 * defaultReconnectBaseDelay},
|
||||
{"attempt 5 = 16s", 5, 16 * time.Second},
|
||||
{"attempt 6 caps at max", 6, defaultReconnectMaxDelay},
|
||||
{"attempt 100 caps at max", 100, defaultReconnectMaxDelay},
|
||||
{"attempt 4601 caps at max (regression)", 4601, defaultReconnectMaxDelay},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := reconnectDelay(tt.attempt)
|
||||
if got != tt.want {
|
||||
t.Errorf("reconnectDelay(%d) = %v, want %v", tt.attempt, got, tt.want)
|
||||
}
|
||||
if got <= 0 {
|
||||
t.Errorf("reconnectDelay(%d) returned non-positive %v — would cause busy loop", tt.attempt, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
// Package wecom implements the WeCom (企业微信) IM adapter for WeKnora.
|
||||
//
|
||||
// WeCom Smart Bot flow:
|
||||
// 1. User sends a message to the bot (direct or @mention in group)
|
||||
// 2. WeCom calls our callback URL with the encrypted message
|
||||
// 3. We decrypt, parse, and return an immediate response (or stream response)
|
||||
// 4. For streaming: respond with msgtype="stream", WeCom pulls subsequent chunks via refresh callbacks
|
||||
//
|
||||
// Reference: https://developer.work.weixin.qq.com/document/path/101031
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var httpClient = secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
|
||||
Timeout: 30 * time.Second,
|
||||
MaxRedirects: 5,
|
||||
})
|
||||
|
||||
const (
|
||||
defaultAPIBaseURL = "https://qyapi.weixin.qq.com"
|
||||
wecomPKCS7BlockSize = 32
|
||||
)
|
||||
|
||||
// extraHostFromEndpoint returns the lowercased hostname from endpoint if it
|
||||
// differs from defaultEndpoint; otherwise returns "". Used to extend the SSRF
|
||||
// allowlist for private deployments.
|
||||
func extraHostFromEndpoint(endpoint, defaultEndpoint string) string {
|
||||
if endpoint == "" || endpoint == defaultEndpoint {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(endpoint); err == nil {
|
||||
return strings.ToLower(u.Hostname())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// validateEndpointURL checks that a custom endpoint URL uses a secure scheme
|
||||
// and does not point to a private/internal address, preventing accidental
|
||||
// credential leakage (e.g. access tokens sent to a rogue server).
|
||||
func validateEndpointURL(endpoint, defaultEndpoint, requiredScheme string) error {
|
||||
if endpoint == "" || endpoint == defaultEndpoint {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid endpoint URL: %w", err)
|
||||
}
|
||||
if u.Scheme != requiredScheme {
|
||||
return fmt.Errorf("endpoint must use %s:// scheme, got %s://", requiredScheme, u.Scheme)
|
||||
}
|
||||
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
|
||||
return fmt.Errorf("%w (for private deployments on internal networks, add the hostname to SSRF_WHITELIST)", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WebhookAdapter implements im.Adapter for WeCom in webhook (self-built app callback) mode.
|
||||
// Messages arrive via HTTP callback; replies are sent via the WeCom REST API.
|
||||
type WebhookAdapter struct {
|
||||
corpID string
|
||||
token string
|
||||
encodingAESKey string
|
||||
aesKey []byte
|
||||
agentSecret string
|
||||
corpAgentID int
|
||||
apiBaseURL string // WeCom API base URL (e.g. "https://qyapi.weixin.qq.com")
|
||||
extraAllowedHost string // hostname from apiBaseURL for SSRF allowlist (empty if default)
|
||||
|
||||
// Token cache
|
||||
tokenMu sync.Mutex
|
||||
tokenCache string
|
||||
tokenExpAt time.Time
|
||||
}
|
||||
|
||||
// Compile-time check that WebhookAdapter implements im.FileDownloader.
|
||||
var _ im.FileDownloader = (*WebhookAdapter)(nil)
|
||||
|
||||
// NewWebhookAdapter creates a new WeCom webhook adapter.
|
||||
// apiBaseURL overrides the default WeCom API base URL; empty uses the public cloud endpoint.
|
||||
func NewWebhookAdapter(corpID, agentSecret, token, encodingAESKey string, corpAgentID int, apiBaseURL string) (*WebhookAdapter, error) {
|
||||
// Decode the AES key from base64
|
||||
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode encoding_aes_key: %w", err)
|
||||
}
|
||||
|
||||
if apiBaseURL == "" {
|
||||
apiBaseURL = defaultAPIBaseURL
|
||||
}
|
||||
apiBaseURL = strings.TrimRight(apiBaseURL, "/")
|
||||
|
||||
if err := validateEndpointURL(apiBaseURL, defaultAPIBaseURL, "https"); err != nil {
|
||||
return nil, fmt.Errorf("invalid api_base_url: %w", err)
|
||||
}
|
||||
|
||||
return &WebhookAdapter{
|
||||
corpID: corpID,
|
||||
token: token,
|
||||
encodingAESKey: encodingAESKey,
|
||||
aesKey: aesKey,
|
||||
agentSecret: agentSecret,
|
||||
corpAgentID: corpAgentID,
|
||||
apiBaseURL: apiBaseURL,
|
||||
extraAllowedHost: extraHostFromEndpoint(apiBaseURL, defaultAPIBaseURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Platform returns the platform identifier.
|
||||
func (a *WebhookAdapter) Platform() im.Platform {
|
||||
return im.PlatformWeCom
|
||||
}
|
||||
|
||||
// VerifyCallback verifies the WeCom callback signature.
|
||||
func (a *WebhookAdapter) VerifyCallback(c *gin.Context) error {
|
||||
timestamp := c.Query("timestamp")
|
||||
nonce := c.Query("nonce")
|
||||
msgSignature := c.Query("msg_signature")
|
||||
|
||||
// For GET requests (URL verification), use echostr
|
||||
// For POST requests (message callback), use request body's Encrypt field
|
||||
var encrypt string
|
||||
if c.Request.Method == http.MethodGet {
|
||||
encrypt = c.Query("echostr")
|
||||
} else {
|
||||
var body callbackRequestBody
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read request body: %w", err)
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
if err := xml.Unmarshal(bodyBytes, &body); err != nil {
|
||||
return fmt.Errorf("unmarshal xml body: %w", err)
|
||||
}
|
||||
encrypt = body.Encrypt
|
||||
}
|
||||
|
||||
if !a.verifySignature(msgSignature, timestamp, nonce, encrypt) {
|
||||
return fmt.Errorf("invalid signature")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleURLVerification handles the WeCom URL verification (GET request).
|
||||
func (a *WebhookAdapter) HandleURLVerification(c *gin.Context) bool {
|
||||
if c.Request.Method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
|
||||
echoStr := c.Query("echostr")
|
||||
if echoStr == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Decrypt the echostr and return it
|
||||
decrypted, err := a.decrypt(echoStr)
|
||||
if err != nil {
|
||||
logger.Errorf(c.Request.Context(), "[WeCom] Failed to decrypt echostr: %v", err)
|
||||
c.String(http.StatusBadRequest, "decrypt failed")
|
||||
return true
|
||||
}
|
||||
|
||||
c.String(http.StatusOK, string(decrypted))
|
||||
return true
|
||||
}
|
||||
|
||||
// ParseCallback parses a WeCom callback into a unified IncomingMessage.
|
||||
func (a *WebhookAdapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
var body callbackRequestBody
|
||||
if err := xml.Unmarshal(bodyBytes, &body); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal xml: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt the message
|
||||
decrypted, err := a.decrypt(body.Encrypt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt message: %w", err)
|
||||
}
|
||||
|
||||
// Log raw decrypted message for debugging
|
||||
logger.Debugf(c.Request.Context(), "[WeCom] Raw decrypted callback: %s", string(decrypted))
|
||||
|
||||
var msg wecomMessage
|
||||
if err := xml.Unmarshal(decrypted, &msg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal decrypted message: %w", err)
|
||||
}
|
||||
|
||||
logger.Debugf(c.Request.Context(), "[WeCom] Parsed webhook message: msgid=%s msgtype=%s from=%s content=%q picurl=%q mediaid=%q",
|
||||
msg.MsgID, msg.MsgType, msg.FromUserName, msg.Content, msg.PicUrl, msg.MediaId)
|
||||
|
||||
// Determine chat type
|
||||
chatType := im.ChatTypeDirect
|
||||
chatID := ""
|
||||
isGroup := msg.ChatID != ""
|
||||
if isGroup {
|
||||
chatType = im.ChatTypeGroup
|
||||
chatID = msg.ChatID
|
||||
}
|
||||
|
||||
switch msg.MsgType {
|
||||
case "text":
|
||||
// Strip @mention in group chat (same issue as long connection mode).
|
||||
// Webhook adapter has no persistent state, so use the standalone helper.
|
||||
textContent := msg.Content
|
||||
if isGroup {
|
||||
textContent = stripAtMentionBasic(textContent)
|
||||
}
|
||||
return &im.IncomingMessage{
|
||||
Platform: im.PlatformWeCom,
|
||||
MessageType: im.MessageTypeText,
|
||||
UserID: msg.FromUserName,
|
||||
UserName: msg.FromUserName,
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
Content: strings.TrimSpace(textContent),
|
||||
MessageID: msg.MsgID,
|
||||
}, nil
|
||||
|
||||
case "image":
|
||||
// Image via webhook: has PicUrl (direct download) and MediaId
|
||||
if msg.PicUrl == "" && msg.MediaId == "" {
|
||||
return nil, nil
|
||||
}
|
||||
fileKey := msg.PicUrl
|
||||
if fileKey == "" {
|
||||
fileKey = msg.MediaId
|
||||
}
|
||||
return &im.IncomingMessage{
|
||||
Platform: im.PlatformWeCom,
|
||||
MessageType: im.MessageTypeImage,
|
||||
UserID: msg.FromUserName,
|
||||
UserName: msg.FromUserName,
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
MessageID: msg.MsgID,
|
||||
FileKey: fileKey,
|
||||
FileName: msg.MsgID + ".png",
|
||||
}, nil
|
||||
|
||||
default:
|
||||
logger.Infof(c.Request.Context(), "[WeCom] Ignoring unsupported message type: %s", msg.MsgType)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SendReply sends a reply message via WeCom API.
|
||||
// For group chats, it tries the appchat API first to reply in the group,
|
||||
// then falls back to sending a direct message to the user.
|
||||
func (a *WebhookAdapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
|
||||
accessToken, err := a.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
// For group chats, try sending to the group via appchat API first.
|
||||
// This works for groups created via /cgi-bin/appchat/create.
|
||||
if incoming.ChatType == im.ChatTypeGroup && incoming.ChatID != "" {
|
||||
if err := a.sendToAppChat(ctx, accessToken, incoming.ChatID, reply); err == nil {
|
||||
return nil
|
||||
}
|
||||
logger.Debugf(ctx, "[WeCom] appchat/send failed for chat=%s, falling back to touser: %v", incoming.ChatID, err)
|
||||
}
|
||||
|
||||
// Fallback (or direct message): send to the user directly.
|
||||
return a.sendToUser(ctx, accessToken, incoming.UserID, reply)
|
||||
}
|
||||
|
||||
// sendToAppChat sends a message to a WeCom group chat via the appchat API.
|
||||
// Reference: https://developer.work.weixin.qq.com/document/path/90248
|
||||
func (a *WebhookAdapter) sendToAppChat(ctx context.Context, accessToken, chatID string, reply *im.ReplyMessage) error {
|
||||
payload := map[string]interface{}{
|
||||
"chatid": chatID,
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]string{
|
||||
"content": reply.Content,
|
||||
},
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
sendURL := fmt.Sprintf("%s/cgi-bin/appchat/send?access_token=%s", a.apiBaseURL, accessToken)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send appchat message: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return fmt.Errorf("appchat api error: code=%d msg=%s", result.ErrCode, result.ErrMsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendToUser sends a message directly to a user via the application message API.
|
||||
// Reference: https://developer.work.weixin.qq.com/document/path/90236
|
||||
func (a *WebhookAdapter) sendToUser(ctx context.Context, accessToken, userID string, reply *im.ReplyMessage) error {
|
||||
payload := map[string]interface{}{
|
||||
"touser": userID,
|
||||
"msgtype": "markdown",
|
||||
"agentid": a.corpAgentID,
|
||||
"markdown": map[string]string{
|
||||
"content": reply.Content,
|
||||
},
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
sendURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", a.apiBaseURL, accessToken)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send message: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return fmt.Errorf("wecom api error: code=%d msg=%s", result.ErrCode, result.ErrMsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAccessToken retrieves the WeCom access token with caching.
|
||||
// WeCom tokens expire in 7200 seconds (2 hours); we cache with a safety margin.
|
||||
func (a *WebhookAdapter) getAccessToken(ctx context.Context) (string, error) {
|
||||
a.tokenMu.Lock()
|
||||
defer a.tokenMu.Unlock()
|
||||
|
||||
if a.tokenCache != "" && time.Now().Before(a.tokenExpAt) {
|
||||
return a.tokenCache, nil
|
||||
}
|
||||
|
||||
tokenURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
|
||||
a.apiBaseURL, a.corpID, a.agentSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request access token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"` // seconds
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("decode token response: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return "", fmt.Errorf("get token error: code=%d msg=%s", result.ErrCode, result.ErrMsg)
|
||||
}
|
||||
|
||||
a.tokenCache = result.AccessToken
|
||||
// Cache with 5-minute safety margin
|
||||
ttl := time.Duration(result.ExpiresIn) * time.Second
|
||||
if ttl > 5*time.Minute {
|
||||
ttl -= 5 * time.Minute
|
||||
}
|
||||
a.tokenExpAt = time.Now().Add(ttl)
|
||||
|
||||
return a.tokenCache, nil
|
||||
}
|
||||
|
||||
// verifySignature verifies the WeCom callback signature using constant-time comparison.
|
||||
func (a *WebhookAdapter) verifySignature(signature, timestamp, nonce, encrypt string) bool {
|
||||
parts := []string{a.token, timestamp, nonce, encrypt}
|
||||
sort.Strings(parts)
|
||||
combined := strings.Join(parts, "")
|
||||
|
||||
hash := sha1.New()
|
||||
hash.Write([]byte(combined))
|
||||
computed := fmt.Sprintf("%x", hash.Sum(nil))
|
||||
|
||||
return hmac.Equal([]byte(computed), []byte(signature))
|
||||
}
|
||||
|
||||
// decrypt decrypts a WeCom AES-encrypted message.
|
||||
func (a *WebhookAdapter) decrypt(encrypted string) ([]byte, error) {
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(a.aesKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new cipher: %w", err)
|
||||
}
|
||||
|
||||
if len(ciphertext) < aes.BlockSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext length is not a multiple of AES block size")
|
||||
}
|
||||
|
||||
iv := a.aesKey[:aes.BlockSize]
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
mode.CryptBlocks(ciphertext, ciphertext)
|
||||
|
||||
// Remove and verify PKCS#7 padding
|
||||
padLen := int(ciphertext[len(ciphertext)-1])
|
||||
if padLen > wecomPKCS7BlockSize || padLen == 0 || padLen > len(ciphertext) {
|
||||
return nil, fmt.Errorf("invalid padding")
|
||||
}
|
||||
for i := 0; i < padLen; i++ {
|
||||
if ciphertext[len(ciphertext)-1-i] != byte(padLen) {
|
||||
return nil, fmt.Errorf("invalid padding")
|
||||
}
|
||||
}
|
||||
plaintext := ciphertext[:len(ciphertext)-padLen]
|
||||
|
||||
// WeCom format: random(16) + msg_len(4) + msg + corp_id
|
||||
if len(plaintext) < 20 {
|
||||
return nil, fmt.Errorf("plaintext too short")
|
||||
}
|
||||
|
||||
msgLen := binary.BigEndian.Uint32(plaintext[16:20])
|
||||
if uint32(len(plaintext)) < 20+msgLen {
|
||||
return nil, fmt.Errorf("message length mismatch")
|
||||
}
|
||||
|
||||
msgBytes := plaintext[20 : 20+msgLen]
|
||||
|
||||
// Verify corp_id from plaintext tail
|
||||
corpIDBytes := plaintext[20+msgLen:]
|
||||
if string(corpIDBytes) != a.corpID {
|
||||
return nil, fmt.Errorf("corp_id mismatch: expected %s, got %s", a.corpID, string(corpIDBytes))
|
||||
}
|
||||
|
||||
return msgBytes, nil
|
||||
}
|
||||
|
||||
// callbackRequestBody is the XML structure of a WeCom callback request body.
|
||||
type callbackRequestBody struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
AgentID string `xml:"AgentID"`
|
||||
}
|
||||
|
||||
// wecomMessage is the decrypted WeCom message structure.
|
||||
// Supports text, image, voice, video, location, and link message types.
|
||||
// Reference: https://developer.work.weixin.qq.com/document/path/90375
|
||||
type wecomMessage struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
FromUserName string `xml:"FromUserName"`
|
||||
CreateTime int64 `xml:"CreateTime"`
|
||||
MsgType string `xml:"MsgType"`
|
||||
Content string `xml:"Content"` // text
|
||||
PicUrl string `xml:"PicUrl"` // image: download URL
|
||||
MediaId string `xml:"MediaId"` // image/voice/video: media ID for download
|
||||
Format string `xml:"Format"` // voice: audio format (amr/speex)
|
||||
ThumbMediaId string `xml:"ThumbMediaId"` // video: thumbnail media ID
|
||||
MsgID string `xml:"MsgId"`
|
||||
AgentID string `xml:"AgentID"`
|
||||
ChatID string `xml:"ChatId"`
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// File download support for WeCom webhook mode
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// DownloadFile downloads a file/image from WeCom.
|
||||
// For webhook mode, images come with MediaId (temporary media) which can be
|
||||
// downloaded via the GetMedia API, or PicUrl for direct download.
|
||||
func (a *WebhookAdapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
|
||||
if msg.FileKey == "" {
|
||||
return nil, "", fmt.Errorf("no file key (URL or media_id) in message")
|
||||
}
|
||||
|
||||
fileName := msg.FileName
|
||||
if fileName == "" {
|
||||
fileName = msg.FileKey
|
||||
}
|
||||
|
||||
// If FileKey looks like a URL, download directly
|
||||
if strings.HasPrefix(msg.FileKey, "http://") || strings.HasPrefix(msg.FileKey, "https://") {
|
||||
return downloadFromURL(ctx, msg.FileKey, fileName, a.extraAllowedHost)
|
||||
}
|
||||
|
||||
// Otherwise treat as media_id, download via temporary media API
|
||||
accessToken, err := a.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/cgi-bin/media/get?access_token=%s&media_id=%s",
|
||||
a.apiBaseURL, accessToken, msg.FileKey)
|
||||
return downloadFromURL(ctx, apiURL, fileName, a.extraAllowedHost)
|
||||
}
|
||||
|
||||
// downloadFromURL performs a GET request and returns the response body.
|
||||
// It tries to resolve the real filename from HTTP response headers:
|
||||
// 1. Content-Disposition: attachment; filename="xxx.pdf"
|
||||
// 2. Content-Type → extension mapping (fallback for platforms like WeCom that
|
||||
// don't provide the original filename in the callback JSON)
|
||||
func downloadFromURL(ctx context.Context, rawURL, fileName string, extraAllowedHost string) (io.ReadCloser, string, error) {
|
||||
// SSRF protection: reject internal/private URLs unless on the WeCom API allowlist.
|
||||
if !isAllowedIMAPIHost(rawURL, extraAllowedHost) {
|
||||
if err := secutils.ValidateURLForSSRF(rawURL); err != nil {
|
||||
return nil, "", fmt.Errorf("URL rejected for security reasons: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("download: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, "", fmt.Errorf("download failed: status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
logger.Debugf(ctx, "[WeCom] Download response: status=%d content-type=%s content-disposition=%s",
|
||||
resp.StatusCode, resp.Header.Get("Content-Type"), resp.Header.Get("Content-Disposition"))
|
||||
|
||||
// Try to extract filename from Content-Disposition header.
|
||||
// Supports both standard filename and RFC 5987 filename* parameters.
|
||||
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
|
||||
if _, params, err := mime.ParseMediaType(cd); err == nil {
|
||||
// Prefer filename* (RFC 5987, already decoded by mime.ParseMediaType)
|
||||
if fn := params["filename"]; fn != "" {
|
||||
fileName = fn
|
||||
}
|
||||
} else {
|
||||
// Fallback: manual extraction for malformed headers
|
||||
if idx := strings.Index(cd, "filename="); idx >= 0 {
|
||||
extracted := strings.Trim(cd[idx+len("filename="):], "\" ")
|
||||
if extracted != "" {
|
||||
fileName = extracted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// URL-decode the filename if it contains percent-encoded characters.
|
||||
// Some servers (e.g. WeCom COS) return URL-encoded Chinese filenames.
|
||||
if strings.Contains(fileName, "%") {
|
||||
if decoded, err := url.QueryUnescape(fileName); err == nil && decoded != "" {
|
||||
fileName = decoded
|
||||
}
|
||||
}
|
||||
|
||||
// Also try to extract a meaningful filename from the URL path itself,
|
||||
// in case Content-Disposition is missing but the URL contains the real name.
|
||||
if !strings.Contains(fileName, ".") {
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
base := path.Base(u.Path)
|
||||
if base != "" && base != "." && base != "/" && strings.Contains(base, ".") {
|
||||
// URL-decode the path component as well
|
||||
if decoded, err := url.QueryUnescape(base); err == nil {
|
||||
fileName = decoded
|
||||
} else {
|
||||
fileName = base
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If filename still has no extension, try to infer from Content-Type.
|
||||
// This handles platforms (e.g. WeCom aibot) where the callback only provides
|
||||
// a hash ID as the filename without any extension.
|
||||
if !strings.Contains(fileName, ".") {
|
||||
if ext := contentTypeToExt(resp.Header.Get("Content-Type")); ext != "" {
|
||||
fileName = fileName + "." + ext
|
||||
}
|
||||
}
|
||||
|
||||
return resp.Body, fileName, nil
|
||||
}
|
||||
|
||||
// allowedIMAPIHosts lists IM platform API hosts that are trusted for file downloads.
|
||||
// URLs pointing to these hosts bypass isSSRFSafeURL checks because the WeCom API
|
||||
// itself returns these URLs in callback payloads (e.g. temporary media links).
|
||||
var allowedIMAPIHosts = []string{
|
||||
"qyapi.weixin.qq.com",
|
||||
"api.weixin.qq.com",
|
||||
"open.work.weixin.qq.com",
|
||||
"novac2c.cdn.weixin.qq.com",
|
||||
"ilinkai.weixin.qq.com",
|
||||
}
|
||||
|
||||
// isAllowedIMAPIHost returns true if rawURL points to a known IM platform API host.
|
||||
// extraHost is an optional additional trusted hostname (e.g. from a private deployment).
|
||||
func isAllowedIMAPIHost(rawURL string, extraHost string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hostname := strings.ToLower(u.Hostname())
|
||||
if extraHost != "" && hostname == extraHost {
|
||||
return true
|
||||
}
|
||||
for _, allowed := range allowedIMAPIHosts {
|
||||
if hostname == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// contentTypeToExt maps common Content-Type values to file extensions.
|
||||
func contentTypeToExt(ct string) string {
|
||||
// Normalize: take only the media type, ignore parameters like charset
|
||||
if idx := strings.Index(ct, ";"); idx >= 0 {
|
||||
ct = strings.TrimSpace(ct[:idx])
|
||||
}
|
||||
ct = strings.ToLower(ct)
|
||||
|
||||
mapping := map[string]string{
|
||||
"application/pdf": "pdf",
|
||||
"application/msword": "doc",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
||||
"application/vnd.ms-excel": "xls",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
||||
"application/vnd.ms-powerpoint": "ppt",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
||||
"text/plain": "txt",
|
||||
"text/markdown": "md",
|
||||
"text/csv": "csv",
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
|
||||
return mapping[ct]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebhookAdapterDecryptPadding(t *testing.T) {
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
corpID := "ww_corp_id"
|
||||
adapter := &WebhookAdapter{aesKey: key, corpID: corpID}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
message string
|
||||
wantPadLen int
|
||||
}{
|
||||
{
|
||||
name: "padding below AES block size",
|
||||
message: "message with seven-byte pad",
|
||||
wantPadLen: 7,
|
||||
},
|
||||
{
|
||||
name: "padding above AES block size",
|
||||
message: "message with 19",
|
||||
wantPadLen: 19,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
encrypted, padLen := encryptWeComTestPayload(t, key, corpID, []byte(tt.message))
|
||||
if padLen != tt.wantPadLen {
|
||||
t.Fatalf("test fixture padding = %d, want %d", padLen, tt.wantPadLen)
|
||||
}
|
||||
|
||||
got, err := adapter.decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt() error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, []byte(tt.message)) {
|
||||
t.Fatalf("decrypt() = %q, want %q", got, tt.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdapterDecryptRejectsNonBlockAlignedCiphertext(t *testing.T) {
|
||||
adapter := &WebhookAdapter{
|
||||
aesKey: []byte("0123456789abcdef0123456789abcdef"),
|
||||
corpID: "ww_corp_id",
|
||||
}
|
||||
encrypted := base64.StdEncoding.EncodeToString(make([]byte, aes.BlockSize+1))
|
||||
|
||||
if _, err := adapter.decrypt(encrypted); err == nil {
|
||||
t.Fatal("decrypt() succeeded for ciphertext with a non-block-aligned length")
|
||||
}
|
||||
}
|
||||
|
||||
func encryptWeComTestPayload(t *testing.T, key []byte, corpID string, message []byte) (string, int) {
|
||||
t.Helper()
|
||||
|
||||
plaintext := make([]byte, 16)
|
||||
messageLength := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(messageLength, uint32(len(message)))
|
||||
plaintext = append(plaintext, messageLength...)
|
||||
plaintext = append(plaintext, message...)
|
||||
plaintext = append(plaintext, []byte(corpID)...)
|
||||
|
||||
padLen := wecomPKCS7BlockSize - len(plaintext)%wecomPKCS7BlockSize
|
||||
plaintext = append(plaintext, bytes.Repeat([]byte{byte(padLen)}, padLen)...)
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
|
||||
ciphertext := make([]byte, len(plaintext))
|
||||
cipher.NewCBCEncrypter(block, key[:aes.BlockSize]).CryptBlocks(ciphertext, plaintext)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), padLen
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Compile-time checks.
|
||||
var (
|
||||
_ im.Adapter = (*WSAdapter)(nil)
|
||||
_ im.StreamSender = (*WSAdapter)(nil)
|
||||
_ im.FileDownloader = (*WSAdapter)(nil)
|
||||
)
|
||||
|
||||
// WSAdapter implements im.Adapter and im.StreamSender for WeCom in WebSocket
|
||||
// (long connection) mode. It delegates to the WebSocket LongConnClient.
|
||||
// The webhook methods (VerifyCallback, ParseCallback, HandleURLVerification) are no-ops
|
||||
// since messages arrive via WebSocket, not HTTP.
|
||||
type WSAdapter struct {
|
||||
client *LongConnClient
|
||||
}
|
||||
|
||||
// NewWSAdapter creates an adapter backed by a WeCom long connection client.
|
||||
func NewWSAdapter(client *LongConnClient) *WSAdapter {
|
||||
return &WSAdapter{client: client}
|
||||
}
|
||||
|
||||
func (a *WSAdapter) Platform() im.Platform {
|
||||
return im.PlatformWeCom
|
||||
}
|
||||
|
||||
func (a *WSAdapter) VerifyCallback(c *gin.Context) error {
|
||||
return fmt.Errorf("WeCom bot adapter does not support webhook callbacks")
|
||||
}
|
||||
|
||||
func (a *WSAdapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
|
||||
return nil, fmt.Errorf("WeCom bot adapter does not support webhook callbacks")
|
||||
}
|
||||
|
||||
func (a *WSAdapter) HandleURLVerification(c *gin.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *WSAdapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
|
||||
return a.client.SendReply(ctx, incoming, reply)
|
||||
}
|
||||
|
||||
// ── StreamSender implementation ──
|
||||
|
||||
func (a *WSAdapter) StartStream(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
|
||||
return a.client.StartStream(ctx, incoming)
|
||||
}
|
||||
|
||||
func (a *WSAdapter) UpdateStreamContent(ctx context.Context, incoming *im.IncomingMessage, streamID string, fullContent string) error {
|
||||
return a.client.UpdateStreamContent(ctx, incoming, streamID, fullContent)
|
||||
}
|
||||
|
||||
func (a *WSAdapter) FinalizeStream(ctx context.Context, incoming *im.IncomingMessage, streamID string, finalContent string) error {
|
||||
return a.client.FinalizeStream(ctx, incoming, streamID, finalContent)
|
||||
}
|
||||
|
||||
func (a *WSAdapter) SendStreamChunk(ctx context.Context, incoming *im.IncomingMessage, streamID string, content string) error {
|
||||
return a.client.UpdateStreamContent(ctx, incoming, streamID, content)
|
||||
}
|
||||
|
||||
func (a *WSAdapter) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
|
||||
return a.client.EndStream(ctx, incoming, streamID)
|
||||
}
|
||||
|
||||
// ── FileDownloader implementation ──
|
||||
// WeCom aibot provides AES-256-CBC encrypted URLs for image/file/video messages.
|
||||
// Each message carries its own aeskey for decryption.
|
||||
|
||||
func (a *WSAdapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
|
||||
if msg.FileKey == "" {
|
||||
return nil, "", fmt.Errorf("no file URL in message")
|
||||
}
|
||||
fileName := msg.FileName
|
||||
if fileName == "" {
|
||||
fileName = msg.FileKey
|
||||
}
|
||||
|
||||
// Download the (encrypted) file content
|
||||
reader, fileName, err := downloadFromURL(ctx, msg.FileKey, fileName, a.client.extraAllowedHost)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// If an AES key is provided, the downloaded content is AES-256-CBC encrypted
|
||||
// and must be decrypted before use. This is the case for WeCom aibot long
|
||||
// connection mode where each file/image message carries a per-message aeskey.
|
||||
aesKeyB64 := msg.Extra["aes_key"]
|
||||
if aesKeyB64 == "" {
|
||||
// No encryption — return raw content (e.g. webhook mode uses media API)
|
||||
return reader, fileName, nil
|
||||
}
|
||||
|
||||
// Read all encrypted content
|
||||
encryptedData, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read encrypted file: %w", err)
|
||||
}
|
||||
|
||||
logger.Debugf(ctx, "[WeCom] Decrypting file: name=%s encrypted_size=%d aes_key_len=%d",
|
||||
fileName, len(encryptedData), len(aesKeyB64))
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := decryptAESCBC(encryptedData, aesKeyB64)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decrypt file: %w", err)
|
||||
}
|
||||
|
||||
logger.Debugf(ctx, "[WeCom] File decrypted: name=%s decrypted_size=%d", fileName, len(decrypted))
|
||||
|
||||
return io.NopCloser(bytes.NewReader(decrypted)), fileName, nil
|
||||
}
|
||||
|
||||
// decryptAESCBC decrypts data encrypted with AES-256-CBC using PKCS#7 padding.
|
||||
// The aesKeyB64 is the base64-encoded AES key provided per-message by WeCom.
|
||||
// IV is the first 16 bytes of the decoded AES key.
|
||||
func decryptAESCBC(ciphertext []byte, aesKeyB64 string) ([]byte, error) {
|
||||
// WeCom's per-message aeskey is base64-encoded (43 chars → 32 bytes after decode)
|
||||
aesKey, err := base64.StdEncoding.DecodeString(aesKeyB64 + "=")
|
||||
if err != nil {
|
||||
// Try without padding
|
||||
aesKey, err = base64.RawStdEncoding.DecodeString(aesKeyB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode aes key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(aesKey) < 16 {
|
||||
return nil, fmt.Errorf("aes key too short: %d bytes", len(aesKey))
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(aesKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new aes cipher: %w", err)
|
||||
}
|
||||
|
||||
if len(ciphertext) < aes.BlockSize {
|
||||
return nil, fmt.Errorf("ciphertext too short: %d bytes", len(ciphertext))
|
||||
}
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext not a multiple of block size: %d bytes", len(ciphertext))
|
||||
}
|
||||
|
||||
// IV = first 16 bytes of the AES key
|
||||
iv := aesKey[:aes.BlockSize]
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
mode.CryptBlocks(plaintext, ciphertext)
|
||||
|
||||
// Remove PKCS#7 padding
|
||||
if len(plaintext) == 0 {
|
||||
return nil, fmt.Errorf("empty plaintext after decryption")
|
||||
}
|
||||
padLen := int(plaintext[len(plaintext)-1])
|
||||
if padLen > aes.BlockSize || padLen == 0 || padLen > len(plaintext) {
|
||||
// No valid PKCS#7 padding — return as-is (some implementations may not pad)
|
||||
return plaintext, nil
|
||||
}
|
||||
// Verify padding bytes
|
||||
for i := 0; i < padLen; i++ {
|
||||
if plaintext[len(plaintext)-1-i] != byte(padLen) {
|
||||
// Invalid padding — return as-is
|
||||
return plaintext, nil
|
||||
}
|
||||
}
|
||||
return plaintext[:len(plaintext)-padLen], nil
|
||||
}
|
||||
Reference in New Issue
Block a user