picoclaw/pkg/channels/wecom/websocket.go

1918 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package wecom
import (
"context"
"crypto/md5"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
const (
// WebSocket 默认配置
defaultWSURL = "wss://openws.work.weixin.qq.com"
defaultReconnectInterval = 5 * time.Second
defaultHeartbeatInterval = 30 * time.Second
defaultReplyTimeout = 30 * time.Second
defaultMaxReconnectAttempts = 100
// 消息处理超时
messageProcessTimeout = 5 * time.Minute
// 消息状态清理配置
messageStateCleanupInterval = 60 * time.Second
messageStateMaxSize = 500
messageStateTTL = 10 * time.Minute
// 文本分块大小
textChunkLimit = 4000
// 媒体下载配置
defaultMediaMaxMB = 5
imageDownloadTimeoutMs = 30000
fileDownloadTimeoutMs = 60000
replySendTimeoutMs = 15000
thinkingMessage = "<think></think>"
mediaImagePlaceholder = "<media:image>"
mediaDocumentPlaceholder = "<media:document>"
)
// WeComCommand 企业微信 WebSocket 命令类型
type WeComCommand string
const (
// 认证订阅
CmdSubscribe WeComCommand = "aibot_subscribe"
// 心跳
CmdPing WeComCommand = "ping"
// 企业微信推送消息
CmdAIBotCallback WeComCommand = "aibot_callback"
// 企业微信事件回调
CmdAIBotEventCallback WeComCommand = "aibot_event_callback"
// 企业微信消息回调
CmdAIBotMsgCallback WeComCommand = "aibot_msg_callback"
// picoclaw 响应消息官方命令名aibot_respond_msg
CmdAIBotResponse WeComCommand = "aibot_respond_msg"
)
// WeComWSMessage WebSocket 消息基础结构
type WeComWSMessage struct {
Cmd string `json:"cmd"`
Headers MessageHeaders `json:"headers"`
Body json.RawMessage `json:"body"`
}
// MessageHeaders 消息头
type MessageHeaders struct {
ReqID string `json:"req_id"`
}
// SubscribeRequest 订阅认证请求
type SubscribeRequest struct {
Secret string `json:"secret"`
BotID string `json:"bot_id"`
}
// SubscribeBody 订阅请求体
type SubscribeBody struct {
Secret string `json:"secret"`
BotID string `json:"bot_id"`
}
// CallbackMessage 企业微信推送消息
type CallbackMessage struct {
MsgID string `json:"msgid"`
AIBotID string `json:"aibotid"`
ChatID string `json:"chatid"`
ChatType string `json:"chattype"`
From From `json:"from"`
ResponseURL string `json:"response_url"`
MsgType string `json:"msgtype"`
Text *Text `json:"text,omitempty"`
Image *Image `json:"image,omitempty"`
Voice *Voice `json:"voice,omitempty"`
Video *Video `json:"video,omitempty"`
File *File `json:"file,omitempty"`
Mixed *Mixed `json:"mixed,omitempty"`
Quote *Quote `json:"quote,omitempty"`
Stream *Stream `json:"stream,omitempty"`
}
// Stream 流式消息
type Stream struct {
ID string `json:"id"`
}
// From 发送者信息
type From struct {
UserID string `json:"userid"`
}
// Text 文本消息
type Text struct {
Content string `json:"content"`
}
// Image 图片消息
type Image struct {
URL string `json:"url,omitempty"`
Base64 string `json:"base64,omitempty"`
MD5 string `json:"md5,omitempty"`
AESKey string `json:"aeskey,omitempty"`
}
// Voice 语音消息
type Voice struct {
Content string `json:"content,omitempty"` // 语音转文字后的内容
URL string `json:"url,omitempty"`
AESKey string `json:"aeskey,omitempty"`
}
// Video 视频消息
type Video struct {
URL string `json:"url,omitempty"`
AESKey string `json:"aeskey,omitempty"`
}
// File 文件消息
type File struct {
URL string `json:"url,omitempty"`
Filename string `json:"filename,omitempty"`
AESKey string `json:"aeskey,omitempty"`
}
// Quote 引用消息
type Quote struct {
MsgType string `json:"msgtype"`
Text *Text `json:"text,omitempty"`
Voice *Voice `json:"voice,omitempty"`
Image *Image `json:"image,omitempty"`
File *File `json:"file,omitempty"`
}
// Mixed 图文混排消息
type Mixed struct {
MsgItem []MixedItem `json:"msg_item"`
}
// MixedItem 混排消息项
type MixedItem struct {
MsgType string `json:"msgtype"`
Text *Text `json:"text,omitempty"`
Image *Image `json:"image,omitempty"`
}
// ResponseMessage 响应消息
type ResponseMessage struct {
MsgType string `json:"msgtype"`
Stream *StreamContent `json:"stream,omitempty"`
Text *Text `json:"text,omitempty"`
Markdown *Markdown `json:"markdown,omitempty"`
Image *ResponseImage `json:"image,omitempty"`
File *ResponseFile `json:"file,omitempty"`
}
// StreamContent 流式内容
type StreamContent struct {
ID string `json:"id"`
Finish bool `json:"finish"`
Content string `json:"content"`
MsgItem []StreamMsgItem `json:"msg_item,omitempty"`
Feedback *StreamFeedback `json:"feedback,omitempty"`
}
// StreamMsgItem 流式消息中的媒体项
type StreamMsgItem struct {
MsgType string `json:"msgtype"`
Image *ResponseImage `json:"image,omitempty"`
File *ResponseFile `json:"file,omitempty"`
}
// StreamFeedback 流式反馈
type StreamFeedback struct {
ID string `json:"id"`
}
// ResponseImage 响应图片
type ResponseImage struct {
Base64 string `json:"base64"`
MD5 string `json:"md5"`
}
// ResponseFile 响应文件
type ResponseFile struct {
Base64 string `json:"base64"`
Filename string `json:"filename"`
}
// Markdown Markdown 消息
type Markdown struct {
Content string `json:"content"`
}
// MessageState 消息状态(用于流式回复)
type MessageState struct {
AccumulatedText string
StreamID string
ReqID string // 透传收到的 req_id
CreatedAt time.Time
}
// ParsedMessageContent 解析后的消息内容
type ParsedMessageContent struct {
TextParts []string
ImageURLs []string
ImageAESKeys map[string]string // URL -> AES key
FileURLs []string
FileAESKeys map[string]string // URL -> AES key
QuoteContent string
MediaList []MediaInfo
}
// MediaInfo 媒体信息
type MediaInfo struct {
URL string
Type string // "image" or "file"
ContentType string
Filename string
Path string // 本地缓存路径
AESKey string
}
// WeComWSChannel 企业微信 WebSocket Channel 实现
type WeComWSChannel struct {
*channels.BaseChannel
config config.WeComWSConfig
ctx context.Context
cancel context.CancelFunc
wsConn *websocket.Conn
connMu sync.RWMutex
reconnects int
// 消息状态管理
messageStates map[string]*MessageState
statesMu sync.RWMutex
// 发送队列
sendCh chan *WeComWSMessage
// 连接状态
connected bool
// 群组管理器
groupManager *GroupManager
// req_id 存储(持久化去重)
reqIDStore *ReqIDStore
// 事件管理器
eventManager *EventManager
}
// NewWeComWSChannel 创建新的 WeCom WebSocket Channel
func NewWeComWSChannel(cfg config.WeComWSConfig, messageBus *bus.MessageBus) (*WeComWSChannel, error) {
if cfg.BotID == "" || cfg.Secret == "" {
return nil, fmt.Errorf("wecom_ws bot_id and secret are required")
}
// 设置默认值
if cfg.WSURL == "" {
cfg.WSURL = defaultWSURL
}
if cfg.ReconnectInterval <= 0 {
cfg.ReconnectInterval = int(defaultReconnectInterval.Seconds())
}
if cfg.HeartbeatInterval <= 0 {
cfg.HeartbeatInterval = int(defaultHeartbeatInterval.Seconds())
}
if cfg.ReplyTimeout <= 0 {
cfg.ReplyTimeout = int(defaultReplyTimeout.Seconds())
}
if cfg.MaxReconnectAttempts <= 0 {
cfg.MaxReconnectAttempts = defaultMaxReconnectAttempts
}
base := channels.NewBaseChannel("wecom_ws", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(textChunkLimit),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
ctx, cancel := context.WithCancel(context.Background())
ch := &WeComWSChannel{
BaseChannel: base,
config: cfg,
ctx: ctx,
cancel: cancel,
messageStates: make(map[string]*MessageState),
sendCh: make(chan *WeComWSMessage, 100),
groupManager: NewGroupManager(cfg.GroupPolicies),
reqIDStore: NewReqIDStore(cfg.PersistencePath),
eventManager: NewEventManager(),
}
ch.SetOwner(ch)
return ch, nil
}
// Name 返回 Channel 名称
func (c *WeComWSChannel) Name() string {
return "wecom_ws"
}
// Start 启动 WebSocket 连接
func (c *WeComWSChannel) Start(ctx context.Context) error {
logger.InfoC("wecom_ws", "Starting WeCom WebSocket channel...")
// 取消旧的 context如果存在
if c.cancel != nil {
c.cancel()
}
c.ctx, c.cancel = context.WithCancel(ctx)
// 启动 req_id 自动保存
if c.reqIDStore != nil {
c.reqIDStore.StartAutoSave(5 * time.Minute)
}
// 启动连接管理
go c.connectionManager()
// 启动消息状态清理
go c.cleanupLoop()
c.SetRunning(true)
logger.InfoC("wecom_ws", "WeCom WebSocket channel started")
return nil
}
// Stop 停止 WebSocket 连接
func (c *WeComWSChannel) Stop(ctx context.Context) error {
logger.InfoC("wecom_ws", "Stopping WeCom WebSocket channel...")
if c.cancel != nil {
c.cancel()
}
c.closeConnection()
// 保存 req_id 存储
if c.reqIDStore != nil {
if err := c.reqIDStore.Stop(); err != nil {
logger.ErrorCF("wecom_ws", "Failed to save req_id store", map[string]any{
"error": err.Error(),
})
}
}
c.SetRunning(false)
logger.InfoC("wecom_ws", "WeCom WebSocket channel stopped")
return nil
}
// Send 发送消息到企业微信
func (c *WeComWSChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
c.connMu.RLock()
connected := c.connected
c.connMu.RUnlock()
if !connected {
return fmt.Errorf("websocket not connected: %w", channels.ErrTemporary)
}
logger.DebugCF("wecom_ws", "Sending message", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
// 获取或创建消息状态
state := c.getOrCreateMessageState(msg.ChatID)
// 检查是否为 Markdown 内容
isMarkdown := c.isMarkdownContent(msg.Content)
// 发送文本/Markdown 内容
if msg.Content != "" {
// 分块发送长消息
chunks := c.splitMessage(msg.Content)
for i, chunk := range chunks {
isLast := i == len(chunks)-1
var response *ResponseMessage
if isMarkdown {
// Markdown 格式响应
response = &ResponseMessage{
MsgType: "markdown",
Markdown: &Markdown{
Content: chunk,
},
}
} else {
// 流式文本响应
response = &ResponseMessage{
MsgType: "stream",
Stream: &StreamContent{
ID: state.StreamID,
Finish: isLast,
Content: chunk,
},
}
}
body, err := json.Marshal(response)
if err != nil {
return fmt.Errorf("failed to marshal response: %w", err)
}
// 企业微信 WS 规范:回复时需要透传收到消息的 req_id
reqID := state.ReqID
if reqID == "" {
reqID = generateReqID()
}
wsMsg := &WeComWSMessage{
Cmd: string(CmdAIBotResponse),
Headers: MessageHeaders{
ReqID: reqID,
},
Body: body,
}
if err := c.sendMessage(wsMsg); err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
// 非最后一条消息时添加小延迟,避免消息顺序混乱
if !isLast {
time.Sleep(100 * time.Millisecond)
}
}
}
// 清理消息状态
c.deleteMessageState(msg.ChatID)
// 触发发送消息事件
msgType := "stream"
if isMarkdown {
msgType = "markdown"
}
c.eventManager.Emit(EventMessageSent, EventPayloadMessageSent{
MsgType: msgType,
ChatID: msg.ChatID,
})
return nil
}
// connectionManager 管理 WebSocket 连接生命周期
func (c *WeComWSChannel) connectionManager() {
for {
select {
case <-c.ctx.Done():
return
default:
}
if err := c.connect(); err != nil {
logger.ErrorCF("wecom_ws", "Connection failed", map[string]any{
"error": err.Error(),
})
c.reconnects++
if c.config.MaxReconnectAttempts > 0 && c.reconnects > c.config.MaxReconnectAttempts {
logger.ErrorC("wecom_ws", "Max reconnection attempts reached, but continuing to loop to ensure service alive")
// 触发错误事件
c.eventManager.Emit(EventError, EventPayloadError{
Error: fmt.Errorf("max reconnection attempts reached"),
})
// 不退出,只保留最大延迟
c.reconnects = c.config.MaxReconnectAttempts
}
// 指数退避重连
backoff := time.Duration(c.config.ReconnectInterval) * time.Second
if c.reconnects > 5 {
backoff = time.Duration(c.config.ReconnectInterval*c.reconnects/5) * time.Second
}
if backoff > 60*time.Second {
backoff = 60 * time.Second
}
// 触发重连事件
c.eventManager.Emit(EventReconnecting, EventPayloadReconnecting{
URL: c.config.WSURL,
Attempt: c.reconnects,
BackoffMs: int(backoff.Milliseconds()),
})
logger.InfoCF("wecom_ws", "Reconnecting...", map[string]any{
"attempt": c.reconnects,
"backoff": backoff.Seconds(),
})
time.Sleep(backoff)
continue
}
// 连接成功,重置重连计数
c.reconnects = 0
// 使用上下文控制这部分的读写协程生命周期
connCtx, connCancel := context.WithCancel(c.ctx)
// 启动读写协程
var wg sync.WaitGroup
wg.Add(1)
// 清空旧的发送队列,防止将上一次遗留的数据发给新建立的连接
drainLoop:
for {
select {
case <-c.sendCh:
default:
break drainLoop
}
}
// 先启动 writeLoop确保订阅消息能被发送
go func() {
defer wg.Done()
defer connCancel()
c.writeLoop(connCtx)
}()
// 等待一小段时间确保 writeLoop 已启动
time.Sleep(100 * time.Millisecond)
// 发送认证订阅消息
if err := c.subscribe(); err != nil {
logger.ErrorCF("wecom_ws", "Subscription failed", map[string]any{
"error": err.Error(),
})
c.closeConnection()
connCancel()
wg.Wait()
continue
}
wg.Add(1)
go func() {
defer wg.Done()
defer connCancel()
c.readLoop(connCtx)
}()
// 等待读写协程结束
wg.Wait()
c.closeConnection()
logger.InfoC("wecom_ws", "Connection closed, will reconnect...")
}
}
// connect 建立 WebSocket 连接
func (c *WeComWSChannel) connect() error {
logger.InfoCF("wecom_ws", "Connecting to WebSocket", map[string]any{
"url": c.config.WSURL,
"bot_id": c.config.BotID,
})
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
// 使用默认 TLS 配置
TLSClientConfig: &tls.Config{
InsecureSkipVerify: false,
},
}
// 添加自定义请求头
headers := http.Header{}
logger.DebugC("wecom_ws", "Dialing WebSocket...")
conn, resp, err := dialer.DialContext(c.ctx, c.config.WSURL, headers)
if err != nil {
logger.ErrorCF("wecom_ws", "WebSocket dial error", map[string]any{
"error": err.Error(),
"url": c.config.WSURL,
})
return fmt.Errorf("websocket dial failed: %w", err)
}
if resp != nil {
logger.DebugCF("wecom_ws", "WebSocket handshake response", map[string]any{
"status": resp.Status,
"code": resp.StatusCode,
})
resp.Body.Close()
}
c.connMu.Lock()
c.wsConn = conn
c.connected = true
c.connMu.Unlock()
// 设置心跳超时和标准 ping/pong 处理器 (处理底层心跳)
deadline := time.Duration(c.config.HeartbeatInterval) * 3 * time.Second
conn.SetReadDeadline(time.Now().Add(deadline))
originalPingHandler := conn.PingHandler()
conn.SetPingHandler(func(appData string) error {
conn.SetReadDeadline(time.Now().Add(deadline))
if originalPingHandler != nil {
return originalPingHandler(appData)
}
return nil
})
originalPongHandler := conn.PongHandler()
conn.SetPongHandler(func(appData string) error {
conn.SetReadDeadline(time.Now().Add(deadline))
if originalPongHandler != nil {
return originalPongHandler(appData)
}
return nil
})
logger.InfoC("wecom_ws", "WebSocket connected")
// 触发连接成功事件
c.eventManager.Emit(EventConnected, EventPayloadConnected{
URL: c.config.WSURL,
})
// 订阅消息将在 connectionManager 中发送,确保 writeLoop 已启动
return nil
}
// subscribe 发送认证订阅
func (c *WeComWSChannel) subscribe() error {
subscribeBody := SubscribeBody{
Secret: c.config.Secret,
BotID: c.config.BotID,
}
body, err := json.Marshal(subscribeBody)
if err != nil {
return fmt.Errorf("failed to marshal subscribe body: %w", err)
}
msg := &WeComWSMessage{
Cmd: string(CmdSubscribe),
Headers: MessageHeaders{
ReqID: generateReqID(),
},
Body: body,
}
if err := c.sendMessage(msg); err != nil {
return err
}
logger.InfoC("wecom_ws", "Subscription sent")
// 触发认证成功事件
c.eventManager.Emit(EventAuthenticated, EventPayloadAuthenticated{
BotID: c.config.BotID,
})
return nil
}
// closeConnection 关闭 WebSocket 连接
func (c *WeComWSChannel) closeConnection() {
c.connMu.Lock()
wasConnected := c.connected
if c.wsConn != nil {
c.wsConn.Close()
c.wsConn = nil
}
c.connected = false
c.connMu.Unlock()
// 触发断开连接事件
if wasConnected {
c.eventManager.Emit(EventDisconnected, EventPayloadDisconnected{
URL: c.config.WSURL,
})
}
}
// readLoop 读取 WebSocket 消息
func (c *WeComWSChannel) readLoop(ctx context.Context) {
defer c.closeConnection()
for {
select {
case <-ctx.Done():
return
default:
}
c.connMu.RLock()
conn := c.wsConn
c.connMu.RUnlock()
if conn == nil {
return
}
// 更新读超时,心跳间隔的 3 倍,如果没有收到任何消息则主动断开
conn.SetReadDeadline(time.Now().Add(time.Duration(c.config.HeartbeatInterval) * 3 * time.Second))
// 先读取原始消息
messageType, rawMessage, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
logger.ErrorCF("wecom_ws", "WebSocket read error", map[string]any{
"error": err.Error(),
})
}
return
}
logger.DebugCF("wecom_ws", "Raw message received", map[string]any{
"message_type": messageType,
"raw_data": string(rawMessage),
})
// 解析 JSON 消息
var msg WeComWSMessage
if err := json.Unmarshal(rawMessage, &msg); err != nil {
logger.WarnCF("wecom_ws", "Failed to unmarshal message", map[string]any{
"error": err.Error(),
"raw_data": string(rawMessage),
"msg_type": messageType,
})
continue
}
c.handleMessage(&msg)
}
}
// writeLoop 写入 WebSocket 消息
func (c *WeComWSChannel) writeLoop(ctx context.Context) {
ticker := time.NewTicker(time.Duration(c.config.HeartbeatInterval) * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case msg := <-c.sendCh:
c.connMu.RLock()
conn := c.wsConn
c.connMu.RUnlock()
if conn == nil {
logger.DebugC("wecom_ws", "Connection is nil, exiting writeLoop")
return
}
// 更新写超时
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteJSON(msg); err != nil {
logger.ErrorCF("wecom_ws", "WebSocket write error", map[string]any{
"error": err.Error(),
})
c.closeConnection()
return
}
case <-ticker.C:
c.connMu.RLock()
conn := c.wsConn
c.connMu.RUnlock()
if conn == nil {
logger.DebugC("wecom_ws", "Connection is nil, exiting writeLoop on ticker")
return
}
// 发送心跳
if err := c.sendPing(); err != nil {
logger.ErrorCF("wecom_ws", "Ping failed", map[string]any{
"error": err.Error(),
})
return
}
}
}
}
// sendMessage 发送消息到发送队列
func (c *WeComWSChannel) sendMessage(msg *WeComWSMessage) error {
select {
case c.sendCh <- msg:
return nil
case <-c.ctx.Done():
return c.ctx.Err()
case <-time.After(5 * time.Second):
return fmt.Errorf("send timeout")
}
}
// sendPing 发送心跳
func (c *WeComWSChannel) sendPing() error {
msg := &WeComWSMessage{
Cmd: string(CmdPing),
Headers: MessageHeaders{
ReqID: generateReqID(),
},
Body: json.RawMessage("{}"),
}
return c.sendMessage(msg)
}
// handleMessage 处理收到的消息
func (c *WeComWSChannel) handleMessage(msg *WeComWSMessage) {
logger.DebugCF("wecom_ws", "Received message", map[string]any{
"cmd": msg.Cmd,
"req_id": msg.Headers.ReqID,
"body_len": len(msg.Body),
})
// 处理空命令(可能是心跳或空消息)
if msg.Cmd == "" {
logger.DebugC("wecom_ws", "Empty command received, ignoring")
return
}
switch WeComCommand(msg.Cmd) {
case CmdPing:
// 心跳响应,无需处理
logger.DebugC("wecom_ws", "Ping received")
case CmdAIBotCallback, CmdAIBotMsgCallback:
// 企业微信推送消息
go c.handleCallback(msg)
case CmdAIBotEventCallback:
// 企业微信事件回调
go c.handleEventCallback(msg)
default:
logger.WarnCF("wecom_ws", "Unknown command", map[string]any{
"cmd": msg.Cmd,
})
}
}
// handleEventCallback 处理企业微信事件回调
func (c *WeComWSChannel) handleEventCallback(msg *WeComWSMessage) {
logger.DebugCF("wecom_ws", "Event callback received", map[string]any{
"body": string(msg.Body),
})
// 事件回调通常不需要处理,只需记录日志
}
// handleCallback 处理企业微信推送的消息
func (c *WeComWSChannel) handleCallback(msg *WeComWSMessage) {
var callback CallbackMessage
if err := json.Unmarshal(msg.Body, &callback); err != nil {
logger.ErrorCF("wecom_ws", "Failed to unmarshal callback", map[string]any{
"error": err.Error(),
})
return
}
// 检查消息是否重复(使用 msgid 去重)
if c.reqIDStore != nil && c.reqIDStore.Exists(callback.MsgID) {
logger.DebugCF("wecom_ws", "Duplicate message ignored", map[string]any{
"msg_id": callback.MsgID,
})
return
}
// 记录 req_id
if c.reqIDStore != nil {
c.reqIDStore.Add(callback.MsgID)
}
logger.DebugCF("wecom_ws", "Handling callback", map[string]any{
"msg_type": callback.MsgType,
"chat_id": callback.ChatID,
"from": callback.From.UserID,
})
// 触发收到消息事件
c.eventManager.Emit(EventMessageReceived, EventPayloadMessageReceived{
MsgType: callback.MsgType,
ChatID: callback.ChatID,
From: callback.From.UserID,
})
// 保存 req_id 以便回复时透传(企业微信 WS 规范要求)
// chatID: 群聊用 chatid单聊用 userid
chatIDForState := callback.ChatID
if chatIDForState == "" {
chatIDForState = callback.From.UserID
}
if chatIDForState != "" && msg.Headers.ReqID != "" {
state := c.getOrCreateMessageState(chatIDForState)
state.ReqID = msg.Headers.ReqID
}
// 使用超时控制包装消息处理
err := withTimeout(c.ctx, messageProcessTimeout, func() error {
// 解析消息内容
parsedContent := c.parseMessageContent(&callback)
// 添加详细的消息处理日志
logger.DebugCF("wecom_ws", "Processing message", map[string]any{
"chat_type": callback.ChatType,
"chat_id": callback.ChatID,
"user_id": callback.From.UserID,
"msg_id": callback.MsgID,
"text_parts": len(parsedContent.TextParts),
"media_count": len(parsedContent.MediaList),
"has_quote": parsedContent.QuoteContent != "",
})
// 检查是否为空消息(没有文本、媒体、引用)
if len(parsedContent.TextParts) == 0 &&
len(parsedContent.MediaList) == 0 &&
parsedContent.QuoteContent == "" {
logger.DebugC("wecom_ws", "Skipping empty message (no text, image, file or quote)")
return nil
}
// 下载媒体文件
if len(parsedContent.MediaList) > 0 {
parsedContent.MediaList = c.downloadAndSaveMediaList(parsedContent.MediaList)
}
// 根据消息类型处理
switch callback.MsgType {
case "text":
c.handleTextMessage(&callback, parsedContent)
case "image":
c.handleImageMessage(&callback, parsedContent)
case "voice":
c.handleVoiceMessage(&callback, parsedContent)
case "video":
c.handleVideoMessage(&callback, parsedContent)
case "file":
c.handleFileMessage(&callback, parsedContent)
case "mixed":
c.handleMixedMessage(&callback, parsedContent)
case "quote":
c.handleQuoteMessage(&callback, parsedContent)
case "stream":
// 流式消息轮询,更新消息状态
c.handleStreamMessage(&callback)
default:
logger.WarnCF("wecom_ws", "Unsupported message type", map[string]any{
"msg_type": callback.MsgType,
})
}
return nil
})
if err != nil {
logger.ErrorCF("wecom_ws", "Message processing timed out or failed", map[string]any{
"msg_id": callback.MsgID,
"error": err.Error(),
})
// 触发错误事件
c.eventManager.Emit(EventError, EventPayloadError{
Error: fmt.Errorf("message processing failed: %w", err),
})
}
}
// handleTextMessage 处理文本消息
func (c *WeComWSChannel) handleTextMessage(msg *CallbackMessage, parsedContent *ParsedMessageContent) {
if msg.Text == nil && len(parsedContent.TextParts) == 0 {
return
}
content := msg.Text.Content
if len(parsedContent.TextParts) > 0 {
// 使用解析后的文本(可能包含语音转文字内容)
content = ""
for _, part := range parsedContent.TextParts {
content += part + " "
}
content = content[:len(content)-1] // 去掉最后的空格
}
senderID := msg.From.UserID
chatID := msg.ChatID
if chatID == "" {
chatID = senderID
}
// 如果文本为空但存在引用消息,使用引用消息内容
if content == "" && parsedContent.QuoteContent != "" {
content = parsedContent.QuoteContent
logger.DebugC("wecom_ws", "Using quote content as message body (user only mentioned bot)")
}
// 判断是否为群聊
isGroupChat := msg.ChatType == "group"
// 群聊触发检查 - 使用群组管理器
if isGroupChat {
// 移除 @提及标记(如 @机器人)
content = regexp.MustCompile(`@\S+`).ReplaceAllString(content, "")
content = strings.TrimSpace(content)
// 检查用户是否在群组白名单中
if !c.groupManager.IsAllowedInGroup(chatID, senderID) {
logger.DebugCF("wecom_ws", "User not allowed in group", map[string]any{
"group_id": chatID,
"sender_id": senderID,
})
return
}
// 检查是否应该响应
respond, cleaned := c.groupManager.ShouldRespondInGroup(chatID, false, content)
if !respond {
return
}
content = cleaned
}
// 初始化消息状态
state := c.getOrCreateMessageState(chatID)
state.StreamID = generateStreamID()
// 发送"思考中"消息(如果启用)
if c.config.SendThinkingMessage {
c.sendThinkingMessage(chatID, state.StreamID)
}
// 构建 metadata
metadata := map[string]string{
"msg_type": "text",
"msg_id": msg.MsgID,
"platform": "wecom_ws",
"response_url": msg.ResponseURL,
"stream_id": state.StreamID,
}
if isGroupChat {
metadata["chat_id"] = msg.ChatID
metadata["sender_id"] = senderID
}
// 添加媒体信息到 metadata
if len(parsedContent.MediaList) > 0 {
var mediaPaths []string
var mediaTypes []string
for _, media := range parsedContent.MediaList {
if media.Path != "" {
mediaPaths = append(mediaPaths, media.Path)
mediaTypes = append(mediaTypes, media.ContentType)
}
}
// 向后兼容:单个媒体
if len(mediaPaths) > 0 {
metadata["media_path"] = mediaPaths[0]
metadata["media_type"] = mediaTypes[0]
}
// 新功能:多个媒体数组
if len(mediaPaths) > 0 {
metadata["media_paths"] = strings.Join(mediaPaths, ",")
metadata["media_types"] = strings.Join(mediaTypes, ",")
}
}
// 添加引用消息内容
if parsedContent.QuoteContent != "" {
metadata["reply_to_body"] = parsedContent.QuoteContent
}
// 构建 sender
sender := bus.SenderInfo{
Platform: "wecom_ws",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("wecom_ws", senderID),
}
if !c.IsAllowedSender(sender) {
return
}
// 确定 peer
peerKind := "direct"
if isGroupChat {
peerKind = "group"
}
peer := bus.Peer{Kind: peerKind, ID: chatID}
logger.DebugCF("wecom_ws", "Publishing message", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"content": utils.Truncate(content, 50),
})
// 发布消息到 bus
c.HandleMessage(c.ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender)
}
// handleImageMessage 处理图片消息
func (c *WeComWSChannel) handleImageMessage(msg *CallbackMessage, parsedContent *ParsedMessageContent) {
logger.DebugCF("wecom_ws", "Handling image message", map[string]any{
"chat_id": msg.ChatID,
"image_count": len(parsedContent.ImageURLs),
})
// 图片已经在 parseMessageContent 和 downloadAndSaveMediaList 中处理
// 这里只需要记录日志,实际的媒体路径已经在 parsedContent.MediaList 中
for _, media := range parsedContent.MediaList {
if media.Type == "image" && media.Path != "" {
logger.DebugCF("wecom_ws", "Image saved", map[string]any{
"url": media.URL,
"path": media.Path,
})
}
}
}
// handleVoiceMessage 处理语音消息
func (c *WeComWSChannel) handleVoiceMessage(msg *CallbackMessage, parsedContent *ParsedMessageContent) {
// 语音消息的内容已经在 parseMessageContent 中提取为文本
// 直接复用文本消息处理
if len(parsedContent.TextParts) > 0 {
c.handleTextMessage(msg, parsedContent)
}
}
// handleVideoMessage 处理视频消息
func (c *WeComWSChannel) handleVideoMessage(msg *CallbackMessage, parsedContent *ParsedMessageContent) {
logger.DebugCF("wecom_ws", "Handling video message", map[string]any{
"chat_id": msg.ChatID,
"videos": len(parsedContent.MediaList),
})
// 视频已经在 downloadAndSaveMediaList 中处理
for _, media := range parsedContent.MediaList {
if media.Type == "video" && media.Path != "" {
logger.DebugCF("wecom_ws", "Video saved", map[string]any{
"url": media.URL,
"path": media.Path,
})
}
}
}
// handleFileMessage 处理文件消息
func (c *WeComWSChannel) handleFileMessage(msg *CallbackMessage, parsedContent *ParsedMessageContent) {
logger.DebugCF("wecom_ws", "Handling file message", map[string]any{
"chat_id": msg.ChatID,
"file_count": len(parsedContent.FileURLs),
})
// 文件已经在 downloadAndSaveMediaList 中处理
for _, media := range parsedContent.MediaList {
if media.Type == "file" && media.Path != "" {
logger.DebugCF("wecom_ws", "File saved", map[string]any{
"url": media.URL,
"path": media.Path,
"filename": media.Filename,
})
}
}
}
// handleMixedMessage 处理图文混排消息
func (c *WeComWSChannel) handleMixedMessage(msg *CallbackMessage, parsedContent *ParsedMessageContent) {
// 图文混排消息已经在 parseMessageContent 中解析
// 直接复用文本消息处理,媒体信息已经在 parsedContent 中
c.handleTextMessage(msg, parsedContent)
}
// handleQuoteMessage 处理引用消息
func (c *WeComWSChannel) handleQuoteMessage(msg *CallbackMessage, parsedContent *ParsedMessageContent) {
logger.DebugCF("wecom_ws", "Handling quote message", map[string]any{
"chat_id": msg.ChatID,
"quote_content": utils.Truncate(parsedContent.QuoteContent, 50),
})
// 引用消息的内容已经在 parseMessageContent 中提取
// 直接复用文本消息处理
if len(parsedContent.TextParts) > 0 || parsedContent.QuoteContent != "" {
c.handleTextMessage(msg, parsedContent)
}
}
// handleStreamMessage 处理流式消息轮询
func (c *WeComWSChannel) handleStreamMessage(msg *CallbackMessage) {
// 流式轮询消息,更新消息状态以继续响应
logger.DebugCF("wecom_ws", "Stream poll received", map[string]any{
"chat_id": msg.ChatID,
})
}
// sendThinkingMessage 发送"思考中"消息
func (c *WeComWSChannel) sendThinkingMessage(chatID, streamID string) {
response := &ResponseMessage{
MsgType: "stream",
Stream: &StreamContent{
ID: streamID,
Finish: false,
Content: "",
},
}
body, err := json.Marshal(response)
if err != nil {
logger.ErrorCF("wecom_ws", "Failed to marshal thinking message", map[string]any{
"error": err.Error(),
})
return
}
wsMsg := &WeComWSMessage{
Cmd: string(CmdAIBotResponse),
Headers: MessageHeaders{
ReqID: generateReqID(),
},
Body: body,
}
if err := c.sendMessage(wsMsg); err != nil {
logger.ErrorCF("wecom_ws", "Failed to send thinking message", map[string]any{
"error": err.Error(),
})
}
}
// getOrCreateMessageState 获取或创建消息状态
func (c *WeComWSChannel) getOrCreateMessageState(chatID string) *MessageState {
c.statesMu.Lock()
defer c.statesMu.Unlock()
if state, exists := c.messageStates[chatID]; exists {
return state
}
state := &MessageState{
StreamID: generateStreamID(),
CreatedAt: time.Now(),
}
c.messageStates[chatID] = state
return state
}
// deleteMessageState 删除消息状态
func (c *WeComWSChannel) deleteMessageState(chatID string) {
c.statesMu.Lock()
defer c.statesMu.Unlock()
delete(c.messageStates, chatID)
}
// cleanupLoop 清理过期的消息状态
func (c *WeComWSChannel) cleanupLoop() {
ticker := time.NewTicker(messageStateCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.cleanupMessageStates()
case <-c.ctx.Done():
return
}
}
}
// cleanupMessageStates 清理过期消息状态
func (c *WeComWSChannel) cleanupMessageStates() {
c.statesMu.Lock()
defer c.statesMu.Unlock()
now := time.Now()
for chatID, state := range c.messageStates {
if now.Sub(state.CreatedAt) > messageStateTTL {
delete(c.messageStates, chatID)
}
}
// 如果超过最大数量,清理最旧的
if len(c.messageStates) > messageStateMaxSize {
// 简单的清理策略:删除一半
count := 0
for chatID := range c.messageStates {
if count >= messageStateMaxSize/2 {
break
}
delete(c.messageStates, chatID)
count++
}
}
}
// splitMessage 分割长消息
func (c *WeComWSChannel) splitMessage(content string) []string {
if len(content) <= textChunkLimit {
return []string{content}
}
var chunks []string
runes := []rune(content)
for len(runes) > 0 {
end := textChunkLimit
if end > len(runes) {
end = len(runes)
}
chunks = append(chunks, string(runes[:end]))
runes = runes[end:]
}
return chunks
}
// generateReqID 生成请求 ID
func generateReqID() string {
return generateRandomString(16)
}
// generateStreamID 生成流 ID
func generateStreamID() string {
return generateRandomString(10)
}
// generateRandomString 生成随机字符串
func generateRandomString(length int) string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
b[i] = letters[n.Int64()]
}
return string(b)
}
// parseMessageContent 解析消息内容
func (c *WeComWSChannel) parseMessageContent(msg *CallbackMessage) *ParsedMessageContent {
result := &ParsedMessageContent{
TextParts: []string{},
ImageURLs: []string{},
ImageAESKeys: make(map[string]string),
FileURLs: []string{},
FileAESKeys: make(map[string]string),
MediaList: []MediaInfo{},
}
switch msg.MsgType {
case "text":
if msg.Text != nil && msg.Text.Content != "" {
result.TextParts = append(result.TextParts, msg.Text.Content)
}
case "image":
if msg.Image != nil && msg.Image.URL != "" {
result.ImageURLs = append(result.ImageURLs, msg.Image.URL)
if msg.Image.AESKey != "" {
result.ImageAESKeys[msg.Image.URL] = msg.Image.AESKey
}
result.MediaList = append(result.MediaList, MediaInfo{
URL: msg.Image.URL,
Type: "image",
AESKey: msg.Image.AESKey,
})
}
case "voice":
if msg.Voice != nil && msg.Voice.Content != "" {
// 语音转文字后的内容作为文本
result.TextParts = append(result.TextParts, msg.Voice.Content)
}
case "video":
if msg.Video != nil && msg.Video.URL != "" {
result.MediaList = append(result.MediaList, MediaInfo{
URL: msg.Video.URL,
Type: "video",
AESKey: msg.Video.AESKey,
})
}
case "file":
if msg.File != nil && msg.File.URL != "" {
result.FileURLs = append(result.FileURLs, msg.File.URL)
if msg.File.AESKey != "" {
result.FileAESKeys[msg.File.URL] = msg.File.AESKey
}
result.MediaList = append(result.MediaList, MediaInfo{
URL: msg.File.URL,
Type: "file",
Filename: msg.File.Filename,
AESKey: msg.File.AESKey,
})
}
case "mixed":
if msg.Mixed != nil {
for _, item := range msg.Mixed.MsgItem {
switch item.MsgType {
case "text":
if item.Text != nil && item.Text.Content != "" {
result.TextParts = append(result.TextParts, item.Text.Content)
}
case "image":
if item.Image != nil && item.Image.URL != "" {
result.ImageURLs = append(result.ImageURLs, item.Image.URL)
if item.Image.AESKey != "" {
result.ImageAESKeys[item.Image.URL] = item.Image.AESKey
}
result.MediaList = append(result.MediaList, MediaInfo{
URL: item.Image.URL,
Type: "image",
AESKey: item.Image.AESKey,
})
}
}
}
}
case "quote":
if msg.Quote != nil {
// 提取引用消息的内容
switch msg.Quote.MsgType {
case "text":
if msg.Quote.Text != nil && msg.Quote.Text.Content != "" {
result.QuoteContent = msg.Quote.Text.Content
}
case "voice":
if msg.Quote.Voice != nil && msg.Quote.Voice.Content != "" {
result.QuoteContent = msg.Quote.Voice.Content
}
}
}
}
return result
}
// withTimeout 带超时的函数执行
func withTimeout(ctx context.Context, timeout time.Duration, fn func() error) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
done := make(chan error, 1)
go func() {
done <- fn()
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("operation timed out after %v", timeout)
}
}
// downloadMediaWithTimeout 带超时的媒体下载
func (c *WeComWSChannel) downloadMediaWithTimeout(url string, timeout time.Duration, maxSizeMB int) ([]byte, error) {
var result []byte
var downloadErr error
err := withTimeout(c.ctx, timeout, func() error {
result, downloadErr = c.downloadMedia(url, timeout, maxSizeMB)
return downloadErr
})
if err != nil {
return nil, err
}
return result, nil
}
// downloadMedia 下载媒体文件
func (c *WeComWSChannel) downloadMedia(url string, timeout time.Duration, maxSizeMB int) ([]byte, error) {
if url == "" {
return nil, fmt.Errorf("empty URL")
}
logger.DebugCF("wecom_ws", "Downloading media", map[string]any{
"url": url,
"timeout": timeout.Seconds(),
})
// 创建带超时的 HTTP 客户端
client := &http.Client{
Timeout: timeout,
}
req, err := http.NewRequestWithContext(c.ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to download media: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode)
}
// 检查文件大小
maxBytes := int64(maxSizeMB) * 1024 * 1024
if resp.ContentLength > maxBytes {
return nil, fmt.Errorf("file too large: %d bytes (max %d MB)", resp.ContentLength, maxSizeMB)
}
// 读取文件内容
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes))
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
logger.DebugCF("wecom_ws", "Media downloaded", map[string]any{
"url": url,
"size": len(data),
})
return data, nil
}
// saveMediaToCache 保存媒体文件到缓存
func (c *WeComWSChannel) saveMediaToCache(data []byte, filename string) (string, error) {
// 获取缓存目录
cacheDir := c.getMediaCacheDir()
// 创建目录
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", fmt.Errorf("failed to create cache directory: %w", err)
}
// 生成文件名
if filename == "" {
filename = generateRandomString(16)
}
filepath := filepath.Join(cacheDir, filename)
// 写入文件
if err := os.WriteFile(filepath, data, 0644); err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
logger.DebugCF("wecom_ws", "Media saved to cache", map[string]any{
"path": filepath,
"size": len(data),
})
return filepath, nil
}
// getMediaCacheDir 获取媒体缓存目录
func (c *WeComWSChannel) getMediaCacheDir() string {
// 优先使用配置的缓存路径
if c.config.MediaCachePath != "" {
return c.config.MediaCachePath
}
// 使用系统临时目录
return filepath.Join(os.TempDir(), "picoclaw", "wecom_ws", "media")
}
// downloadAndSaveMediaList 下载并保存媒体列表(并发下载)
func (c *WeComWSChannel) downloadAndSaveMediaList(mediaList []MediaInfo) []MediaInfo {
if !c.config.EnableMediaDownload || len(mediaList) == 0 {
return mediaList
}
maxSize := c.config.MediaMaxSize
if maxSize == 0 {
maxSize = defaultMediaMaxMB
}
// 使用 WaitGroup 并发下载
var wg sync.WaitGroup
results := make([]MediaInfo, len(mediaList))
var mu sync.Mutex
for i, media := range mediaList {
wg.Add(1)
go func(index int, m MediaInfo) {
defer wg.Done()
var timeout time.Duration
if m.Type == "image" {
timeout = imageDownloadTimeoutMs * time.Millisecond
} else {
timeout = fileDownloadTimeoutMs * time.Millisecond
}
// 下载媒体
data, err := c.downloadMediaWithTimeout(m.URL, timeout, maxSize)
if err != nil {
logger.WarnCF("wecom_ws", "Failed to download media", map[string]any{
"url": m.URL,
"error": err.Error(),
})
mu.Lock()
results[index] = m
mu.Unlock()
return
}
// 解密(如果有 AES key
if m.AESKey != "" {
decrypted, err := c.decryptMediaData(data, m.AESKey)
if err != nil {
logger.WarnCF("wecom_ws", "Failed to decrypt media", map[string]any{
"url": m.URL,
"error": err.Error(),
})
// 解密失败,使用原始数据
} else {
data = decrypted
}
}
// 检测文件类型
fileType := DetectFileType(data)
m.ContentType = string(fileType.Type)
// 生成文件名
filename := m.Filename
if filename == "" {
filename = generateRandomString(16) + fileType.Ext
}
// 保存到缓存
cachePath, err := c.saveMediaToCache(data, filename)
if err != nil {
logger.WarnCF("wecom_ws", "Failed to save media to cache", map[string]any{
"url": m.URL,
"error": err.Error(),
})
mu.Lock()
results[index] = m
mu.Unlock()
return
}
m.Path = cachePath
mu.Lock()
results[index] = m
mu.Unlock()
logger.DebugCF("wecom_ws", "Media downloaded and saved", map[string]any{
"url": m.URL,
"path": cachePath,
"type": m.Type,
})
}(i, media)
}
wg.Wait()
return results
}
// isMarkdownContent 检查内容是否为 Markdown 格式
func (c *WeComWSChannel) isMarkdownContent(content string) bool {
// 简单的 Markdown 检测规则
markdownPatterns := []string{
"# ", // 标题
"## ", // 二级标题
"### ", // 三级标题
"**", // 粗体
"*", // 斜体
"`", // 代码
"```", // 代码块
"[", // 链接
"!", // 图片
"- ", // 列表
"1. ", // 有序列表
"> ", // 引用
"|", // 表格
"---", // 分隔线
}
for _, pattern := range markdownPatterns {
if strings.Contains(content, pattern) {
return true
}
}
return false
}
// sendMediaResponse 发送媒体响应(图片或文件)
func (c *WeComWSChannel) sendMediaResponse(streamID, mediaPath, mediaType string) error {
if mediaPath == "" {
return nil
}
// 读取媒体文件
data, err := os.ReadFile(mediaPath)
if err != nil {
return fmt.Errorf("failed to read media file: %w", err)
}
// 根据文件类型发送
fileTypeInfo := DetectFileType(data)
if IsImage(fileTypeInfo.Type) {
return c.sendImageResponse(streamID, data, true)
}
// 文件响应
filename := filepath.Base(mediaPath)
return c.sendFileResponse(streamID, data, filename, true)
}
// cleanupMediaCache 清理媒体缓存
func (c *WeComWSChannel) cleanupMediaCache(maxAge time.Duration) {
cacheDir := c.getMediaCacheDir()
entries, err := os.ReadDir(cacheDir)
if err != nil {
logger.DebugCF("wecom_ws", "Failed to read cache directory", map[string]any{
"error": err.Error(),
})
return
}
now := time.Now()
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if now.Sub(info.ModTime()) > maxAge {
path := filepath.Join(cacheDir, entry.Name())
if err := os.Remove(path); err != nil {
logger.DebugCF("wecom_ws", "Failed to remove old cache file", map[string]any{
"path": path,
"error": err.Error(),
})
}
}
}
}
// decryptMediaData 解密媒体文件数据
// 企业微信媒体文件使用 AES-256-CBC 加密
func (c *WeComWSChannel) decryptMediaData(encryptedData []byte, aesKey string) ([]byte, error) {
if aesKey == "" {
// 没有加密密钥,直接返回
return encryptedData, nil
}
logger.DebugC("wecom_ws", "Decrypting media data")
// 解码 AES key
key, err := decodeWeComAESKey(aesKey)
if err != nil {
return nil, fmt.Errorf("failed to decode AES key: %w", err)
}
// 解密数据
decryptedData, err := decryptAESCBC(key, encryptedData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt media: %w", err)
}
logger.DebugCF("wecom_ws", "Media decrypted", map[string]any{
"original_size": len(encryptedData),
"decrypted_size": len(decryptedData),
})
return decryptedData, nil
}
// downloadAndProcessMedia 下载并处理媒体文件(包括解密)
func (c *WeComWSChannel) downloadAndProcessMedia(url, aesKey, filename string, timeout time.Duration, maxSizeMB int) (string, error) {
// 下载媒体文件
data, err := c.downloadMedia(url, timeout, maxSizeMB)
if err != nil {
return "", fmt.Errorf("failed to download media: %w", err)
}
// 如果有 AES key解密数据
if aesKey != "" && c.config.EnableMediaDownload {
data, err = c.decryptMediaData(data, aesKey)
if err != nil {
logger.WarnCF("wecom_ws", "Failed to decrypt media, using encrypted data", map[string]any{
"error": err.Error(),
})
// 解密失败,继续使用加密数据
}
}
// 保存到缓存
cachePath, err := c.saveMediaToCache(data, filename)
if err != nil {
return "", fmt.Errorf("failed to save media to cache: %w", err)
}
return cachePath, nil
}
// sendImageResponse 发送图片响应
func (c *WeComWSChannel) sendImageResponse(streamID string, imageData []byte, finish bool) error {
// 计算 MD5
md5Hash := fmt.Sprintf("%x", md5.Sum(imageData))
// 转换为 base64
base64Data := base64.StdEncoding.EncodeToString(imageData)
response := &ResponseMessage{
MsgType: "stream",
Stream: &StreamContent{
ID: streamID,
Finish: finish,
MsgItem: []StreamMsgItem{
{
MsgType: "image",
Image: &ResponseImage{
Base64: base64Data,
MD5: md5Hash,
},
},
},
},
}
body, err := json.Marshal(response)
if err != nil {
return fmt.Errorf("failed to marshal image response: %w", err)
}
wsMsg := &WeComWSMessage{
Cmd: string(CmdAIBotResponse),
Headers: MessageHeaders{
ReqID: generateReqID(),
},
Body: body,
}
return c.sendMessage(wsMsg)
}
// sendFileResponse 发送文件响应
func (c *WeComWSChannel) sendFileResponse(streamID string, fileData []byte, filename string, finish bool) error {
// 转换为 base64
base64Data := base64.StdEncoding.EncodeToString(fileData)
response := &ResponseMessage{
MsgType: "stream",
Stream: &StreamContent{
ID: streamID,
Finish: finish,
MsgItem: []StreamMsgItem{
{
MsgType: "file",
File: &ResponseFile{
Base64: base64Data,
Filename: filename,
},
},
},
},
}
body, err := json.Marshal(response)
if err != nil {
return fmt.Errorf("failed to marshal file response: %w", err)
}
wsMsg := &WeComWSMessage{
Cmd: string(CmdAIBotResponse),
Headers: MessageHeaders{
ReqID: generateReqID(),
},
Body: body,
}
return c.sendMessage(wsMsg)
}