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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+79
View File
@@ -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)
}
}
}
+835
View File
@@ -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
}
+152
View File
@@ -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")
}
}
}
+157
View File
@@ -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")
}
})
}
+71
View File
@@ -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
}
+256
View File
@@ -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")
}
})
}
+37
View File
@@ -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)
}
})
}
}
+705
View File
@@ -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]
}
+85
View File
@@ -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
}
+181
View File
@@ -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
}