From 6caee427bb4c4556ff06c17ded8b36a4c4364e8b Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 13:25:37 +0800 Subject: [PATCH 01/22] Add WeCom AIBot channel implementation and tests - Introduced WeCom AIBot channel configuration in config.go with relevant fields. - Implemented WeCom AIBot channel factory registration in init.go. - Created unit tests for WeCom AIBot channel functionalities including initialization, start/stop behavior, webhook path handling, message encryption/decryption, and signature generation. - Set default values for WeCom AIBot configuration in defaults.go. --- config/config.example.json | 13 +- pkg/channels/manager.go | 4 + pkg/channels/wecom/aibot.go | 1143 ++++++++++++++++++++++++++++++ pkg/channels/wecom/aibot_test.go | 218 ++++++ pkg/channels/wecom/init.go | 3 + pkg/config/config.go | 43 +- pkg/config/defaults.go | 12 + 7 files changed, 1421 insertions(+), 15 deletions(-) create mode 100644 pkg/channels/wecom/aibot.go create mode 100644 pkg/channels/wecom/aibot_test.go diff --git a/config/config.example.json b/config/config.example.json index d885ef94b..df72a876e 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -127,7 +127,7 @@ "reasoning_channel_id": "" }, "wecom": { - "_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats", + "_comment": "WeCom Bot - Easier setup, supports group chats", "enabled": false, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", @@ -149,6 +149,17 @@ "allow_from": [], "reply_timeout": 5, "reasoning_channel_id": "" + }, + "wecom_aibot": { + "_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.", + "enabled": false, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "max_steps": 10, + "welcome_message": "Hello! I'm your AI assistant. How can I help you today?" } }, "providers": { diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 155e50b39..21fcddd09 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -255,6 +255,10 @@ func (m *Manager) initChannels() error { m.initChannel("wecom", "WeCom") } + if m.config.Channels.WeComAIBot.Enabled && m.config.Channels.WeComAIBot.Token != "" { + m.initChannel("wecom_aibot", "WeCom AI Bot") + } + if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" { m.initChannel("wecom_app", "WeCom App") } diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go new file mode 100644 index 000000000..c2f98806b --- /dev/null +++ b/pkg/channels/wecom/aibot.go @@ -0,0 +1,1143 @@ +package wecom + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// WeComAIBotChannel implements the Channel interface for WeCom AI Bot (企业微信智能机器人) +type WeComAIBotChannel struct { + *channels.BaseChannel + config config.WeComAIBotConfig + ctx context.Context + cancel context.CancelFunc + streamTasks map[string]*streamTask // streamID -> task (for poll lookups) + chatTasks map[string][]*streamTask // chatID -> in-flight tasks queue (FIFO) + taskMu sync.RWMutex +} + +// streamTask represents a streaming task for AI Bot +type streamTask struct { + StreamID string + ChatID string // used by Send() to find this task + ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) + Question string + CreatedTime time.Time + Deadline time.Time // ~5m30s, we close the stream here and switch to response_url + StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url + Finished bool // fully done + mu sync.Mutex + answerCh chan string // receives agent reply from Send() +} + +// WeComAIBotMessage represents the decrypted JSON message from WeCom AI Bot +// Ref: https://developer.work.weixin.qq.com/document/path/100719 +type WeComAIBotMessage struct { + MsgID string `json:"msgid"` + AIBotID string `json:"aibotid"` + ChatID string `json:"chatid"` // only for group chat + ChatType string `json:"chattype"` // "single" or "group" + From struct { + UserID string `json:"userid"` + } `json:"from"` + ResponseURL string `json:"response_url"` // temporary URL for proactive reply + MsgType string `json:"msgtype"` + // text message + Text *struct { + Content string `json:"content"` + } `json:"text,omitempty"` + // stream polling refresh + Stream *struct { + ID string `json:"id"` + } `json:"stream,omitempty"` + // image message + Image *struct { + URL string `json:"url"` + } `json:"image,omitempty"` + // mixed message (text + image) + Mixed *struct { + MsgItem []struct { + MsgType string `json:"msgtype"` + Text *struct { + Content string `json:"content"` + } `json:"text,omitempty"` + Image *struct { + URL string `json:"url"` + } `json:"image,omitempty"` + } `json:"msg_item"` + } `json:"mixed,omitempty"` + // event field + Event *struct { + EventType string `json:"eventtype"` + } `json:"event,omitempty"` +} + +// WeComAIBotStreamResponse represents the streaming response format +type WeComAIBotStreamResponse struct { + MsgType string `json:"msgtype"` + Stream struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + } `json:"stream"` +} + +// WeComAIBotEncryptedResponse represents the encrypted response wrapper +// Fields match WXBizJsonMsgCrypt.generate() in Python SDK +type WeComAIBotEncryptedResponse struct { + Encrypt string `json:"encrypt"` + MsgSignature string `json:"msgsignature"` + Timestamp string `json:"timestamp"` + Nonce string `json:"nonce"` +} + +// NewWeComAIBotChannel creates a new WeCom AI Bot channel instance +func NewWeComAIBotChannel( + cfg config.WeComAIBotConfig, + messageBus *bus.MessageBus, +) (*WeComAIBotChannel, error) { + if cfg.Token == "" || cfg.EncodingAESKey == "" { + return nil, fmt.Errorf("token and encoding_aes_key are required for WeCom AI Bot") + } + + base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(2048), + ) + + return &WeComAIBotChannel{ + BaseChannel: base, + config: cfg, + streamTasks: make(map[string]*streamTask), + chatTasks: make(map[string][]*streamTask), + }, nil +} + +// Name returns the channel name +func (c *WeComAIBotChannel) Name() string { + return "wecom_aibot" +} + +// Start initializes the WeCom AI Bot channel +func (c *WeComAIBotChannel) Start(ctx context.Context) error { + logger.InfoC("wecom_aibot", "Starting WeCom AI Bot channel...") + + c.ctx, c.cancel = context.WithCancel(ctx) + + // Start cleanup goroutine for old tasks + go c.cleanupLoop() + + c.SetRunning(true) + logger.InfoC("wecom_aibot", "WeCom AI Bot channel started") + + return nil +} + +// Stop gracefully stops the WeCom AI Bot channel +func (c *WeComAIBotChannel) Stop(ctx context.Context) error { + logger.InfoC("wecom_aibot", "Stopping WeCom AI Bot channel...") + + if c.cancel != nil { + c.cancel() + } + + c.SetRunning(false) + logger.InfoC("wecom_aibot", "WeCom AI Bot channel stopped") + return nil +} + +// Send delivers the agent reply into the active streamTask for msg.ChatID. +// It writes into the earliest unfinished task in the queue (FIFO per chatID). +// If the stream has already closed (deadline passed), it posts directly to response_url. +func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + c.taskMu.Lock() + queue := c.chatTasks[msg.ChatID] + for len(queue) > 0 && queue[0].Finished { + queue = queue[1:] + } + c.chatTasks[msg.ChatID] = queue + var task *streamTask + if len(queue) > 0 { + task = queue[0] + } + c.taskMu.Unlock() + + if task == nil { + logger.DebugCF( + "wecom_aibot", + "Send: no active task for chat (may have timed out)", + map[string]any{ + "chat_id": msg.ChatID, + }, + ) + return nil + } + + task.mu.Lock() + streamClosed := task.StreamClosed + responseURL := task.ResponseURL + task.mu.Unlock() + + if streamClosed { + // Stream already ended with a "please wait" notice; send the real reply via response_url. + logger.InfoCF("wecom_aibot", "Sending reply via response_url", map[string]any{ + "stream_id": task.StreamID, + "chat_id": msg.ChatID, + }) + if responseURL != "" { + if err := c.sendViaResponseURL(responseURL, msg.Content); err != nil { + logger.ErrorCF("wecom_aibot", "Failed to send via response_url", map[string]any{ + "error": err, + "stream_id": task.StreamID, + }) + } + } else { + logger.WarnCF("wecom_aibot", "Stream closed but no response_url available", map[string]any{ + "stream_id": task.StreamID, + }) + } + c.removeTask(task) + return nil + } + + // Stream still open: deliver via answerCh for the next poll response. + select { + case task.answerCh <- msg.Content: + case <-ctx.Done(): + return ctx.Err() + } + return nil +} + +// WebhookPath returns the path for registering on the shared HTTP server +func (c *WeComAIBotChannel) WebhookPath() string { + if c.config.WebhookPath == "" { + return "/webhook/wecom-aibot" + } + return c.config.WebhookPath +} + +// ServeHTTP implements http.Handler for the shared HTTP server +func (c *WeComAIBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + c.handleWebhook(w, r) +} + +// HealthPath returns the health check endpoint path +func (c *WeComAIBotChannel) HealthPath() string { + return c.WebhookPath() + "/health" +} + +// HealthHandler handles health check requests +func (c *WeComAIBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { + c.handleHealth(w, r) +} + +// handleWebhook handles incoming webhook requests from WeCom AI Bot +func (c *WeComAIBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Log all incoming requests for debugging + logger.DebugCF("wecom_aibot", "Received webhook request", map[string]any{ + "method": r.Method, + "path": r.URL.Path, + "query": r.URL.RawQuery, + }) + + if r.Method == http.MethodGet { + // URL verification + c.handleVerification(ctx, w, r) + } else if r.Method == http.MethodPost { + // Message callback + c.handleMessageCallback(ctx, w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleVerification handles the URL verification request from WeCom +func (c *WeComAIBotChannel) handleVerification( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, +) { + msgSignature := r.URL.Query().Get("msg_signature") + timestamp := r.URL.Query().Get("timestamp") + nonce := r.URL.Query().Get("nonce") + echostr := r.URL.Query().Get("echostr") + + logger.DebugCF("wecom_aibot", "URL verification request", map[string]any{ + "msg_signature": msgSignature, + "timestamp": timestamp, + "nonce": nonce, + }) + + // Verify signature + if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { + logger.ErrorC("wecom_aibot", "Signature verification failed") + http.Error(w, "Signature verification failed", http.StatusUnauthorized) + return + } + + // Decrypt echostr + // For WeCom AI Bot (智能机器人), receiveid should be empty string + decrypted, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to decrypt echostr", map[string]any{ + "error": err, + }) + http.Error(w, "Decryption failed", http.StatusInternalServerError) + return + } + + // Remove BOM and whitespace as per WeCom documentation + decrypted = strings.TrimPrefix(decrypted, "\ufeff") + decrypted = strings.TrimSpace(decrypted) + + logger.InfoC("wecom_aibot", "URL verification successful") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(decrypted)) +} + +// handleMessageCallback handles incoming messages from WeCom AI Bot +func (c *WeComAIBotChannel) handleMessageCallback( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, +) { + msgSignature := r.URL.Query().Get("msg_signature") + timestamp := r.URL.Query().Get("timestamp") + nonce := r.URL.Query().Get("nonce") + + // Read request body + body, err := io.ReadAll(r.Body) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to read request body", map[string]any{ + "error": err, + }) + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + + // Parse JSON body to get encrypted message + // Format: {"encrypt": "base64_encrypted_string"} + var encryptedMsg struct { + Encrypt string `json:"encrypt"` + } + if err := json.Unmarshal(body, &encryptedMsg); err != nil { + logger.ErrorCF("wecom_aibot", "Failed to parse JSON body", map[string]any{ + "error": err, + "body": string(body), + }) + http.Error(w, "Failed to parse JSON", http.StatusBadRequest) + return + } + + // Verify signature + if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { + logger.ErrorC("wecom_aibot", "Signature verification failed") + http.Error(w, "Signature verification failed", http.StatusUnauthorized) + return + } + + // Decrypt message + // For WeCom AI Bot (智能机器人), receiveid is empty string + decrypted, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to decrypt message", map[string]any{ + "error": err, + }) + http.Error(w, "Decryption failed", http.StatusInternalServerError) + return + } + + // Parse decrypted JSON message + var msg WeComAIBotMessage + if unmarshalErr := json.Unmarshal([]byte(decrypted), &msg); unmarshalErr != nil { + logger.ErrorCF("wecom_aibot", "Failed to parse decrypted JSON", map[string]any{ + "error": unmarshalErr, + "decrypted": decrypted, + }) + http.Error(w, "Failed to parse message", http.StatusInternalServerError) + return + } + + logger.DebugCF("wecom_aibot", "Decrypted message", map[string]any{ + "msgtype": msg.MsgType, + }) + + // Process the message and get streaming response + response := c.processMessage(ctx, msg, timestamp, nonce) + + // Return encrypted JSON response + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) +} + +// processMessage processes the received message and returns encrypted response +func (c *WeComAIBotChannel) processMessage( + ctx context.Context, + msg WeComAIBotMessage, + timestamp, nonce string, +) string { + logger.DebugCF("wecom_aibot", "Processing message", map[string]any{ + "msgtype": msg.MsgType, + }) + + switch msg.MsgType { + case "text": + return c.handleTextMessage(ctx, msg, timestamp, nonce) + case "stream": + return c.handleStreamMessage(ctx, msg, timestamp, nonce) + case "image": + return c.handleImageMessage(ctx, msg, timestamp, nonce) + case "mixed": + return c.handleMixedMessage(ctx, msg, timestamp, nonce) + case "event": + return c.handleEventMessage(ctx, msg, timestamp, nonce) + default: + logger.WarnCF("wecom_aibot", "Unsupported message type", map[string]any{ + "msgtype": msg.MsgType, + }) + return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: c.generateStreamID(), + Finish: true, + Content: "Unsupported message type: " + msg.MsgType, + }, + }) + } +} + +// handleTextMessage handles text messages by starting a new streaming task +func (c *WeComAIBotChannel) handleTextMessage( + ctx context.Context, + msg WeComAIBotMessage, + timestamp, nonce string, +) string { + if msg.Text == nil { + logger.ErrorC("wecom_aibot", "text message missing text field") + return c.encryptEmptyResponse(timestamp, nonce) + } + + content := msg.Text.Content + userID := msg.From.UserID + if userID == "" { + userID = "unknown" + } + + // chatID: group chat uses chatid, single chat uses userid + chatID := msg.ChatID + if chatID == "" { + chatID = userID + } + + streamID := c.generateStreamID() + + // WeCom stops sending stream-refresh callbacks after 6 minutes. + // Set a slightly shorter deadline so we can send a timeout notice before it gives up. + deadline := time.Now().Add(30 * time.Second) + + task := &streamTask{ + StreamID: streamID, + ChatID: chatID, + ResponseURL: msg.ResponseURL, + Question: content, + CreatedTime: time.Now(), + Deadline: deadline, + Finished: false, + answerCh: make(chan string, 1), + } + + c.taskMu.Lock() + c.streamTasks[streamID] = task + c.chatTasks[chatID] = append(c.chatTasks[chatID], task) + c.taskMu.Unlock() + + // Publish to agent asynchronously; agent will call Send() with reply + // Use c.ctx (channel lifetime) instead of r.Context() which is canceled when the HTTP handler returns. + go func() { + sender := bus.SenderInfo{ + Platform: "wecom_aibot", + PlatformID: userID, + CanonicalID: "wecom_aibot:" + userID, + DisplayName: userID, + } + peerKind := "direct" + if msg.ChatType == "group" { + peerKind = "group" + } + peer := bus.Peer{Kind: peerKind, ID: chatID} + metadata := map[string]string{ + "channel": "wecom_aibot", + "chat_type": msg.ChatType, + "msg_type": "text", + "msgid": msg.MsgID, + "aibotid": msg.AIBotID, + "stream_id": streamID, + "response_url": msg.ResponseURL, + } + c.HandleMessage(c.ctx, peer, msg.MsgID, userID, chatID, + content, nil, metadata, sender) + }() + + // Return first streaming response immediately (finish=false, content empty) + return c.getStreamResponse(task, timestamp, nonce) +} + +// handleStreamMessage handles stream polling requests +func (c *WeComAIBotChannel) handleStreamMessage( + ctx context.Context, + msg WeComAIBotMessage, + timestamp, nonce string, +) string { + if msg.Stream == nil { + logger.ErrorC("wecom_aibot", "Stream message missing stream field") + return c.encryptEmptyResponse(timestamp, nonce) + } + + streamID := msg.Stream.ID + + c.taskMu.RLock() + task, exists := c.streamTasks[streamID] + c.taskMu.RUnlock() + + if !exists { + logger.DebugCF( + "wecom_aibot", + "Stream task not found (may be from previous session)", + map[string]any{ + "stream_id": streamID, + }, + ) + return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: streamID, + Finish: true, + Content: "Task not found or already finished. Please resend your message to start a new session.", + }, + }) + } + + // Get next response + return c.getStreamResponse(task, timestamp, nonce) +} + +// handleImageMessage handles image messages +func (c *WeComAIBotChannel) handleImageMessage( + ctx context.Context, + msg WeComAIBotMessage, + timestamp, nonce string, +) string { + logger.WarnC("wecom_aibot", "Image message type not yet fully implemented") + if msg.Image == nil { + logger.ErrorC("wecom_aibot", "Image message missing image field") + return c.encryptEmptyResponse(timestamp, nonce) + } + + imageURL := msg.Image.URL + + // Download and decrypt image + _, err := c.downloadAndDecryptImage(ctx, imageURL) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to process image", map[string]any{ + "error": err, + "url": imageURL, + }) + return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: c.generateStreamID(), + Finish: true, + Content: fmt.Sprintf( + "Image received (URL: %s), but image messages are not yet supported", + imageURL, + ), + }, + }) + } + + // Echo back the image (simple demo behavior) + // streamID := c.generateStreamID() + // return c.encryptImageResponse(streamID, timestamp, nonce, imageData) + + // For now, just acknowledge receipt without echoing the image + return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: c.generateStreamID(), + Finish: true, + Content: fmt.Sprintf( + "Image received (URL: %s), but image messages are not yet supported", + imageURL, + ), + }, + }) +} + +// handleMixedMessage handles mixed (text + image) messages +func (c *WeComAIBotChannel) handleMixedMessage( + ctx context.Context, + msg WeComAIBotMessage, + timestamp, nonce string, +) string { + logger.WarnC("wecom_aibot", "Mixed message type not yet fully implemented") + return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: c.generateStreamID(), + Finish: true, + Content: "Mixed message type is not yet supported", + }, + }) +} + +// handleEventMessage handles event messages +func (c *WeComAIBotChannel) handleEventMessage( + ctx context.Context, + msg WeComAIBotMessage, + timestamp, nonce string, +) string { + eventType := "" + if msg.Event != nil { + eventType = msg.Event.EventType + } + logger.DebugCF("wecom_aibot", "Received event", map[string]any{ + "event_type": eventType, + }) + + // Send welcome message when user opens the chat window + if eventType == "enter_chat" && c.config.WelcomeMessage != "" { + streamID := c.generateStreamID() + return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: streamID, + Finish: true, + Content: c.config.WelcomeMessage, + }, + }) + } + + return c.encryptEmptyResponse(timestamp, nonce) +} + +// getStreamResponse gets the next streaming response for a task. +// - If agent replied: return finish=true with the real answer. +// - If deadline passed: return finish=true with a "please wait" notice, keep task alive for response_url. +// - Otherwise: return finish=false (empty), client will poll again. +func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce string) string { + var content string + var finish bool + var closeStreamOnly bool // close stream but do NOT remove task (response_url still pending) + + select { + case answer := <-task.answerCh: + // Agent replied before deadline — normal finish. + content = answer + finish = true + default: + if time.Now().After(task.Deadline) { + // Deadline reached: close the stream with a notice, then wait for agent via response_url. + content = "⏳ Processing, please wait. The results will be sent shortly." + finish = true + closeStreamOnly = true + logger.InfoCF( + "wecom_aibot", + "Stream deadline reached, switching to response_url mode", + map[string]any{ + "stream_id": task.StreamID, + "chat_id": task.ChatID, + "response_url": task.ResponseURL != "", + }, + ) + } + // else: still waiting, return finish=false + } + + if finish && !closeStreamOnly { + // Normal finish: remove from all maps. + c.removeTask(task) + } else if closeStreamOnly { + // Only mark stream as closed; keep in chatTasks for Send() to find. + task.mu.Lock() + task.StreamClosed = true + task.mu.Unlock() + // Remove from streamTasks (no more stream polls expected). + c.taskMu.Lock() + delete(c.streamTasks, task.StreamID) + c.taskMu.Unlock() + } + + response := WeComAIBotStreamResponse{ + MsgType: "stream", + Stream: struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` + }{ + ID: task.StreamID, + Finish: finish, + Content: content, + }, + } + + return c.encryptResponse(task.StreamID, timestamp, nonce, response) +} + +// removeTask removes a task from both streamTasks and chatTasks and marks it finished. +func (c *WeComAIBotChannel) removeTask(task *streamTask) { + task.mu.Lock() + task.Finished = true + task.mu.Unlock() + + c.taskMu.Lock() + delete(c.streamTasks, task.StreamID) + queue := c.chatTasks[task.ChatID] + for i, t := range queue { + if t == task { + c.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...) + break + } + } + if len(c.chatTasks[task.ChatID]) == 0 { + delete(c.chatTasks, task.ChatID) + } + c.taskMu.Unlock() +} + +// sendViaResponseURL posts a markdown reply to the WeCom response_url. +// response_url is valid for 1 hour and can only be used once per callback. +func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) error { + payload := map[string]any{ + "msgtype": "markdown", + "markdown": map[string]string{ + "content": content, + }, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + ctx, cancel := context.WithTimeout(c.ctx, 15*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, responseURL, bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to post to response_url: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("response_url returned %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +// encryptResponse encrypts a streaming response +func (c *WeComAIBotChannel) encryptResponse( + streamID, timestamp, nonce string, + response WeComAIBotStreamResponse, +) string { + // Marshal response to JSON + plaintext, err := json.Marshal(response) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to marshal response", map[string]any{ + "error": err, + }) + return "" + } + + logger.DebugCF("wecom_aibot", "Encrypting response", map[string]any{ + "stream_id": streamID, + "finish": response.Stream.Finish, + "preview": utils.Truncate(response.Stream.Content, 100), + }) + + // Encrypt message + encrypted, err := c.encryptMessage(string(plaintext), "") + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to encrypt message", map[string]any{ + "error": err, + }) + return "" + } + + // Generate signature + signature := c.generateSignature(timestamp, nonce, encrypted) + + // Build encrypted response + encryptedResp := WeComAIBotEncryptedResponse{ + Encrypt: encrypted, + MsgSignature: signature, + Timestamp: timestamp, + Nonce: nonce, + } + + respJSON, err := json.Marshal(encryptedResp) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to marshal encrypted response", map[string]any{ + "error": err, + }) + return "" + } + + logger.DebugCF("wecom_aibot", "Response encrypted", map[string]any{ + "stream_id": streamID, + }) + + return string(respJSON) +} + +// encryptEmptyResponse returns empty encrypted response +func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string { + return "" +} + +// encryptMessage encrypts a plain text message for WeCom AI Bot +func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) { + // Decode AES key + aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=") + if err != nil { + return "", fmt.Errorf("failed to decode AES key: %w", err) + } + + if len(aesKey) != 32 { + return "", fmt.Errorf("invalid AES key length: %d", len(aesKey)) + } + + // Generate 16-byte random string + randomBytes := make([]byte, 16) + for i := range 16 { + n, randErr := rand.Int(rand.Reader, big.NewInt(10)) + if randErr != nil { + return "", fmt.Errorf("failed to generate random: %w", randErr) + } + randomBytes[i] = byte('0' + n.Int64()) + } + + // Build message: random(16) + msg_len(4) + msg + receiveid + plaintextBytes := []byte(plaintext) + receiveidBytes := []byte(receiveid) + + msgLen := uint32(len(plaintextBytes)) + msgLenBytes := make([]byte, 4) + binary.BigEndian.PutUint32(msgLenBytes, msgLen) + + // Concatenate + var buffer bytes.Buffer + buffer.Write(randomBytes) + buffer.Write(msgLenBytes) + buffer.Write(plaintextBytes) + buffer.Write(receiveidBytes) + + // PKCS7 padding + plainData := buffer.Bytes() + plainData = pkcs7Pad(plainData, blockSize) + + // AES-CBC encrypt + block, err := aes.NewCipher(aesKey) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + ciphertext := make([]byte, len(plainData)) + iv := aesKey[:aes.BlockSize] + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(ciphertext, plainData) + + // Base64 encode + encoded := base64.StdEncoding.EncodeToString(ciphertext) + + return encoded, nil +} + +// pkcs7Pad adds PKCS7 padding +func pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - (len(data) % blockSize) + if padding == 0 { + padding = blockSize + } + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padText...) +} + +// generateSignature generates message signature using common function +func (c *WeComAIBotChannel) generateSignature(timestamp, nonce, encrypt string) string { + // Sort parameters + params := []string{c.config.Token, timestamp, nonce, encrypt} + sort.Strings(params) + + // Concatenate + str := strings.Join(params, "") + + // SHA1 hash + hash := sha1.Sum([]byte(str)) + return fmt.Sprintf("%x", hash) +} + +// generateStreamID generates a random stream ID +func (c *WeComAIBotChannel) generateStreamID() string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, 10) + for i := range b { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + b[i] = letters[n.Int64()] + } + return string(b) +} + +// downloadAndDecryptImage downloads and decrypts an encrypted image +func (c *WeComAIBotChannel) downloadAndDecryptImage( + ctx context.Context, + imageURL string, +) ([]byte, error) { + // Download image + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{ + Timeout: 15 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to download image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode) + } + + encryptedData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read image data: %w", err) + } + + logger.DebugCF("wecom_aibot", "Image downloaded", map[string]any{ + "size": len(encryptedData), + }) + + // Decode AES key + aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=") + if err != nil { + return nil, fmt.Errorf("failed to decode AES key: %w", err) + } + + if len(aesKey) != 32 { + return nil, fmt.Errorf("invalid AES key length: %d", len(aesKey)) + } + + // Decrypt image (AES-CBC) + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, fmt.Errorf("failed to create cipher: %w", err) + } + + if len(encryptedData)%aes.BlockSize != 0 { + return nil, fmt.Errorf("encrypted data size not multiple of block size") + } + + iv := aesKey[:aes.BlockSize] + mode := cipher.NewCBCDecrypter(block, iv) + + decryptedData := make([]byte, len(encryptedData)) + mode.CryptBlocks(decryptedData, encryptedData) + + // Remove PKCS7 padding + decryptedData, err = pkcs7Unpad(decryptedData) + if err != nil { + return nil, fmt.Errorf("failed to unpad: %w", err) + } + + logger.DebugCF("wecom_aibot", "Image decrypted", map[string]any{ + "size": len(decryptedData), + }) + + return decryptedData, nil +} + +// cleanupLoop periodically cleans up old streaming tasks +func (c *WeComAIBotChannel) cleanupLoop() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + c.cleanupOldTasks() + case <-c.ctx.Done(): + return + } + } +} + +// cleanupOldTasks removes tasks that have been alive longer than 1 hour +// (response_url validity window), which is the absolute maximum lifetime of any task. +func (c *WeComAIBotChannel) cleanupOldTasks() { + c.taskMu.Lock() + defer c.taskMu.Unlock() + + cutoff := time.Now().Add(-1 * time.Hour) + for id, task := range c.streamTasks { + if task.CreatedTime.Before(cutoff) { + delete(c.streamTasks, id) + queue := c.chatTasks[task.ChatID] + for i, t := range queue { + if t == task { + c.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...) + break + } + } + if len(c.chatTasks[task.ChatID]) == 0 { + delete(c.chatTasks, task.ChatID) + } + logger.DebugCF("wecom_aibot", "Cleaned up expired task", map[string]any{ + "stream_id": id, + }) + } + } + // Also clean up StreamClosed tasks from chatTasks that are older than 1 hour. + for chatID, queue := range c.chatTasks { + filtered := queue[:0] + for _, t := range queue { + if !t.Finished && t.CreatedTime.After(cutoff) { + filtered = append(filtered, t) + } + } + if len(filtered) == 0 { + delete(c.chatTasks, chatID) + } else { + c.chatTasks[chatID] = filtered + } + } +} + +// handleHealth handles health check requests +func (c *WeComAIBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) { + status := "ok" + if !c.IsRunning() { + status = "not running" + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "status": status, + }) +} diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go new file mode 100644 index 000000000..7fb90f22e --- /dev/null +++ b/pkg/channels/wecom/aibot_test.go @@ -0,0 +1,218 @@ +package wecom + +import ( + "context" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewWeComAIBotChannel(t *testing.T) { + t.Run("success with valid config", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "testkey1234567890123456789012345678901234567", + WebhookPath: "/webhook/test", + } + + messageBus := bus.NewMessageBus() + ch, err := NewWeComAIBotChannel(cfg, messageBus) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if ch == nil { + t.Fatal("Expected channel to be created") + } + + if ch.Name() != "wecom_aibot" { + t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name()) + } + }) + + t.Run("error with missing token", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + EncodingAESKey: "testkey1234567890123456789012345678901234567", + } + + messageBus := bus.NewMessageBus() + _, err := NewWeComAIBotChannel(cfg, messageBus) + + if err == nil { + t.Fatal("Expected error for missing token, got nil") + } + }) + + t.Run("error with missing encoding key", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + } + + messageBus := bus.NewMessageBus() + _, err := NewWeComAIBotChannel(cfg, messageBus) + + if err == nil { + t.Fatal("Expected error for missing encoding key, got nil") + } + }) +} + +func TestWeComAIBotChannelStartStop(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "testkey1234567890123456789012345678901234567", + } + + messageBus := bus.NewMessageBus() + ch, err := NewWeComAIBotChannel(cfg, messageBus) + if err != nil { + t.Fatalf("Failed to create channel: %v", err) + } + + ctx := context.Background() + + // Test Start + if err := ch.Start(ctx); err != nil { + t.Fatalf("Failed to start channel: %v", err) + } + + if !ch.IsRunning() { + t.Error("Expected channel to be running") + } + + // Test Stop + if err := ch.Stop(ctx); err != nil { + t.Fatalf("Failed to stop channel: %v", err) + } + + if ch.IsRunning() { + t.Error("Expected channel to be stopped") + } +} + +func TestWeComAIBotChannelWebhookPath(t *testing.T) { + t.Run("default path", func(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "testkey1234567890123456789012345678901234567", + } + + messageBus := bus.NewMessageBus() + ch, _ := NewWeComAIBotChannel(cfg, messageBus) + + expectedPath := "/webhook/wecom-aibot" + if ch.WebhookPath() != expectedPath { + t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, ch.WebhookPath()) + } + }) + + t.Run("custom path", func(t *testing.T) { + customPath := "/custom/webhook" + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "testkey1234567890123456789012345678901234567", + WebhookPath: customPath, + } + + messageBus := bus.NewMessageBus() + ch, _ := NewWeComAIBotChannel(cfg, messageBus) + + if ch.WebhookPath() != customPath { + t.Errorf("Expected webhook path '%s', got '%s'", customPath, ch.WebhookPath()) + } + }) +} + +func TestGenerateStreamID(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "testkey1234567890123456789012345678901234567", + } + + messageBus := bus.NewMessageBus() + ch, _ := NewWeComAIBotChannel(cfg, messageBus) + + // Generate multiple IDs and check they are unique + ids := make(map[string]bool) + for i := 0; i < 100; i++ { + id := ch.generateStreamID() + + if len(id) != 10 { + t.Errorf("Expected stream ID length 10, got %d", len(id)) + } + + if ids[id] { + t.Errorf("Duplicate stream ID generated: %s", id) + } + ids[id] = true + } +} + +func TestEncryptDecrypt(t *testing.T) { + // Use a valid 43-character base64 key (企业微信标准格式) + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters + } + + messageBus := bus.NewMessageBus() + ch, _ := NewWeComAIBotChannel(cfg, messageBus) + + plaintext := "Hello, World!" + receiveid := "" + + // Encrypt + encrypted, err := ch.encryptMessage(plaintext, receiveid) + if err != nil { + t.Fatalf("Failed to encrypt message: %v", err) + } + + if encrypted == "" { + t.Fatal("Encrypted message is empty") + } + + // Decrypt + decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid) + if err != nil { + t.Fatalf("Failed to decrypt message: %v", err) + } + + if decrypted != plaintext { + t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted) + } +} + +func TestGenerateSignature(t *testing.T) { + cfg := config.WeComAIBotConfig{ + Enabled: true, + Token: "test_token", + EncodingAESKey: "testkey1234567890123456789012345678901234567", + } + + messageBus := bus.NewMessageBus() + ch, _ := NewWeComAIBotChannel(cfg, messageBus) + + timestamp := "1234567890" + nonce := "test_nonce" + encrypt := "encrypted_msg" + + signature := ch.generateSignature(timestamp, nonce, encrypt) + + if signature == "" { + t.Error("Generated signature is empty") + } + + // Verify signature using verifySignature function + if !verifySignature(cfg.Token, signature, timestamp, nonce, encrypt) { + t.Error("Generated signature does not verify correctly") + } +} diff --git a/pkg/channels/wecom/init.go b/pkg/channels/wecom/init.go index 3ef1ecdf3..bc5a70fa3 100644 --- a/pkg/channels/wecom/init.go +++ b/pkg/channels/wecom/init.go @@ -13,4 +13,7 @@ func init() { channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { return NewWeComAppChannel(cfg.Channels.WeComApp, b) }) + channels.RegisterFactory("wecom_aibot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewWeComAIBotChannel(cfg.Channels.WeComAIBot, b) + }) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 9f4769de4..66f3945ed 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -192,19 +192,20 @@ func (d *AgentDefaults) GetModelName() string { } type ChannelsConfig struct { - WhatsApp WhatsAppConfig `json:"whatsapp"` - Telegram TelegramConfig `json:"telegram"` - Feishu FeishuConfig `json:"feishu"` - Discord DiscordConfig `json:"discord"` - MaixCam MaixCamConfig `json:"maixcam"` - QQ QQConfig `json:"qq"` - DingTalk DingTalkConfig `json:"dingtalk"` - Slack SlackConfig `json:"slack"` - LINE LINEConfig `json:"line"` - OneBot OneBotConfig `json:"onebot"` - WeCom WeComConfig `json:"wecom"` - WeComApp WeComAppConfig `json:"wecom_app"` - Pico PicoConfig `json:"pico"` + WhatsApp WhatsAppConfig `json:"whatsapp"` + Telegram TelegramConfig `json:"telegram"` + Feishu FeishuConfig `json:"feishu"` + Discord DiscordConfig `json:"discord"` + MaixCam MaixCamConfig `json:"maixcam"` + QQ QQConfig `json:"qq"` + DingTalk DingTalkConfig `json:"dingtalk"` + Slack SlackConfig `json:"slack"` + LINE LINEConfig `json:"line"` + OneBot OneBotConfig `json:"onebot"` + WeCom WeComConfig `json:"wecom"` + WeComApp WeComAppConfig `json:"wecom_app"` + WeComAIBot WeComAIBotConfig `json:"wecom_aibot"` + Pico PicoConfig `json:"pico"` } // GroupTriggerConfig controls when the bot responds in group chats. @@ -360,6 +361,19 @@ type WeComAppConfig struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"` } +type WeComAIBotConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` + MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps + WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome +} + type PicoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` @@ -637,7 +651,8 @@ func (c *Config) migrateChannelConfigs() { } // OneBot: group_trigger_prefix -> group_trigger.prefixes - if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { + if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && + len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix } } diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 44f4de7e9..fce955c83 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -137,6 +137,18 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, ReplyTimeout: 5, }, + WeComAIBot: WeComAIBotConfig{ + Enabled: false, + Token: "", + EncodingAESKey: "", + WebhookHost: "0.0.0.0", + WebhookPort: 18791, + WebhookPath: "/webhook/wecom-aibot", + AllowFrom: FlexibleStringSlice{}, + ReplyTimeout: 5, + MaxSteps: 10, + WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?", + }, Pico: PicoConfig{ Enabled: false, Token: "", From c7d4012fc9ab678168a7418d962fa7b8b9163ceb Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 14:04:26 +0800 Subject: [PATCH 02/22] fix(wecom-aibot): correct variable name in JSON parsing in message callback handler --- pkg/channels/wecom/aibot.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index c2f98806b..115fde9f7 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -351,9 +351,9 @@ func (c *WeComAIBotChannel) handleMessageCallback( var encryptedMsg struct { Encrypt string `json:"encrypt"` } - if err := json.Unmarshal(body, &encryptedMsg); err != nil { + if unmarshalErr := json.Unmarshal(body, &encryptedMsg); unmarshalErr != nil { logger.ErrorCF("wecom_aibot", "Failed to parse JSON body", map[string]any{ - "error": err, + "error": unmarshalErr, "body": string(body), }) http.Error(w, "Failed to parse JSON", http.StatusBadRequest) From a25726e7981ba8b184b401c9f198b5525fb06a41 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 14:38:41 +0800 Subject: [PATCH 03/22] feat(wecom): add WeCom AI Bot integration and update documentation --- README.fr.md | 46 ++++++- README.ja.md | 46 ++++++- README.md | 46 ++++++- README.pt-br.md | 46 ++++++- README.vi.md | 46 ++++++- README.zh.md | 2 +- config/config.example.json | 2 +- docs/channels/wecom/wecom_aibot/README.zh.md | 120 +++++++++++++++++++ docs/wecom-app-configuration.md | 115 ------------------ 9 files changed, 327 insertions(+), 142 deletions(-) create mode 100644 docs/channels/wecom/wecom_aibot/README.zh.md delete mode 100644 docs/wecom-app-configuration.md diff --git a/README.fr.md b/README.fr.md index 2bec768fc..43a6cab7a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -288,7 +288,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom | **QQ** | Facile (AppID + AppSecret) | | **DingTalk** | Moyen (identifiants de l'application) | | **LINE** | Moyen (identifiants + URL de webhook) | -| **WeCom** | Moyen (CorpID + configuration webhook) | +| **WeCom AI Bot** | Moyen (Token + clé AES) |
Telegram (Recommandé) @@ -491,12 +491,13 @@ picoclaw gateway
WeCom (WeChat Work) -PicoClaw prend en charge deux types d'intégration WeCom : +PicoClaw prend en charge trois types d'intégration WeCom : -**Option 1 : WeCom Bot (Robot Intelligent)** - Configuration plus facile, prend en charge les discussions de groupe -**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive +**Option 1 : WeCom Bot (Robot)** - Configuration plus facile, prend en charge les discussions de groupe +**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement +**Option 3 : WeCom AI Bot (Bot Intelligent)** - Bot IA officiel, réponses en streaming, prend en charge groupe et privé -Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour des instructions détaillées. +Voir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour des instructions détaillées. **Configuration Rapide - WeCom Bot :** @@ -563,6 +564,41 @@ picoclaw gateway > **Note** : Les callbacks webhook WeCom App sont servis par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Assurez-vous que le port `18790` est accessible ou utilisez un proxy inverse HTTPS en production. +**Configuration Rapide - WeCom AI Bot :** + +**1. Créer un AI Bot** + +* Accédez à la Console d'Administration WeCom → Gestion des Applications → AI Bot +* Configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot` +* Copiez le **Token** et générez l'**EncodingAESKey** + +**2. Configurer** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Bonjour ! Comment puis-je vous aider ?" + } + } +} +``` + +**3. Lancer** + +```bash +picoclaw gateway +``` + +> **Note** : WeCom AI Bot utilise le protocole pull en streaming — pas de problème de timeout. Les tâches longues (>5,5 min) basculent automatiquement vers la livraison via `response_url`. +
## ClawdChat Rejoignez le Réseau Social d'Agents diff --git a/README.ja.md b/README.ja.md index 15ed1f649..a6af79eb6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -257,7 +257,7 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき | **QQ** | 簡単(AppID + AppSecret) | | **DingTalk** | 普通(アプリ認証情報) | | **LINE** | 普通(認証情報 + Webhook URL) | -| **WeCom** | 普通(CorpID + Webhook設定) | +| **WeCom AI Bot** | 普通(Token + AES キー) |
Telegram(推奨) @@ -456,12 +456,13 @@ picoclaw gateway
WeCom (企業微信) -PicoClaw は2種類の WeCom 統合をサポートしています: +PicoClaw は3種類の WeCom 統合をサポートしています: -**オプション1: WeCom Bot (智能ロボット)** - 簡単な設定、グループチャット対応 -**オプション2: WeCom App (自作アプリ)** - より多機能、アクティブメッセージング対応 +**オプション1: WeCom Bot (ロボット)** - 簡単な設定、グループチャット対応 +**オプション2: WeCom App (カスタムアプリ)** - より多機能、アクティブメッセージング対応、プライベートチャットのみ +**オプション3: WeCom AI Bot (スマートボット)** - 公式 AI Bot、ストリーミング返信、グループ・プライベート両対応 -詳細な設定手順は [WeCom App Configuration Guide](docs/wecom-app-configuration.md) を参照してください。 +詳細な設定手順は [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) を参照してください。 **クイックセットアップ - WeCom Bot:** @@ -530,6 +531,41 @@ picoclaw gateway > **注意**: WeCom App の Webhook コールバックは共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は HTTPS 用のリバースプロキシを設定してください。 +**クイックセットアップ - WeCom AI Bot:** + +**1. AI Bot を作成** + +* WeCom 管理コンソール → アプリ管理 → AI Bot +* コールバック URL を設定: `http://your-server:18791/webhook/wecom-aibot` +* **Token** をコピーし、**EncodingAESKey** を生成 + +**2. 設定** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "こんにちは!何かお手伝いできますか?" + } + } +} +``` + +**3. 起動** + +```bash +picoclaw gateway +``` + +> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用 — 返信タイムアウトの心配なし。長時間タスク(>5.5分)は自動的に `response_url` によるプッシュ配信に切り替わります。 +
## ⚙️ 設定 diff --git a/README.md b/README.md index 2fc60343b..e49acb362 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We | **QQ** | Easy (AppID + AppSecret) | | **DingTalk** | Medium (app credentials) | | **LINE** | Medium (credentials + webhook URL) | -| **WeCom** | Medium (CorpID + webhook setup) | +| **WeCom AI Bot** | Medium (Token + AES key) |
Telegram (Recommended) @@ -557,12 +557,13 @@ picoclaw gateway
WeCom (企业微信) -PicoClaw supports two types of WeCom integration: +PicoClaw supports three types of WeCom integration: -**Option 1: WeCom Bot (智能机器人)** - Easier setup, supports group chats -**Option 2: WeCom App (自建应用)** - More features, proactive messaging +**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats +**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only +**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat -See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions. +See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions. **Quick Setup - WeCom Bot:** @@ -631,6 +632,41 @@ picoclaw gateway > **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS. +**Quick Setup - WeCom AI Bot:** + +**1. Create an AI Bot** + +* Go to WeCom Admin Console → App Management → AI Bot +* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot` +* Copy **Token** and click "Random Generate" for **EncodingAESKey** + +**2. Configure** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Hello! How can I help you?" + } + } +} +``` + +**3. Run** + +```bash +picoclaw gateway +``` + +> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>5.5 min) automatically switch to `response_url` push delivery. +
## ClawdChat Join the Agent Social Network diff --git a/README.pt-br.md b/README.pt-br.md index 611a61281..c37fb929b 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -282,7 +282,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom. | **QQ** | Fácil (AppID + AppSecret) | | **DingTalk** | Médio (credenciais do app) | | **LINE** | Médio (credenciais + webhook URL) | -| **WeCom** | Médio (CorpID + configuração webhook) | +| **WeCom AI Bot** | Médio (Token + chave AES) |
Telegram (Recomendado) @@ -485,12 +485,13 @@ picoclaw gateway
WeCom (WeChat Work) -O PicoClaw suporta dois tipos de integração WeCom: +O PicoClaw suporta três tipos de integração WeCom: -**Opção 1: WeCom Bot (Robô Inteligente)** - Configuração mais fácil, suporta chats em grupo -**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas +**Opção 1: WeCom Bot (Robô)** - Configuração mais fácil, suporta chats em grupo +**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas, somente chat privado +**Opção 3: WeCom AI Bot (Robô Inteligente)** - Bot IA oficial, respostas em streaming, suporta grupo e privado -Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para instruções detalhadas. +Veja o [Guia de Configuração WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) para instruções detalhadas. **Configuração Rápida - WeCom Bot:** @@ -559,6 +560,41 @@ picoclaw gateway > **Nota**: O WeCom App (callbacks de webhook) é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Em produção use um proxy reverso HTTPS para expor a porta do Gateway, ou atualize `PICOCLAW_GATEWAY_HOST` para `0.0.0.0` se necessário. +**Configuração Rápida - WeCom AI Bot:** + +**1. Criar um AI Bot** + +* Acesse o Console de Administração WeCom → Gerenciamento de Aplicativos → AI Bot +* Configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot` +* Copie o **Token** e gere o **EncodingAESKey** + +**2. Configurar** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Olá! Como posso ajudá-lo?" + } + } +} +``` + +**3. Executar** + +```bash +picoclaw gateway +``` + +> **Nota**: O WeCom AI Bot usa protocolo de pull em streaming — sem preocupações com timeout de resposta. Tarefas longas (>5,5 min) alternam automaticamente para entrega via `response_url`. +
## ClawdChat Junte-se a Rede Social de Agentes diff --git a/README.vi.md b/README.vi.md index e836e30f0..417ca0393 100644 --- a/README.vi.md +++ b/README.vi.md @@ -256,7 +256,7 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom. | **QQ** | Dễ (AppID + AppSecret) | | **DingTalk** | Trung bình (app credentials) | | **LINE** | Trung bình (credentials + webhook URL) | -| **WeCom** | Trung bình (CorpID + cấu hình webhook) | +| **WeCom AI Bot** | Trung bình (Token + khóa AES) |
Telegram (Khuyên dùng) @@ -457,12 +457,13 @@ picoclaw gateway
WeCom (WeChat Work) -PicoClaw hỗ trợ hai loại tích hợp WeCom: +PicoClaw hỗ trợ ba loại tích hợp WeCom: -**Tùy chọn 1: WeCom Bot (Robot Thông minh)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm -**Tùy chọn 2: WeCom App (Ứng dụng Tự xây dựng)** - Nhiều tính năng hơn, nhắn tin chủ động +**Tùy chọn 1: WeCom Bot (Robot)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm +**Tùy chọn 2: WeCom App (Ứng dụng Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng tư +**Tùy chọn 3: WeCom AI Bot (Bot Thông Minh)** - Bot AI chính thức, phản hồi streaming, hỗ trợ nhóm và riêng tư -Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) để biết hướng dẫn chi tiết. +Xem [Hướng dẫn Cấu hình WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) để biết hướng dẫn chi tiết. **Thiết lập Nhanh - WeCom Bot:** @@ -531,6 +532,41 @@ picoclaw gateway > **Lưu ý**: WeCom App callback webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). Sử dụng proxy ngược để cung cấp HTTPS trong môi trường production nếu cần. +**Thiết lập Nhanh - WeCom AI Bot:** + +**1. Tạo AI Bot** + +* Truy cập Bảng điều khiển Quản trị WeCom → Quản lý Ứng dụng → AI Bot +* Cấu hình URL callback: `http://your-server:18791/webhook/wecom-aibot` +* Sao chép **Token** và tạo **EncodingAESKey** + +**2. Cấu hình** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Xin chào! Tôi có thể giúp gì cho bạn?" + } + } +} +``` + +**3. Chạy** + +```bash +picoclaw gateway +``` + +> **Lưu ý**: WeCom AI Bot sử dụng giao thức pull streaming — không lo timeout phản hồi. Tác vụ dài (>5,5 phút) tự động chuyển sang gửi qua `response_url`. +
## ClawdChat Tham gia Mạng xã hội Agent diff --git a/README.zh.md b/README.zh.md index 95984bbdf..d3a49ee8d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -301,7 +301,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方 | **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) | | **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) | | **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) | -| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) | +| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) | | **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) | | **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) | | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | diff --git a/config/config.example.json b/config/config.example.json index df72a876e..36783d0ea 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -138,7 +138,7 @@ "reasoning_channel_id": "" }, "wecom_app": { - "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md", + "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.", "enabled": false, "corp_id": "YOUR_CORP_ID", "corp_secret": "YOUR_CORP_SECRET", diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md new file mode 100644 index 000000000..200a83a69 --- /dev/null +++ b/docs/channels/wecom/wecom_aibot/README.zh.md @@ -0,0 +1,120 @@ +# 企业微信智能机器人 (AI Bot) + +企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议,并支持超时后通过 `response_url` 主动推送最终回复。 + +## 与其他 WeCom 通道的对比 + +| 特性 | WeCom Bot | WeCom App | **WeCom AI Bot** | +|------|-----------|-----------|-----------------| +| 私聊 | ✅ | ✅ | ✅ | +| 群聊 | ✅ | ❌ | ✅ | +| 流式输出 | ❌ | ❌ | ✅ | +| 超时主动推送 | ❌ | ✅ | ✅ | +| 配置复杂度 | 低 | 高 | 中 | + +## 配置 + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "你好!有什么可以帮助你的吗?", + "max_steps": 10 + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ---------------- | ------ | ---- | -------------------------------------------------- | +| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 | +| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 | +| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) | +| webhook_port | int | 否 | HTTP 服务器端口(默认:18791) | +| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-aibot) | +| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 | +| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 | +| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) | +| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) | + +## 设置流程 + +1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin) +2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot +3. 在 AI Bot 配置页面,填写"消息接收"信息: + - **URL**:`http://:18791/webhook/wecom-aibot` + - **Token**:随机生成或自定义 + - **EncodingAESKey**:点击"随机生成",得到 43 字符密钥 +4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存(企业微信会发送验证请求) + +> [!TIP] +> 服务器需要能被企业微信服务器访问。如在内网/本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。 + +## 流式响应协议 + +WeCom AI Bot 使用"流式拉取"协议,区别于普通 Webhook 的一次性回复: + +``` +用户发消息 + │ + ▼ +PicoClaw 立即返回 {finish: false}(Agent 开始处理) + │ + ▼ +企业微信每隔约 1 秒拉取一次 {msgtype: "stream", stream: {id: "..."}} + │ + ├─ Agent 未完成 → 返回 {finish: false}(继续等待) + │ + └─ Agent 完成 → 返回 {finish: true, content: "回答内容"} +``` + +**超时处理**(任务超过 5 分 30 秒): + +若 Agent 处理时间超过约 5 分 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会: + +1. 立即关闭流,向用户显示「⏳ 正在处理中,请稍候,结果将稍后发送。」 +2. Agent 继续在后台运行 +3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户 + +> `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。 + +## 欢迎语 + +配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件),PicoClaw 会自动回复该欢迎语。留空则静默忽略。 + +```json +"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?" +``` + +## 常见问题 + +### 回调 URL 验证失败 + +- 确认服务器防火墙已开放对应端口(默认 18791) +- 确认 `token` 与 `encoding_aes_key` 填写正确 +- 检查 PicoClaw 日志是否收到了来自企业微信的 GET 请求 + +### 消息没有回复 + +- 检查 `allow_from` 是否意外限制了发送者 +- 查看日志中是否出现 `context canceled` 或 Agent 错误 +- 确认 Agent 配置(`model_name` 等)正确 + +### 超长任务没有收到最终推送 + +- 确认消息回调中携带了 `response_url`(仅企业微信新版 AI Bot 支持) +- 确认服务器能主动访问外网(需向 `response_url` POST 请求) +- 查看日志关键词 `response_url mode` 和 `Sending reply via response_url` + +## 参考文档 + +- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/100719) +- [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719) +- [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138) diff --git a/docs/wecom-app-configuration.md b/docs/wecom-app-configuration.md deleted file mode 100644 index 3c720ecd1..000000000 --- a/docs/wecom-app-configuration.md +++ /dev/null @@ -1,115 +0,0 @@ -# 企业微信自建应用 (WeCom App) 配置指南 - -本文档介绍如何在 PicoClaw 中配置企业微信自建应用 (wecom-app) 通道。 - -## 功能特性 - -| 功能 | 支持状态 | -|------|---------| -| 被动接收消息 | ✅ | -| 主动发送消息 | ✅ | -| 私聊 | ✅ | -| 群聊 | ❌ | - -## 配置步骤 - -### 1. 企业微信后台配置 - -1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin) -2. 进入"应用管理" → 选择自建应用 -3. 记录以下信息: - - **AgentId**: 应用详情页显示 - - **Secret**: 点击"查看"获取 -4. 进入"我的企业"页面,记录 **企业ID** (CorpID) - -### 2. 接收消息配置 - -1. 在应用详情页,点击"接收消息"的"设置API接收" -2. 填写以下信息: - - **URL**: `http://your-server:18790/webhook/wecom-app` - - **Token**: 随机生成或自定义(用于签名验证) - - **EncodingAESKey**: 点击"随机生成"生成43字符的密钥 -3. 点击"保存"时,企业微信会发送验证请求 - -### 3. PicoClaw 配置 - -在 `config.json` 中添加以下配置: - -```json -{ - "channels": { - "wecom_app": { - "enabled": true, - "corp_id": "wwxxxxxxxxxxxxxxxx", // 企业ID - "corp_secret": "xxxxxxxxxxxxxxxxxxxxxxxx", // 应用Secret - "agent_id": 1000002, // 应用AgentId - "token": "your_token", // 接收消息配置的Token - "encoding_aes_key": "your_encoding_aes_key", // 接收消息配置的EncodingAESKey - "webhook_path": "/webhook/wecom-app", - "allow_from": [], - "reply_timeout": 5 - } - } -} -``` - -## 常见问题 - -### 1. 回调URL验证失败 - -**症状**: 企业微信保存API接收消息时提示验证失败 - -**检查项**: -- 确认服务器防火墙已开放 Gateway 端口(默认 18790) -- 确认 `corp_id`、`token`、`encoding_aes_key` 配置正确 -- 查看 PicoClaw 日志是否有请求到达 - -### 2. 中文消息解密失败 - -**症状**: 发送中文消息时出现 `invalid padding size` 错误 - -**原因**: 企业微信使用非标准的 PKCS7 填充(32字节块大小) - -**解决**: 确保使用最新版本的 PicoClaw,已修复此问题。 - -### 3. 端口冲突 - -**症状**: 启动时提示端口已被占用 - -**解决**: 修改 `gateway.port` 为其他端口(所有 Webhook 渠道共享同一个 Gateway HTTP 服务器) - -## 技术细节 - -### 加密算法 - -- **算法**: AES-256-CBC -- **密钥**: EncodingAESKey Base64解码后的32字节 -- **IV**: AESKey的前16字节 -- **填充**: PKCS7(块大小为32字节,非标准16字节) -- **消息格式**: XML - -### 消息结构 - -解密后的消息格式: -``` -random(16B) + msg_len(4B) + msg + receiveid -``` - -其中 `receiveid` 对于自建应用是 `corp_id`。 - -## 调试 - -启用调试模式查看详细日志: - -```bash -picoclaw gateway --debug -``` - -关键日志标识: -- `wecom_app`: WeCom App 通道相关日志 -- `wecom_common`: 加密解密相关日志 - -## 参考文档 - -- [企业微信官方文档 - 接收消息](https://developer.work.weixin.qq.com/document/path/96211) -- [企业微信官方加解密库](https://github.com/sbzhu/weworkapi_golang) From e894f8d39af59724e9d74b017b11ec417a6ef0f2 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 15:08:07 +0800 Subject: [PATCH 04/22] feat(wecom-aibot): add reasoning_channel_id to configuration and enhance message handling limits --- config/config.example.json | 3 ++- pkg/channels/wecom/aibot.go | 17 ++++++++++++++--- pkg/config/config.go | 21 +++++++++++---------- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index 36783d0ea..872358bd4 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -159,7 +159,8 @@ "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "max_steps": 10, - "welcome_message": "Hello! I'm your AI assistant. How can I help you today?" + "welcome_message": "Hello! I'm your AI assistant. How can I help you today?", + "reasoning_channel_id": "" } }, "providers": { diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 115fde9f7..c4970d8fb 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -130,6 +130,7 @@ func NewWeComAIBotChannel( base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &WeComAIBotChannel{ @@ -336,8 +337,9 @@ func (c *WeComAIBotChannel) handleMessageCallback( timestamp := r.URL.Query().Get("timestamp") nonce := r.URL.Query().Get("nonce") - // Read request body - body, err := io.ReadAll(r.Body) + // Read request body (limit to 4 MB to prevent memory exhaustion) + const maxBodySize = 4 << 20 // 4 MB + body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1)) if err != nil { logger.ErrorCF("wecom_aibot", "Failed to read request body", map[string]any{ "error": err, @@ -345,6 +347,10 @@ func (c *WeComAIBotChannel) handleMessageCallback( http.Error(w, "Failed to read body", http.StatusBadRequest) return } + if len(body) > maxBodySize { + http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge) + return + } // Parse JSON body to get encrypted message // Format: {"encrypt": "base64_encrypted_string"} @@ -1024,10 +1030,15 @@ func (c *WeComAIBotChannel) downloadAndDecryptImage( return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode) } - encryptedData, err := io.ReadAll(resp.Body) + // Limit image download to 20 MB to prevent memory exhaustion + const maxImageSize = 20 << 20 // 20 MB + encryptedData, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize+1)) if err != nil { return nil, fmt.Errorf("failed to read image data: %w", err) } + if len(encryptedData) > maxImageSize { + return nil, fmt.Errorf("image too large (exceeds %d MB)", maxImageSize>>20) + } logger.DebugCF("wecom_aibot", "Image downloaded", map[string]any{ "size": len(encryptedData), diff --git a/pkg/config/config.go b/pkg/config/config.go index 66f3945ed..439c2b995 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -362,16 +362,17 @@ type WeComAppConfig struct { } type WeComAIBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` - MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps - WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` + MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps + WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"` } type PicoConfig struct { From e33712deff3be0b766d5d4490b646f22685aff06 Mon Sep 17 00:00:00 2001 From: ZHANG RUI Date: Sat, 28 Feb 2026 15:26:26 +0800 Subject: [PATCH 05/22] Update pkg/channels/wecom/aibot.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/channels/wecom/aibot.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index c4970d8fb..7b0470b40 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -44,7 +44,7 @@ type streamTask struct { ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) Question string CreatedTime time.Time - Deadline time.Time // ~5m30s, we close the stream here and switch to response_url + Deadline time.Time // ~30s, we close the stream here and switch to response_url StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url Finished bool // fully done mu sync.Mutex From aa9ce6955b18574cc920907703fa22654c1078d4 Mon Sep 17 00:00:00 2001 From: ZHANG RUI Date: Sat, 28 Feb 2026 15:29:51 +0800 Subject: [PATCH 06/22] Update pkg/channels/wecom/aibot.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/channels/wecom/aibot.go | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 7b0470b40..d26ed9066 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -93,21 +93,24 @@ type WeComAIBotMessage struct { } `json:"event,omitempty"` } +// WeComAIBotStreamInfo represents the detailed stream content in streaming responses +type WeComAIBotStreamInfo struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []struct { + MsgType string `json:"msgtype"` + Image *struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` + } `json:"image,omitempty"` + } `json:"msg_item,omitempty"` +} + // WeComAIBotStreamResponse represents the streaming response format type WeComAIBotStreamResponse struct { - MsgType string `json:"msgtype"` - Stream struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - } `json:"stream"` + MsgType string `json:"msgtype"` + Stream WeComAIBotStreamInfo `json:"stream"` } // WeComAIBotEncryptedResponse represents the encrypted response wrapper From 0b6d913dfca4b72f56703799c14809ef4cdc629f Mon Sep 17 00:00:00 2001 From: ZHANG RUI Date: Sat, 28 Feb 2026 15:31:39 +0800 Subject: [PATCH 07/22] Update pkg/channels/wecom/aibot.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/channels/wecom/aibot.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index d26ed9066..7924f38e6 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -181,6 +181,9 @@ func (c *WeComAIBotChannel) Stop(ctx context.Context) error { // It writes into the earliest unfinished task in the queue (FIFO per chatID). // If the stream has already closed (deadline passed), it posts directly to response_url. func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } c.taskMu.Lock() queue := c.chatTasks[msg.ChatID] for len(queue) > 0 && queue[0].Finished { From 4e09c91dda6ae1f2239b94b07d6e54b7e2b179cd Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 15:38:49 +0800 Subject: [PATCH 08/22] feat(wecom-aibot): add context management for stream tasks to improve agent cancellation --- pkg/channels/wecom/aibot.go | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 7924f38e6..b54202ece 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -48,7 +48,9 @@ type streamTask struct { StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url Finished bool // fully done mu sync.Mutex - answerCh chan string // receives agent reply from Send() + answerCh chan string // receives agent reply from Send() + ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine + cancel context.CancelFunc // call on task removal to cancel ctx } // WeComAIBotMessage represents the decrypted JSON message from WeCom AI Bot @@ -109,7 +111,7 @@ type WeComAIBotStreamInfo struct { // WeComAIBotStreamResponse represents the streaming response format type WeComAIBotStreamResponse struct { - MsgType string `json:"msgtype"` + MsgType string `json:"msgtype"` Stream WeComAIBotStreamInfo `json:"stream"` } @@ -237,6 +239,9 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e // Stream still open: deliver via answerCh for the next poll response. select { case task.answerCh <- msg.Content: + case <-task.ctx.Done(): + // Task was canceled (cleanup removed it); silently drop the reply. + return nil case <-ctx.Done(): return ctx.Err() } @@ -490,6 +495,10 @@ func (c *WeComAIBotChannel) handleTextMessage( // Set a slightly shorter deadline so we can send a timeout notice before it gives up. deadline := time.Now().Add(30 * time.Second) + // Each task gets its own context derived from the channel lifetime context. + // Canceling taskCancel interrupts the agent goroutine when the task is removed. + taskCtx, taskCancel := context.WithCancel(c.ctx) + task := &streamTask{ StreamID: streamID, ChatID: chatID, @@ -499,6 +508,8 @@ func (c *WeComAIBotChannel) handleTextMessage( Deadline: deadline, Finished: false, answerCh: make(chan string, 1), + ctx: taskCtx, + cancel: taskCancel, } c.taskMu.Lock() @@ -506,8 +517,8 @@ func (c *WeComAIBotChannel) handleTextMessage( c.chatTasks[chatID] = append(c.chatTasks[chatID], task) c.taskMu.Unlock() - // Publish to agent asynchronously; agent will call Send() with reply - // Use c.ctx (channel lifetime) instead of r.Context() which is canceled when the HTTP handler returns. + // Publish to agent asynchronously; agent will call Send() with reply. + // Use task.ctx (not c.ctx) so the agent goroutine is canceled when the task is removed. go func() { sender := bus.SenderInfo{ Platform: "wecom_aibot", @@ -529,7 +540,7 @@ func (c *WeComAIBotChannel) handleTextMessage( "stream_id": streamID, "response_url": msg.ResponseURL, } - c.HandleMessage(c.ctx, peer, msg.MsgID, userID, chatID, + c.HandleMessage(task.ctx, peer, msg.MsgID, userID, chatID, content, nil, metadata, sender) }() @@ -800,11 +811,13 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce return c.encryptResponse(task.StreamID, timestamp, nonce, response) } -// removeTask removes a task from both streamTasks and chatTasks and marks it finished. +// removeTask removes a task from both streamTasks and chatTasks, marks it finished, +// and cancels its context to interrupt the associated agent goroutine. func (c *WeComAIBotChannel) removeTask(task *streamTask) { task.mu.Lock() task.Finished = true task.mu.Unlock() + task.cancel() // interrupt agent goroutine bound to this task c.taskMu.Lock() delete(c.streamTasks, task.StreamID) @@ -1114,6 +1127,7 @@ func (c *WeComAIBotChannel) cleanupOldTasks() { for id, task := range c.streamTasks { if task.CreatedTime.Before(cutoff) { delete(c.streamTasks, id) + task.cancel() // interrupt agent goroutine still waiting for LLM queue := c.chatTasks[task.ChatID] for i, t := range queue { if t == task { @@ -1130,11 +1144,14 @@ func (c *WeComAIBotChannel) cleanupOldTasks() { } } // Also clean up StreamClosed tasks from chatTasks that are older than 1 hour. + // These were removed from streamTasks earlier but kept alive for response_url delivery. for chatID, queue := range c.chatTasks { filtered := queue[:0] for _, t := range queue { if !t.Finished && t.CreatedTime.After(cutoff) { filtered = append(filtered, t) + } else if !t.Finished { + t.cancel() // cancel any lingering agent goroutine } } if len(filtered) == 0 { From a87e6b0551593a466c3b3a863b5f4a8ebf7959ba Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 15:45:32 +0800 Subject: [PATCH 09/22] feat(wecom-aibot): enhance stream task management with StreamClosedAt and improved cleanup logic --- pkg/channels/wecom/aibot.go | 56 +++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index b54202ece..4be430626 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -39,18 +39,19 @@ type WeComAIBotChannel struct { // streamTask represents a streaming task for AI Bot type streamTask struct { - StreamID string - ChatID string // used by Send() to find this task - ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) - Question string - CreatedTime time.Time - Deadline time.Time // ~30s, we close the stream here and switch to response_url - StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url - Finished bool // fully done - mu sync.Mutex - answerCh chan string // receives agent reply from Send() - ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine - cancel context.CancelFunc // call on task removal to cancel ctx + StreamID string + ChatID string // used by Send() to find this task + ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) + Question string + CreatedTime time.Time + Deadline time.Time // ~30s, we close the stream here and switch to response_url + StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url + StreamClosedAt time.Time // set when StreamClosed becomes true; used for accelerated cleanup + Finished bool // fully done + mu sync.Mutex + answerCh chan string // receives agent reply from Send() + ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine + cancel context.CancelFunc // call on task removal to cancel ctx } // WeComAIBotMessage represents the decrypted JSON message from WeCom AI Bot @@ -781,6 +782,7 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce // Only mark stream as closed; keep in chatTasks for Send() to find. task.mu.Lock() task.StreamClosed = true + task.StreamClosedAt = time.Now() task.mu.Unlock() // Remove from streamTasks (no more stream polls expected). c.taskMu.Lock() @@ -1117,13 +1119,24 @@ func (c *WeComAIBotChannel) cleanupLoop() { } } -// cleanupOldTasks removes tasks that have been alive longer than 1 hour -// (response_url validity window), which is the absolute maximum lifetime of any task. +// cleanupOldTasks removes tasks that have exceeded their expected lifetime: +// - Active tasks (in streamTasks): cleaned up after 1 hour (response_url validity window). +// - StreamClosed tasks (in chatTasks only): cleaned up after streamClosedGracePeriod. +// These tasks are waiting for the agent to call Send() via response_url. If the agent +// crashes or times out without calling Send(), we must not let them accumulate indefinitely. +// The grace period is generous enough to cover typical LLM latency but far shorter than 1 hour, +// preventing chatTasks from filling up when many requests time out in quick succession. +const ( + streamClosedGracePeriod = 10 * time.Minute // max wait for agent after stream closes + taskMaxLifetime = 1 * time.Hour // absolute max (≈ response_url validity) +) + func (c *WeComAIBotChannel) cleanupOldTasks() { c.taskMu.Lock() defer c.taskMu.Unlock() - cutoff := time.Now().Add(-1 * time.Hour) + now := time.Now() + cutoff := now.Add(-taskMaxLifetime) for id, task := range c.streamTasks { if task.CreatedTime.Before(cutoff) { delete(c.streamTasks, id) @@ -1143,12 +1156,19 @@ func (c *WeComAIBotChannel) cleanupOldTasks() { }) } } - // Also clean up StreamClosed tasks from chatTasks that are older than 1 hour. - // These were removed from streamTasks earlier but kept alive for response_url delivery. + // Clean up StreamClosed tasks from chatTasks. + // Two expiry conditions are checked: + // 1. Absolute expiry: task was created more than taskMaxLifetime ago. + // 2. Grace expiry: stream closed more than streamClosedGracePeriod ago + // (agent had enough time to reply; it is not coming back). for chatID, queue := range c.chatTasks { filtered := queue[:0] for _, t := range queue { - if !t.Finished && t.CreatedTime.After(cutoff) { + absoluteExpired := t.CreatedTime.Before(cutoff) + graceExpired := t.StreamClosed && + !t.StreamClosedAt.IsZero() && + t.StreamClosedAt.Before(now.Add(-streamClosedGracePeriod)) + if !t.Finished && !absoluteExpired && !graceExpired { filtered = append(filtered, t) } else if !t.Finished { t.cancel() // cancel any lingering agent goroutine From 4a87090fd9d95ebfe3597fb895fe3565f37c9871 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 15:50:32 +0800 Subject: [PATCH 10/22] fix(docs): update WeCom AI Bot task timeout duration in README --- README.ja.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.ja.md b/README.ja.md index a6af79eb6..34c034243 100644 --- a/README.ja.md +++ b/README.ja.md @@ -564,7 +564,7 @@ picoclaw gateway picoclaw gateway ``` -> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用 — 返信タイムアウトの心配なし。長時間タスク(>5.5分)は自動的に `response_url` によるプッシュ配信に切り替わります。 +> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用 — 返信タイムアウトの心配なし。長時間タスク(>30秒)は自動的に `response_url` によるプッシュ配信に切り替わります。
diff --git a/README.md b/README.md index e49acb362..046213598 100644 --- a/README.md +++ b/README.md @@ -665,7 +665,7 @@ picoclaw gateway picoclaw gateway ``` -> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>5.5 min) automatically switch to `response_url` push delivery. +> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
From e88b39f21e6bb619b43081591be75a931897efe4 Mon Sep 17 00:00:00 2001 From: ZHANG RUI Date: Sat, 28 Feb 2026 15:54:00 +0800 Subject: [PATCH 11/22] Update pkg/channels/wecom/aibot.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/channels/wecom/aibot.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 4be430626..de1a50c4a 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -928,9 +928,12 @@ func (c *WeComAIBotChannel) encryptResponse( return string(respJSON) } -// encryptEmptyResponse returns empty encrypted response +// encryptEmptyResponse returns a minimal valid encrypted response func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string { - return "" + // Construct a zero-value stream response and encrypt it so that + // WeCom always receives a syntactically valid encrypted JSON object. + emptyResp := WeComAIBotStreamResponse{} + return c.encryptResponse("", timestamp, nonce, emptyResp) } // encryptMessage encrypts a plain text message for WeCom AI Bot From 81f6787dd59eedcc4061045b222880276df60afc Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 16:05:26 +0800 Subject: [PATCH 12/22] fix(docs): update WeCom AI Bot timeout duration in README and improve streamTask comments --- docs/channels/wecom/wecom_aibot/README.zh.md | 4 +- pkg/channels/wecom/aibot.go | 56 +++++++++++--------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md index 200a83a69..8470fe16f 100644 --- a/docs/channels/wecom/wecom_aibot/README.zh.md +++ b/docs/channels/wecom/wecom_aibot/README.zh.md @@ -75,9 +75,9 @@ PicoClaw 立即返回 {finish: false}(Agent 开始处理) └─ Agent 完成 → 返回 {finish: true, content: "回答内容"} ``` -**超时处理**(任务超过 5 分 30 秒): +**超时处理**(任务超过 30 秒): -若 Agent 处理时间超过约 5 分 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会: +若 Agent 处理时间超过约 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会: 1. 立即关闭流,向用户显示「⏳ 正在处理中,请稍候,结果将稍后发送。」 2. Agent 继续在后台运行 diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index de1a50c4a..2962623e1 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -37,21 +37,28 @@ type WeComAIBotChannel struct { taskMu sync.RWMutex } -// streamTask represents a streaming task for AI Bot +// streamTask represents a streaming task for AI Bot. +// +// Mutable fields (Finished, StreamClosed, StreamClosedAt) must be read/written +// while holding WeComAIBotChannel.taskMu. Immutable fields (StreamID, ChatID, +// ResponseURL, Question, CreatedTime, Deadline, answerCh, ctx, cancel) are set +// once at creation and never modified, so they are safe to read without a lock. type streamTask struct { - StreamID string - ChatID string // used by Send() to find this task - ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) - Question string - CreatedTime time.Time - Deadline time.Time // ~30s, we close the stream here and switch to response_url + // immutable after creation + StreamID string + ChatID string // used by Send() to find this task + ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) + Question string + CreatedTime time.Time + Deadline time.Time // ~30s, we close the stream here and switch to response_url + answerCh chan string // receives agent reply from Send() + ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine + cancel context.CancelFunc // call on task removal to cancel ctx + + // mutable — guarded by WeComAIBotChannel.taskMu StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url StreamClosedAt time.Time // set when StreamClosed becomes true; used for accelerated cleanup Finished bool // fully done - mu sync.Mutex - answerCh chan string // receives agent reply from Send() - ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine - cancel context.CancelFunc // call on task removal to cancel ctx } // WeComAIBotMessage represents the decrypted JSON message from WeCom AI Bot @@ -194,8 +201,13 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e } c.chatTasks[msg.ChatID] = queue var task *streamTask + var streamClosed bool + var responseURL string if len(queue) > 0 { task = queue[0] + // Read mutable fields while holding c.taskMu to avoid data races. + streamClosed = task.StreamClosed + responseURL = task.ResponseURL } c.taskMu.Unlock() @@ -210,13 +222,9 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e return nil } - task.mu.Lock() - streamClosed := task.StreamClosed - responseURL := task.ResponseURL - task.mu.Unlock() - if streamClosed { // Stream already ended with a "please wait" notice; send the real reply via response_url. + // Note: task.StreamID and task.ChatID are immutable, safe to read without a lock. logger.InfoCF("wecom_aibot", "Sending reply via response_url", map[string]any{ "stream_id": task.StreamID, "chat_id": msg.ChatID, @@ -779,13 +787,11 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce // Normal finish: remove from all maps. c.removeTask(task) } else if closeStreamOnly { - // Only mark stream as closed; keep in chatTasks for Send() to find. - task.mu.Lock() + // Mark stream as closed and remove from streamTasks under a single lock + // to keep StreamClosed/StreamClosedAt consistent with map membership. + c.taskMu.Lock() task.StreamClosed = true task.StreamClosedAt = time.Now() - task.mu.Unlock() - // Remove from streamTasks (no more stream polls expected). - c.taskMu.Lock() delete(c.streamTasks, task.StreamID) c.taskMu.Unlock() } @@ -816,12 +822,12 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce // removeTask removes a task from both streamTasks and chatTasks, marks it finished, // and cancels its context to interrupt the associated agent goroutine. func (c *WeComAIBotChannel) removeTask(task *streamTask) { - task.mu.Lock() - task.Finished = true - task.mu.Unlock() - task.cancel() // interrupt agent goroutine bound to this task + // Cancel first so the agent goroutine stops as soon as possible, + // before we acquire the write lock. + task.cancel() c.taskMu.Lock() + task.Finished = true // written under c.taskMu, consistent with all readers delete(c.streamTasks, task.StreamID) queue := c.chatTasks[task.ChatID] for i, t := range queue { From 8f3d611a4c56f43be7d76ff65e8d7b82d8fe5e98 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 22:56:55 +0800 Subject: [PATCH 13/22] refactor(wecom): replace generateSignature with computeSignature and update related tests --- pkg/channels/wecom/aibot.go | 25 +++++-------------------- pkg/channels/wecom/aibot_test.go | 14 +++----------- pkg/channels/wecom/common.go | 24 +++++++++++------------- 3 files changed, 19 insertions(+), 44 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 2962623e1..788305e36 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -6,7 +6,6 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" - "crypto/sha1" "encoding/base64" "encoding/binary" "encoding/json" @@ -14,7 +13,6 @@ import ( "io" "math/big" "net/http" - "sort" "strings" "sync" "time" @@ -291,13 +289,14 @@ func (c *WeComAIBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request "query": r.URL.RawQuery, }) - if r.Method == http.MethodGet { + switch r.Method { + case http.MethodGet: // URL verification c.handleVerification(ctx, w, r) - } else if r.Method == http.MethodPost { + case http.MethodPost: // Message callback c.handleMessageCallback(ctx, w, r) - } else { + default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } @@ -909,7 +908,7 @@ func (c *WeComAIBotChannel) encryptResponse( } // Generate signature - signature := c.generateSignature(timestamp, nonce, encrypted) + signature := computeSignature(c.config.Token, timestamp, nonce, encrypted) // Build encrypted response encryptedResp := WeComAIBotEncryptedResponse{ @@ -1010,20 +1009,6 @@ func pkcs7Pad(data []byte, blockSize int) []byte { return append(data, padText...) } -// generateSignature generates message signature using common function -func (c *WeComAIBotChannel) generateSignature(timestamp, nonce, encrypt string) string { - // Sort parameters - params := []string{c.config.Token, timestamp, nonce, encrypt} - sort.Strings(params) - - // Concatenate - str := strings.Join(params, "") - - // SHA1 hash - hash := sha1.Sum([]byte(str)) - return fmt.Sprintf("%x", hash) -} - // generateStreamID generates a random stream ID func (c *WeComAIBotChannel) generateStreamID() string { const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go index 7fb90f22e..6f0664187 100644 --- a/pkg/channels/wecom/aibot_test.go +++ b/pkg/channels/wecom/aibot_test.go @@ -192,27 +192,19 @@ func TestEncryptDecrypt(t *testing.T) { } func TestGenerateSignature(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "testkey1234567890123456789012345678901234567", - } - - messageBus := bus.NewMessageBus() - ch, _ := NewWeComAIBotChannel(cfg, messageBus) - + token := "test_token" timestamp := "1234567890" nonce := "test_nonce" encrypt := "encrypted_msg" - signature := ch.generateSignature(timestamp, nonce, encrypt) + signature := computeSignature(token, timestamp, nonce, encrypt) if signature == "" { t.Error("Generated signature is empty") } // Verify signature using verifySignature function - if !verifySignature(cfg.Token, signature, timestamp, nonce, encrypt) { + if !verifySignature(token, signature, timestamp, nonce, encrypt) { t.Error("Generated signature does not verify correctly") } } diff --git a/pkg/channels/wecom/common.go b/pkg/channels/wecom/common.go index 39a27d04c..b1b5399f4 100644 --- a/pkg/channels/wecom/common.go +++ b/pkg/channels/wecom/common.go @@ -14,25 +14,23 @@ import ( // blockSize is the PKCS7 block size used by WeCom (32) const blockSize = 32 +// computeSignature computes the WeCom message signature from the given parameters. +// It sorts [token, timestamp, nonce, encrypt], concatenates them and returns the SHA1 hex digest. +func computeSignature(token, timestamp, nonce, encrypt string) string { + params := []string{token, timestamp, nonce, encrypt} + sort.Strings(params) + str := strings.Join(params, "") + hash := sha1.Sum([]byte(str)) + return fmt.Sprintf("%x", hash) +} + // verifySignature verifies the message signature for WeCom // This is a common function used by both WeCom Bot and WeCom App func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { if token == "" { return true // Skip verification if token is not set } - - // Sort parameters - params := []string{token, timestamp, nonce, msgEncrypt} - sort.Strings(params) - - // Concatenate - str := strings.Join(params, "") - - // SHA1 hash - hash := sha1.Sum([]byte(str)) - expectedSignature := fmt.Sprintf("%x", hash) - - return expectedSignature == msgSignature + return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature } // decryptMessage decrypts the encrypted message using AES From 880c402ab7025fd2a65bda487486c854c52647f5 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 23:14:10 +0800 Subject: [PATCH 14/22] refactor(wecom): streamline AES encryption/decryption and improve task management logic --- pkg/channels/wecom/aibot.go | 166 +++++++++-------------------------- pkg/channels/wecom/common.go | 135 +++++++++++++++++++++------- 2 files changed, 144 insertions(+), 157 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 788305e36..9003b0777 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -3,11 +3,8 @@ package wecom import ( "bytes" "context" - "crypto/aes" - "crypto/cipher" "crypto/rand" "encoding/base64" - "encoding/binary" "encoding/json" "fmt" "io" @@ -194,6 +191,12 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e } c.taskMu.Lock() queue := c.chatTasks[msg.ChatID] + // Only compact Finished tasks at the head of the queue. + // Tasks that are Finished in the middle are NOT removed here: doing a full + // scan on every Send() call would be O(n) and is unnecessary given that + // removeTask() always splices the task out of the queue immediately. + // Any Finished task left stranded in the middle (e.g. due to an unexpected + // code path) will be collected by cleanupOldTasks. for len(queue) > 0 && queue[0].Finished { queue = queue[1:] } @@ -620,41 +623,6 @@ func (c *WeComAIBotChannel) handleImageMessage( imageURL := msg.Image.URL - // Download and decrypt image - _, err := c.downloadAndDecryptImage(ctx, imageURL) - if err != nil { - logger.ErrorCF("wecom_aibot", "Failed to process image", map[string]any{ - "error": err, - "url": imageURL, - }) - return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ - MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ - ID: c.generateStreamID(), - Finish: true, - Content: fmt.Sprintf( - "Image received (URL: %s), but image messages are not yet supported", - imageURL, - ), - }, - }) - } - - // Echo back the image (simple demo behavior) - // streamID := c.generateStreamID() - // return c.encryptImageResponse(streamID, timestamp, nonce, imageData) - // For now, just acknowledge receipt without echoing the image return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", @@ -943,70 +911,24 @@ func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string // encryptMessage encrypts a plain text message for WeCom AI Bot func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) { - // Decode AES key - aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=") + aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey) if err != nil { - return "", fmt.Errorf("failed to decode AES key: %w", err) + return "", err } - if len(aesKey) != 32 { - return "", fmt.Errorf("invalid AES key length: %d", len(aesKey)) - } - - // Generate 16-byte random string - randomBytes := make([]byte, 16) - for i := range 16 { - n, randErr := rand.Int(rand.Reader, big.NewInt(10)) - if randErr != nil { - return "", fmt.Errorf("failed to generate random: %w", randErr) - } - randomBytes[i] = byte('0' + n.Int64()) - } - - // Build message: random(16) + msg_len(4) + msg + receiveid - plaintextBytes := []byte(plaintext) - receiveidBytes := []byte(receiveid) - - msgLen := uint32(len(plaintextBytes)) - msgLenBytes := make([]byte, 4) - binary.BigEndian.PutUint32(msgLenBytes, msgLen) - - // Concatenate - var buffer bytes.Buffer - buffer.Write(randomBytes) - buffer.Write(msgLenBytes) - buffer.Write(plaintextBytes) - buffer.Write(receiveidBytes) - - // PKCS7 padding - plainData := buffer.Bytes() - plainData = pkcs7Pad(plainData, blockSize) - - // AES-CBC encrypt - block, err := aes.NewCipher(aesKey) + frame, err := packWeComFrame(plaintext, receiveid) if err != nil { - return "", fmt.Errorf("failed to create cipher: %w", err) + return "", err } - ciphertext := make([]byte, len(plainData)) - iv := aesKey[:aes.BlockSize] - mode := cipher.NewCBCEncrypter(block, iv) - mode.CryptBlocks(ciphertext, plainData) - - // Base64 encode - encoded := base64.StdEncoding.EncodeToString(ciphertext) - - return encoded, nil -} - -// pkcs7Pad adds PKCS7 padding -func pkcs7Pad(data []byte, blockSize int) []byte { - padding := blockSize - (len(data) % blockSize) - if padding == 0 { - padding = blockSize + // PKCS7 padding then AES-CBC encrypt + paddedFrame := pkcs7Pad(frame, blockSize) + ciphertext, err := encryptAESCBC(aesKey, paddedFrame) + if err != nil { + return "", err } - padText := bytes.Repeat([]byte{byte(padding)}, padding) - return append(data, padText...) + + return base64.StdEncoding.EncodeToString(ciphertext), nil } // generateStreamID generates a random stream ID @@ -1060,35 +982,15 @@ func (c *WeComAIBotChannel) downloadAndDecryptImage( }) // Decode AES key - aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=") + aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey) if err != nil { - return nil, fmt.Errorf("failed to decode AES key: %w", err) + return nil, err } - if len(aesKey) != 32 { - return nil, fmt.Errorf("invalid AES key length: %d", len(aesKey)) - } - - // Decrypt image (AES-CBC) - block, err := aes.NewCipher(aesKey) + // Decrypt image (AES-CBC with IV = first 16 bytes of key, PKCS7 padding stripped) + decryptedData, err := decryptAESCBC(aesKey, encryptedData) if err != nil { - return nil, fmt.Errorf("failed to create cipher: %w", err) - } - - if len(encryptedData)%aes.BlockSize != 0 { - return nil, fmt.Errorf("encrypted data size not multiple of block size") - } - - iv := aesKey[:aes.BlockSize] - mode := cipher.NewCBCDecrypter(block, iv) - - decryptedData := make([]byte, len(encryptedData)) - mode.CryptBlocks(decryptedData, encryptedData) - - // Remove PKCS7 padding - decryptedData, err = pkcs7Unpad(decryptedData) - if err != nil { - return nil, fmt.Errorf("failed to unpad: %w", err) + return nil, fmt.Errorf("failed to decrypt image: %w", err) } logger.DebugCF("wecom_aibot", "Image decrypted", map[string]any{ @@ -1157,14 +1059,32 @@ func (c *WeComAIBotChannel) cleanupOldTasks() { // (agent had enough time to reply; it is not coming back). for chatID, queue := range c.chatTasks { filtered := queue[:0] - for _, t := range queue { + for i, t := range queue { absoluteExpired := t.CreatedTime.Before(cutoff) graceExpired := t.StreamClosed && !t.StreamClosedAt.IsZero() && t.StreamClosedAt.Before(now.Add(-streamClosedGracePeriod)) - if !t.Finished && !absoluteExpired && !graceExpired { + if t.Finished { + // Finished tasks should have been removed by removeTask(). + // Finding one here (especially not at position 0) means an + // unexpected code path left it stranded, causing the queue to + // grow silently. Log a warning so it is visible, then drop it. + if i > 0 { + logger.WarnCF("wecom_aibot", + "Found stranded Finished task in the middle of chatTasks queue; "+ + "this should not happen — removeTask() should have spliced it out", + map[string]any{ + "chat_id": chatID, + "stream_id": t.StreamID, + "position": i, + }) + } + // The task is already finished; its context was already canceled + // by removeTask(), so no further action is required. + continue + } else if !absoluteExpired && !graceExpired { filtered = append(filtered, t) - } else if !t.Finished { + } else { t.cancel() // cancel any lingering agent goroutine } } diff --git a/pkg/channels/wecom/common.go b/pkg/channels/wecom/common.go index b1b5399f4..6510e6f81 100644 --- a/pkg/channels/wecom/common.go +++ b/pkg/channels/wecom/common.go @@ -1,12 +1,15 @@ package wecom import ( + "bytes" "crypto/aes" "crypto/cipher" + "crypto/rand" "crypto/sha1" "encoding/base64" "encoding/binary" "fmt" + "math/big" "sort" "strings" ) @@ -51,64 +54,128 @@ func decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (s return string(decoded), nil } - // Decode AES key (base64) - aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") + aesKey, err := decodeWeComAESKey(encodingAESKey) if err != nil { - return "", fmt.Errorf("failed to decode AES key: %w", err) + return "", err } - // Decode encrypted message cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg) if err != nil { return "", fmt.Errorf("failed to decode message: %w", err) } - // AES decrypt + plainText, err := decryptAESCBC(aesKey, cipherText) + if err != nil { + return "", err + } + + return unpackWeComFrame(plainText, receiveid) +} + +// decodeWeComAESKey base64-decodes the 43-character EncodingAESKey (trailing "=" is +// appended automatically) and validates that the result is exactly 32 bytes. +// It is the single place that handles this repeated pattern in both encrypt and decrypt paths. +func decodeWeComAESKey(encodingAESKey string) ([]byte, error) { + aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") + if err != nil { + return nil, fmt.Errorf("failed to decode AES key: %w", err) + } + if len(aesKey) != 32 { + return nil, fmt.Errorf("invalid AES key length: %d", len(aesKey)) + } + return aesKey, nil +} + +// encryptAESCBC encrypts plaintext using AES-CBC with the given key, mirroring +// decryptAESCBC. IV = aesKey[:aes.BlockSize]. The caller must PKCS7-pad the +// plaintext to a multiple of aes.BlockSize before calling. +func encryptAESCBC(aesKey, plaintext []byte) ([]byte, error) { block, err := aes.NewCipher(aesKey) if err != nil { - return "", fmt.Errorf("failed to create cipher: %w", err) + return nil, fmt.Errorf("failed to create cipher: %w", err) } - - if len(cipherText) < aes.BlockSize { - return "", fmt.Errorf("ciphertext too short") - } - - // IV is the first 16 bytes of AESKey iv := aesKey[:aes.BlockSize] - mode := cipher.NewCBCDecrypter(block, iv) - plainText := make([]byte, len(cipherText)) - mode.CryptBlocks(plainText, cipherText) + ciphertext := make([]byte, len(plaintext)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plaintext) + return ciphertext, nil +} - // Remove PKCS7 padding - plainText, err = pkcs7Unpad(plainText) - if err != nil { - return "", fmt.Errorf("failed to unpad: %w", err) +// packWeComFrame builds the WeCom wire format: +// +// random(16 ASCII digits) + msg_len(4, big-endian) + msg + receiveid +func packWeComFrame(msg, receiveid string) ([]byte, error) { + randomBytes := make([]byte, 16) + for i := range 16 { + n, err := rand.Int(rand.Reader, big.NewInt(10)) + if err != nil { + return nil, fmt.Errorf("failed to generate random: %w", err) + } + randomBytes[i] = byte('0' + n.Int64()) } + msgBytes := []byte(msg) + msgLenBytes := make([]byte, 4) + binary.BigEndian.PutUint32(msgLenBytes, uint32(len(msgBytes))) + var buf bytes.Buffer + buf.Write(randomBytes) + buf.Write(msgLenBytes) + buf.Write(msgBytes) + buf.WriteString(receiveid) + return buf.Bytes(), nil +} - // Parse message structure - // Format: random(16) + msg_len(4) + msg + receiveid - if len(plainText) < 20 { - return "", fmt.Errorf("decrypted message too short") +// unpackWeComFrame parses the WeCom wire format produced by packWeComFrame. +// If receiveid is non-empty it verifies the frame's trailing receiveid field. +func unpackWeComFrame(data []byte, receiveid string) (string, error) { + if len(data) < 20 { + return "", fmt.Errorf("decrypted frame too short: %d bytes", len(data)) } - - msgLen := binary.BigEndian.Uint32(plainText[16:20]) - if int(msgLen) > len(plainText)-20 { - return "", fmt.Errorf("invalid message length") + msgLen := binary.BigEndian.Uint32(data[16:20]) + if int(msgLen) > len(data)-20 { + return "", fmt.Errorf("invalid message length: %d", msgLen) } - - msg := plainText[20 : 20+msgLen] - - // Verify receiveid if provided - if receiveid != "" && len(plainText) > 20+int(msgLen) { - actualReceiveID := string(plainText[20+msgLen:]) + msg := data[20 : 20+msgLen] + if receiveid != "" && len(data) > 20+int(msgLen) { + actualReceiveID := string(data[20+msgLen:]) if actualReceiveID != receiveid { return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID) } } - return string(msg), nil } +// decryptAESCBC decrypts ciphertext using AES-CBC with the given key. +// IV = aesKey[:aes.BlockSize]. PKCS7 padding is stripped from the returned plaintext. +func decryptAESCBC(aesKey, ciphertext []byte) ([]byte, error) { + if len(ciphertext) == 0 { + return nil, fmt.Errorf("ciphertext is empty") + } + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size", len(ciphertext)) + } + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, fmt.Errorf("failed to create cipher: %w", err) + } + iv := aesKey[:aes.BlockSize] + plaintext := make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(plaintext, ciphertext) + plaintext, err = pkcs7Unpad(plaintext) + if err != nil { + return nil, fmt.Errorf("failed to unpad: %w", err) + } + return plaintext, nil +} + +// pkcs7Pad adds PKCS7 padding +func pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - (len(data) % blockSize) + if padding == 0 { + padding = blockSize + } + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padText...) +} + // pkcs7Unpad removes PKCS7 padding with validation func pkcs7Unpad(data []byte) ([]byte, error) { if len(data) == 0 { From 79bc06c0ba7caac36a872a5667185da7b5d8f92e Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 23:35:38 +0800 Subject: [PATCH 15/22] refactor(wecom): simplify stream message structure by introducing WeComAIBotMsgItem and WeComAIBotMsgItemImage types --- pkg/channels/wecom/aibot.go | 106 ++++++++---------------------------- 1 file changed, 23 insertions(+), 83 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 9003b0777..de56e7a75 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -98,18 +98,24 @@ type WeComAIBotMessage struct { } `json:"event,omitempty"` } -// WeComAIBotStreamInfo represents the detailed stream content in streaming responses +// WeComAIBotMsgItemImage holds the image payload inside a stream message item. +type WeComAIBotMsgItemImage struct { + Base64 string `json:"base64"` + MD5 string `json:"md5"` +} + +// WeComAIBotMsgItem is a single item inside a stream's msg_item list. +type WeComAIBotMsgItem struct { + MsgType string `json:"msgtype"` + Image *WeComAIBotMsgItemImage `json:"image,omitempty"` +} + +// WeComAIBotStreamInfo represents the detailed stream content in streaming responses. type WeComAIBotStreamInfo struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` + MsgItem []WeComAIBotMsgItem `json:"msg_item,omitempty"` } // WeComAIBotStreamResponse represents the streaming response format @@ -457,18 +463,7 @@ func (c *WeComAIBotChannel) processMessage( }) return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ + Stream: WeComAIBotStreamInfo{ ID: c.generateStreamID(), Finish: true, Content: "Unsupported message type: " + msg.MsgType, @@ -586,18 +581,7 @@ func (c *WeComAIBotChannel) handleStreamMessage( ) return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ + Stream: WeComAIBotStreamInfo{ ID: streamID, Finish: true, Content: "Task not found or already finished. Please resend your message to start a new session.", @@ -626,18 +610,7 @@ func (c *WeComAIBotChannel) handleImageMessage( // For now, just acknowledge receipt without echoing the image return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ + Stream: WeComAIBotStreamInfo{ ID: c.generateStreamID(), Finish: true, Content: fmt.Sprintf( @@ -657,18 +630,7 @@ func (c *WeComAIBotChannel) handleMixedMessage( logger.WarnC("wecom_aibot", "Mixed message type not yet fully implemented") return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ + Stream: WeComAIBotStreamInfo{ ID: c.generateStreamID(), Finish: true, Content: "Mixed message type is not yet supported", @@ -695,18 +657,7 @@ func (c *WeComAIBotChannel) handleEventMessage( streamID := c.generateStreamID() return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{ MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ + Stream: WeComAIBotStreamInfo{ ID: streamID, Finish: true, Content: c.config.WelcomeMessage, @@ -765,18 +716,7 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce response := WeComAIBotStreamResponse{ MsgType: "stream", - Stream: struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []struct { - MsgType string `json:"msgtype"` - Image *struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` - } `json:"image,omitempty"` - } `json:"msg_item,omitempty"` - }{ + Stream: WeComAIBotStreamInfo{ ID: task.StreamID, Finish: finish, Content: content, From 79b7fb7792fd8cce890c273a25b7c2a79735cb7b Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 23:43:33 +0800 Subject: [PATCH 16/22] fix(wecom): improve error handling in sendViaResponseURL and remove task on failure --- pkg/channels/wecom/aibot.go | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index de56e7a75..4bf29479c 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -242,6 +242,8 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e "error": err, "stream_id": task.StreamID, }) + c.removeTask(task) + return err } } else { logger.WarnCF("wecom_aibot", "Stream closed but no response_url available", map[string]any{ @@ -751,6 +753,8 @@ func (c *WeComAIBotChannel) removeTask(task *streamTask) { // sendViaResponseURL posts a markdown reply to the WeCom response_url. // response_url is valid for 1 hour and can only be used once per callback. +// Returned errors are wrapped with channels.ErrRateLimit, channels.ErrTemporary, +// or channels.ErrSendFailed so the manager can apply the right retry policy. func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) error { payload := map[string]any{ "msgtype": "markdown", @@ -775,15 +779,26 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to post to response_url: %w", err) + return fmt.Errorf("post to response_url failed: %w: %w", channels.ErrTemporary, err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("response_url returned %d: %s", resp.StatusCode, string(respBody)) + if resp.StatusCode == http.StatusOK { + return nil + } + + respBody, _ := io.ReadAll(resp.Body) + switch { + case resp.StatusCode == http.StatusTooManyRequests: + return fmt.Errorf("response_url rate limited (%d): %s: %w", + resp.StatusCode, respBody, channels.ErrRateLimit) + case resp.StatusCode >= 500: + return fmt.Errorf("response_url server error (%d): %s: %w", + resp.StatusCode, respBody, channels.ErrTemporary) + default: + return fmt.Errorf("response_url returned %d: %s: %w", + resp.StatusCode, respBody, channels.ErrSendFailed) } - return nil } // encryptResponse encrypts a streaming response From 55c556a4c5cb9c57b2af54c4d4133563bc0b89a7 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 23:46:04 +0800 Subject: [PATCH 17/22] fix(wecom): update CanonicalID generation to use identity.BuildCanonicalID for consistency --- pkg/channels/wecom/aibot.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 4bf29479c..27c118675 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -17,6 +17,7 @@ import ( "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" ) @@ -531,7 +532,7 @@ func (c *WeComAIBotChannel) handleTextMessage( sender := bus.SenderInfo{ Platform: "wecom_aibot", PlatformID: userID, - CanonicalID: "wecom_aibot:" + userID, + CanonicalID: identity.BuildCanonicalID("wecom_aibot", userID), DisplayName: userID, } peerKind := "direct" From d4824a00b6756c0bb67be670014d3cfb091fc5ef Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 23:50:18 +0800 Subject: [PATCH 18/22] refactor(config): remove WebhookHost and WebhookPort from WeComAIBotConfig --- pkg/config/config.go | 2 -- pkg/config/defaults.go | 2 -- 2 files changed, 4 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 439c2b995..51e55a99a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -365,8 +365,6 @@ type WeComAIBotConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PORT"` WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index fce955c83..fb0fd4451 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -141,8 +141,6 @@ func DefaultConfig() *Config { Enabled: false, Token: "", EncodingAESKey: "", - WebhookHost: "0.0.0.0", - WebhookPort: 18791, WebhookPath: "/webhook/wecom-aibot", AllowFrom: FlexibleStringSlice{}, ReplyTimeout: 5, From bf4445f1f333194c4b8381752f13fbef273efd9e Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sat, 28 Feb 2026 23:52:56 +0800 Subject: [PATCH 19/22] refactor(docs): remove webhook_host and webhook_port from configuration examples --- README.fr.md | 2 -- README.ja.md | 2 -- README.md | 2 -- README.pt-br.md | 2 -- README.vi.md | 2 -- config/config.example.json | 2 -- docs/channels/wecom/wecom_aibot/README.zh.md | 4 ---- 7 files changed, 16 deletions(-) diff --git a/README.fr.md b/README.fr.md index 43a6cab7a..e537fc13a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -581,8 +581,6 @@ picoclaw gateway "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "Bonjour ! Comment puis-je vous aider ?" diff --git a/README.ja.md b/README.ja.md index 34c034243..20ad5033b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -548,8 +548,6 @@ picoclaw gateway "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "こんにちは!何かお手伝いできますか?" diff --git a/README.md b/README.md index 046213598..a06f2ea61 100644 --- a/README.md +++ b/README.md @@ -649,8 +649,6 @@ picoclaw gateway "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "Hello! How can I help you?" diff --git a/README.pt-br.md b/README.pt-br.md index c37fb929b..bfe655770 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -577,8 +577,6 @@ picoclaw gateway "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "Olá! Como posso ajudá-lo?" diff --git a/README.vi.md b/README.vi.md index 417ca0393..b30659614 100644 --- a/README.vi.md +++ b/README.vi.md @@ -549,8 +549,6 @@ picoclaw gateway "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "Xin chào! Tôi có thể giúp gì cho bạn?" diff --git a/config/config.example.json b/config/config.example.json index 872358bd4..e292731b9 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -155,8 +155,6 @@ "enabled": false, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "max_steps": 10, "welcome_message": "Hello! I'm your AI assistant. How can I help you today?", diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md index 8470fe16f..d210528af 100644 --- a/docs/channels/wecom/wecom_aibot/README.zh.md +++ b/docs/channels/wecom/wecom_aibot/README.zh.md @@ -21,8 +21,6 @@ "enabled": true, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/wecom-aibot", "allow_from": [], "welcome_message": "你好!有什么可以帮助你的吗?", @@ -36,8 +34,6 @@ | ---------------- | ------ | ---- | -------------------------------------------------- | | token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 | | encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 | -| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) | -| webhook_port | int | 否 | HTTP 服务器端口(默认:18791) | | webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-aibot) | | allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 | | welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 | From 619948f8ff59a30c4c8ef91bd3ece7d7bc051b80 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sun, 1 Mar 2026 00:00:14 +0800 Subject: [PATCH 20/22] fix(wecom): improve error message for response_url delivery failure --- pkg/channels/wecom/aibot.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 27c118675..e9eda6810 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -244,7 +244,7 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e "stream_id": task.StreamID, }) c.removeTask(task) - return err + return fmt.Errorf("response_url delivery failed: %w", channels.ErrSendFailed) } } else { logger.WarnCF("wecom_aibot", "Stream closed but no response_url available", map[string]any{ From edd339e05603d187db0e7bad65462b393aa3d660 Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Sun, 1 Mar 2026 00:02:22 +0800 Subject: [PATCH 21/22] fix(wecom): handle empty response by encrypting and returning a default response --- pkg/channels/wecom/aibot.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index e9eda6810..4bd8adb98 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -433,6 +433,11 @@ func (c *WeComAIBotChannel) handleMessageCallback( // Process the message and get streaming response response := c.processMessage(ctx, msg, timestamp, nonce) + // Check if response is empty (e.g. due to unsupported message type) + if response == "" { + response = c.encryptEmptyResponse(timestamp, nonce) + } + // Return encrypted JSON response w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) From 23f48d7c4e76c3b4bda281b5f2bc4bce3af364ee Mon Sep 17 00:00:00 2001 From: Zhang Rui Date: Mon, 2 Mar 2026 18:21:53 +0800 Subject: [PATCH 22/22] refactor(aibot): remove downloadAndDecryptImage function to streamline image handling --- pkg/channels/wecom/aibot.go | 58 ------------------------------------- 1 file changed, 58 deletions(-) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 4bd8adb98..6c5aca40b 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -903,64 +903,6 @@ func (c *WeComAIBotChannel) generateStreamID() string { return string(b) } -// downloadAndDecryptImage downloads and decrypts an encrypted image -func (c *WeComAIBotChannel) downloadAndDecryptImage( - ctx context.Context, - imageURL string, -) ([]byte, error) { - // Download image - req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - client := &http.Client{ - Timeout: 15 * time.Second, - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to download image: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode) - } - - // Limit image download to 20 MB to prevent memory exhaustion - const maxImageSize = 20 << 20 // 20 MB - encryptedData, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize+1)) - if err != nil { - return nil, fmt.Errorf("failed to read image data: %w", err) - } - if len(encryptedData) > maxImageSize { - return nil, fmt.Errorf("image too large (exceeds %d MB)", maxImageSize>>20) - } - - logger.DebugCF("wecom_aibot", "Image downloaded", map[string]any{ - "size": len(encryptedData), - }) - - // Decode AES key - aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey) - if err != nil { - return nil, err - } - - // Decrypt image (AES-CBC with IV = first 16 bytes of key, PKCS7 padding stripped) - decryptedData, err := decryptAESCBC(aesKey, encryptedData) - if err != nil { - return nil, fmt.Errorf("failed to decrypt image: %w", err) - } - - logger.DebugCF("wecom_aibot", "Image decrypted", map[string]any{ - "size": len(decryptedData), - }) - - return decryptedData, nil -} - // cleanupLoop periodically cleans up old streaming tasks func (c *WeComAIBotChannel) cleanupLoop() { ticker := time.NewTicker(5 * time.Minute)