package wecom import ( "bytes" "context" "encoding/json" "encoding/xml" "fmt" "io" "mime/multipart" "net/http" "net/url" "os" "path/filepath" "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/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) const ( wecomAPIBase = "https://qyapi.weixin.qq.com" ) // WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用) type WeComAppChannel struct { *channels.BaseChannel config config.WeComAppConfig client *http.Client accessToken string tokenExpiry time.Time tokenMu sync.RWMutex ctx context.Context cancel context.CancelFunc processedMsgs *MessageDeduplicator } // WeComXMLMessage represents the XML message structure from WeCom type WeComXMLMessage struct { XMLName xml.Name `xml:"xml"` ToUserName string `xml:"ToUserName"` FromUserName string `xml:"FromUserName"` CreateTime int64 `xml:"CreateTime"` MsgType string `xml:"MsgType"` Content string `xml:"Content"` MsgId int64 `xml:"MsgId"` AgentID int64 `xml:"AgentID"` PicUrl string `xml:"PicUrl"` MediaId string `xml:"MediaId"` Format string `xml:"Format"` ThumbMediaId string `xml:"ThumbMediaId"` LocationX float64 `xml:"Location_X"` LocationY float64 `xml:"Location_Y"` Scale int `xml:"Scale"` Label string `xml:"Label"` Title string `xml:"Title"` Description string `xml:"Description"` Url string `xml:"Url"` Event string `xml:"Event"` EventKey string `xml:"EventKey"` } // WeComTextMessage represents text message for sending type WeComTextMessage struct { ToUser string `json:"touser"` MsgType string `json:"msgtype"` AgentID int64 `json:"agentid"` Text struct { Content string `json:"content"` } `json:"text"` Safe int `json:"safe,omitempty"` } // WeComMarkdownMessage represents markdown message for sending type WeComMarkdownMessage struct { ToUser string `json:"touser"` MsgType string `json:"msgtype"` AgentID int64 `json:"agentid"` Markdown struct { Content string `json:"content"` } `json:"markdown"` } // WeComImageMessage represents image message for sending type WeComImageMessage struct { ToUser string `json:"touser"` MsgType string `json:"msgtype"` AgentID int64 `json:"agentid"` Image struct { MediaID string `json:"media_id"` } `json:"image"` } // WeComAccessTokenResponse represents the access token API response type WeComAccessTokenResponse struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } // WeComSendMessageResponse represents the send message API response type WeComSendMessageResponse struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` InvalidUser string `json:"invaliduser"` InvalidParty string `json:"invalidparty"` InvalidTag string `json:"invalidtag"` } // PKCS7Padding adds PKCS7 padding type PKCS7Padding struct{} // NewWeComAppChannel creates a new WeCom App channel instance func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) { if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 { return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required") } base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048), channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) // Client timeout must be >= the configured ReplyTimeout so the // per-request context deadline is always the effective limit. clientTimeout := 30 * time.Second if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout { clientTimeout = d } ctx, cancel := context.WithCancel(context.Background()) return &WeComAppChannel{ BaseChannel: base, config: cfg, client: &http.Client{Timeout: clientTimeout}, ctx: ctx, cancel: cancel, processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages), }, nil } // Name returns the channel name func (c *WeComAppChannel) Name() string { return "wecom_app" } // Start initializes the WeCom App channel func (c *WeComAppChannel) Start(ctx context.Context) error { logger.InfoC("wecom_app", "Starting WeCom App channel...") // Cancel the context created in the constructor to avoid a resource leak. if c.cancel != nil { c.cancel() } c.ctx, c.cancel = context.WithCancel(ctx) // Get initial access token if err := c.refreshAccessToken(); err != nil { logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]any{ "error": err.Error(), }) } // Start token refresh goroutine go c.tokenRefreshLoop() c.SetRunning(true) logger.InfoC("wecom_app", "WeCom App channel started") return nil } // Stop gracefully stops the WeCom App channel func (c *WeComAppChannel) Stop(ctx context.Context) error { logger.InfoC("wecom_app", "Stopping WeCom App channel...") if c.cancel != nil { c.cancel() } c.SetRunning(false) logger.InfoC("wecom_app", "WeCom App channel stopped") return nil } // Send sends a message to WeCom user proactively using access token func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } accessToken := c.getAccessToken() if accessToken == "" { return fmt.Errorf("no valid access token available") } logger.DebugCF("wecom_app", "Sending message", map[string]any{ "chat_id": msg.ChatID, "preview": utils.Truncate(msg.Content, 100), }) return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content) } // SendMedia implements the channels.MediaSender interface. func (c *WeComAppChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } accessToken := c.getAccessToken() if accessToken == "" { return fmt.Errorf("no valid access token available: %w", channels.ErrTemporary) } store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { logger.ErrorCF("wecom_app", "Failed to resolve media ref", map[string]any{ "ref": part.Ref, "error": err.Error(), }) continue } // Map part type to WeCom media type var mediaType string switch part.Type { case "image": mediaType = "image" case "audio": mediaType = "voice" case "video": mediaType = "video" default: mediaType = "file" } // Upload media to get media_id mediaID, err := c.uploadMedia(ctx, accessToken, mediaType, localPath) if err != nil { logger.ErrorCF("wecom_app", "Failed to upload media", map[string]any{ "type": mediaType, "error": err.Error(), }) // Fallback: send caption as text if part.Caption != "" { _ = c.sendTextMessage(ctx, accessToken, msg.ChatID, part.Caption) } continue } // Send media message using the media_id if mediaType == "image" { err = c.sendImageMessage(ctx, accessToken, msg.ChatID, mediaID) } else { // For non-image types, send as text fallback with caption caption := part.Caption if caption == "" { caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename) } err = c.sendTextMessage(ctx, accessToken, msg.ChatID, caption) } if err != nil { return err } } return nil } // uploadMedia uploads a local file to WeCom temporary media storage. func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaType, localPath string) (string, error) { apiURL := fmt.Sprintf("%s/cgi-bin/media/upload?access_token=%s&type=%s", wecomAPIBase, url.QueryEscape(accessToken), url.QueryEscape(mediaType)) file, err := os.Open(localPath) if err != nil { return "", fmt.Errorf("failed to open file: %w", err) } defer file.Close() body := &bytes.Buffer{} writer := multipart.NewWriter(body) filename := filepath.Base(localPath) formFile, err := writer.CreateFormFile("media", filename) if err != nil { return "", fmt.Errorf("failed to create form file: %w", err) } if _, err = io.Copy(formFile, file); err != nil { return "", fmt.Errorf("failed to copy file content: %w", err) } writer.Close() req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := c.client.Do(req) if err != nil { return "", channels.ClassifyNetError(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { return "", channels.ClassifySendError( resp.StatusCode, fmt.Errorf("reading wecom upload error response: %w", readErr), ) } return "", channels.ClassifySendError( resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody)), ) } var result struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` MediaID string `json:"media_id"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("failed to parse upload response: %w", err) } if result.ErrCode != 0 { return "", fmt.Errorf("upload API error: %s (code: %d)", result.ErrMsg, result.ErrCode) } return result.MediaID, nil } // sendWeComMessage marshals payload and POSTs it to the WeCom message API. func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken string, payload any) error { apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken) jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal message: %w", err) } timeout := c.config.ReplyTimeout if timeout <= 0 { timeout = 5 } reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { return channels.ClassifyNetError(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { return channels.ClassifySendError( resp.StatusCode, fmt.Errorf("reading wecom_app error response: %w", readErr), ) } return channels.ClassifySendError( resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody)), ) } respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %w", err) } var sendResp WeComSendMessageResponse if err := json.Unmarshal(respBody, &sendResp); err != nil { return fmt.Errorf("failed to parse response: %w", err) } if sendResp.ErrCode != 0 { return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode) } return nil } // sendImageMessage sends an image message using a media_id. func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error { msg := WeComImageMessage{ ToUser: userID, MsgType: "image", AgentID: c.config.AgentID, } msg.Image.MediaID = mediaID return c.sendWeComMessage(ctx, accessToken, msg) } // WebhookPath returns the path for registering on the shared HTTP server. func (c *WeComAppChannel) WebhookPath() string { if c.config.WebhookPath != "" { return c.config.WebhookPath } return "/webhook/wecom-app" } // ServeHTTP implements http.Handler for the shared HTTP server. func (c *WeComAppChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.handleWebhook(w, r) } // HealthPath returns the health check endpoint path. func (c *WeComAppChannel) HealthPath() string { return "/health/wecom-app" } // HealthHandler handles health check requests. func (c *WeComAppChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { c.handleHealth(w, r) } // handleWebhook handles incoming webhook requests from WeCom func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Log all incoming requests for debugging logger.DebugCF("wecom_app", "Received webhook request", map[string]any{ "method": r.Method, "url": r.URL.String(), "path": r.URL.Path, "query": r.URL.RawQuery, }) if r.Method == http.MethodGet { // Handle verification request c.handleVerification(ctx, w, r) return } if r.Method == http.MethodPost { // Handle message callback c.handleMessageCallback(ctx, w, r) return } logger.WarnCF("wecom_app", "Method not allowed", map[string]any{ "method": r.Method, }) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } // handleVerification handles the URL verification request from WeCom func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) { query := r.URL.Query() msgSignature := query.Get("msg_signature") timestamp := query.Get("timestamp") nonce := query.Get("nonce") echostr := query.Get("echostr") logger.DebugCF("wecom_app", "Handling verification request", map[string]any{ "msg_signature": msgSignature, "timestamp": timestamp, "nonce": nonce, "echostr": echostr, "corp_id": c.config.CorpID, }) if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" { logger.ErrorC("wecom_app", "Missing parameters in verification request") http.Error(w, "Missing parameters", http.StatusBadRequest) return } // Verify signature if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{ "token": c.config.Token, "msg_signature": msgSignature, "timestamp": timestamp, "nonce": nonce, }) http.Error(w, "Invalid signature", http.StatusForbidden) return } logger.DebugC("wecom_app", "Signature verification passed") // Decrypt echostr with CorpID verification // For WeCom App (自建应用), receiveid should be corp_id logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{ "encoding_aes_key": c.config.EncodingAESKey, "corp_id": c.config.CorpID, }) decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID) if err != nil { logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{ "error": err.Error(), "encoding_aes_key": c.config.EncodingAESKey, "corp_id": c.config.CorpID, }) http.Error(w, "Decryption failed", http.StatusInternalServerError) return } logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{ "decrypted": decryptedEchoStr, }) // Remove BOM and whitespace as per WeCom documentation // The response must be plain text without quotes, BOM, or newlines decryptedEchoStr = strings.TrimSpace(decryptedEchoStr) decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM w.Write([]byte(decryptedEchoStr)) } // handleMessageCallback handles incoming messages from WeCom func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) { query := r.URL.Query() msgSignature := query.Get("msg_signature") timestamp := query.Get("timestamp") nonce := query.Get("nonce") if msgSignature == "" || timestamp == "" || nonce == "" { http.Error(w, "Missing parameters", http.StatusBadRequest) return } // Read request body body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return } defer r.Body.Close() // Parse XML to get encrypted message var encryptedMsg struct { XMLName xml.Name `xml:"xml"` ToUserName string `xml:"ToUserName"` Encrypt string `xml:"Encrypt"` AgentID string `xml:"AgentID"` } if err = xml.Unmarshal(body, &encryptedMsg); err != nil { logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid XML", http.StatusBadRequest) return } // Verify signature if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { logger.WarnC("wecom_app", "Message signature verification failed") http.Error(w, "Invalid signature", http.StatusForbidden) return } // Decrypt message with CorpID verification // For WeCom App (自建应用), receiveid should be corp_id decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID) if err != nil { logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{ "error": err.Error(), }) http.Error(w, "Decryption failed", http.StatusInternalServerError) return } // Parse decrypted XML message var msg WeComXMLMessage if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil { logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid message format", http.StatusBadRequest) return } // Process the message with the channel's long-lived context (not the HTTP // request context, which is canceled as soon as we return the response). go c.processMessage(c.ctx, msg) // Return success response immediately // WeCom App requires response within configured timeout (default 5 seconds) w.Write([]byte("success")) } // processMessage processes the received message func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) { // Skip non-text messages for now (can be extended) if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" { logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]any{ "msg_type": msg.MsgType, }) return } // Message deduplication: Use msg_id to prevent duplicate processing // As per WeCom documentation, use msg_id for deduplication msgID := fmt.Sprintf("%d", msg.MsgId) if !c.processedMsgs.MarkMessageProcessed(msgID) { logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{ "msg_id": msgID, }) return } senderID := msg.FromUserName chatID := senderID // WeCom App uses user ID as chat ID for direct messages // Build metadata // WeCom App only supports direct messages (private chat) peer := bus.Peer{Kind: "direct", ID: senderID} messageID := fmt.Sprintf("%d", msg.MsgId) metadata := map[string]string{ "msg_type": msg.MsgType, "msg_id": fmt.Sprintf("%d", msg.MsgId), "agent_id": fmt.Sprintf("%d", msg.AgentID), "platform": "wecom_app", "media_id": msg.MediaId, "create_time": fmt.Sprintf("%d", msg.CreateTime), } content := msg.Content logger.DebugCF("wecom_app", "Received message", map[string]any{ "sender_id": senderID, "msg_type": msg.MsgType, "preview": utils.Truncate(content, 50), }) // Build sender info appSender := bus.SenderInfo{ Platform: "wecom", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("wecom", senderID), } // Handle the message through the base channel c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender) } // tokenRefreshLoop periodically refreshes the access token func (c *WeComAppChannel) tokenRefreshLoop() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for { select { case <-c.ctx.Done(): return case <-ticker.C: if err := c.refreshAccessToken(); err != nil { logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]any{ "error": err.Error(), }) } } } } // refreshAccessToken gets a new access token from WeCom API func (c *WeComAppChannel) refreshAccessToken() error { apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s", wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret)) resp, err := http.Get(apiURL) if err != nil { return fmt.Errorf("failed to request access token: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %w", err) } var tokenResp WeComAccessTokenResponse if err := json.Unmarshal(body, &tokenResp); err != nil { return fmt.Errorf("failed to parse response: %w", err) } if tokenResp.ErrCode != 0 { return fmt.Errorf("API error: %s (code: %d)", tokenResp.ErrMsg, tokenResp.ErrCode) } c.tokenMu.Lock() c.accessToken = tokenResp.AccessToken c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) // Refresh 5 minutes early c.tokenMu.Unlock() logger.DebugC("wecom_app", "Access token refreshed successfully") return nil } // getAccessToken returns the current valid access token func (c *WeComAppChannel) getAccessToken() string { c.tokenMu.RLock() defer c.tokenMu.RUnlock() if time.Now().After(c.tokenExpiry) { return "" } return c.accessToken } // sendTextMessage sends a text message to a user. func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error { msg := WeComTextMessage{ ToUser: userID, MsgType: "text", AgentID: c.config.AgentID, } msg.Text.Content = content return c.sendWeComMessage(ctx, accessToken, msg) } // handleHealth handles health check requests func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) { status := map[string]any{ "status": "ok", "running": c.IsRunning(), "has_token": c.getAccessToken() != "", } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(status) }