package wecom import ( "context" "crypto/aes" "crypto/cipher" "encoding/base64" "encoding/hex" "fmt" "io" "log/slog" "net/http" "net/url" "path/filepath" "strings" "time" "github.com/chenhg5/cc-connect/core" ) // Max download size for WeCom WS image/file payloads (matches OpenClaw default). const wecomWSMediaMaxBytes = 20 << 20 // wsMediaRef is an encrypted download URL plus optional AES key (base64) from the WS protocol. type wsMediaRef struct { URL string Aeskey string } // wsMsgCallbackBodyWS is the full callback body for media-capable parsing (embedded in main struct). // We keep flat fields on wsMsgCallbackBody for backward compatibility; this mirrors the official JSON. type wsMixedItem struct { MsgType string `json:"msgtype"` Text *struct { Content string `json:"content"` } `json:"text,omitempty"` Image *struct { URL string `json:"url"` Aeskey string `json:"aeskey"` } `json:"image,omitempty"` File *struct { URL string `json:"url"` Aeskey string `json:"aeskey"` } `json:"file,omitempty"` } type wsMixedBlock struct { MsgItem []wsMixedItem `json:"msg_item"` } type wsQuoteBlock struct { MsgType string `json:"msgtype"` Text *struct { Content string `json:"content"` } `json:"text,omitempty"` Voice *struct { Content string `json:"content"` } `json:"voice,omitempty"` Image *struct { URL string `json:"url"` Aeskey string `json:"aeskey"` } `json:"image,omitempty"` File *struct { URL string `json:"url"` Aeskey string `json:"aeskey"` } `json:"file,omitempty"` Mixed *wsMixedBlock `json:"mixed,omitempty"` } // wsCollectInboundParts extracts text lines and media refs (main message + quote + mixed), // matching @wecom/aibot-node-sdk message parsing. Does not include the top-level voice // transcription (handled separately via wsVoiceText). func wsCollectInboundParts(body *wsMsgCallbackBody) (texts []string, imgs, files []wsMediaRef) { appendText := func(s string) { s = strings.TrimSpace(s) if s != "" { texts = append(texts, s) } } appendImage := func(url, aeskey string) { if url != "" { imgs = append(imgs, wsMediaRef{URL: url, Aeskey: aeskey}) } } appendFile := func(url, aeskey string) { if url != "" { files = append(files, wsMediaRef{URL: url, Aeskey: aeskey}) } } walkMixed := func(m *wsMixedBlock) { if m == nil { return } for _, item := range m.MsgItem { switch item.MsgType { case "text": if item.Text != nil { appendText(item.Text.Content) } case "image": if item.Image != nil { appendImage(item.Image.URL, item.Image.Aeskey) } case "file": if item.File != nil { appendFile(item.File.URL, item.File.Aeskey) } } } } walkQuote := func(q *wsQuoteBlock) { if q == nil { return } switch q.MsgType { case "text": if q.Text != nil { appendText(q.Text.Content) } case "voice": if q.Voice != nil { appendText(q.Voice.Content) } case "image": if q.Image != nil { appendImage(q.Image.URL, q.Image.Aeskey) } case "file": if q.File != nil { appendFile(q.File.URL, q.File.Aeskey) } case "mixed": walkMixed(q.Mixed) } } if body.Mixed != nil && len(body.Mixed.MsgItem) > 0 { walkMixed(body.Mixed) } else { appendText(body.Text.Content) if body.Image != nil { appendImage(body.Image.URL, body.Image.Aeskey) } if body.MsgType == "file" && body.File != nil { appendFile(body.File.URL, body.File.Aeskey) } } // WeCom may send msgtype=file (or image) together with a non-empty mixed block; the real // download url is then only on the top-level file/image object. Merge those here. if body.Mixed != nil && len(body.Mixed.MsgItem) > 0 { if body.MsgType == "file" && body.File != nil { appendFile(body.File.URL, body.File.Aeskey) } if body.MsgType == "image" && body.Image != nil { appendImage(body.Image.URL, body.Image.Aeskey) } } walkQuote(body.Quote) return texts, imgs, files } // decodeWeComAESKey normalizes and decodes the aeskey from WeCom WS callbacks. // The server may send standard Base64, URL-safe Base64 (- _), omit padding, insert // whitespace, or (rarely) a 64-char hex string. Node's Buffer.from(s, 'base64') is more // permissive than Go's StdEncoding; we mirror common cases so decryption matches the SDK. func decodeWeComAESKey(aesKey string) ([]byte, error) { s := strings.TrimSpace(aesKey) if s == "" { return nil, fmt.Errorf("wecom-ws: empty aeskey") } var b strings.Builder b.Grow(len(s)) for i := 0; i < len(s); i++ { switch s[i] { case '\n', '\r', ' ', '\t': continue default: b.WriteByte(s[i]) } } s = b.String() if len(s) == 64 && isHexString(s) { key, err := hex.DecodeString(s) if err != nil { return nil, fmt.Errorf("wecom-ws: decode aeskey hex: %w", err) } if len(key) != 32 { return nil, fmt.Errorf("wecom-ws: aeskey hex length %d, want 32 bytes", len(key)) } return key, nil } // URL-safe alphabet → standard (RFC 4648 §5) s = strings.ReplaceAll(s, "-", "+") s = strings.ReplaceAll(s, "_", "/") switch len(s) % 4 { case 0: case 2: s += "==" case 3: s += "=" default: return nil, fmt.Errorf("wecom-ws: invalid aeskey base64 length") } key, err := base64.StdEncoding.DecodeString(s) if err != nil { return nil, fmt.Errorf("wecom-ws: decode aeskey: %w", err) } if len(key) < 32 { return nil, fmt.Errorf("wecom-ws: aeskey decoded length %d, need >= 32", len(key)) } return key, nil } func isHexString(s string) bool { for i := 0; i < len(s); i++ { c := s[i] switch { case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F': default: return false } } return true } // wecomDecryptFile decrypts payload from WeCom WS media URLs (AES-256-CBC, IV = first 16 key bytes). // Same algorithm as @wecom/aibot-node-sdk decryptFile. func wecomDecryptFile(ciphertext []byte, aesKeyB64 string) ([]byte, error) { if len(ciphertext) == 0 { return nil, fmt.Errorf("wecom-ws: empty ciphertext") } key, err := decodeWeComAESKey(aesKeyB64) if err != nil { return nil, err } key32 := key[:32] iv := key32[:16] block, err := aes.NewCipher(key32) if err != nil { return nil, err } if len(ciphertext)%aes.BlockSize != 0 { return nil, fmt.Errorf("wecom-ws: ciphertext not multiple of block size") } plain := make([]byte, len(ciphertext)) cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, ciphertext) return pkcs7UnpadWeCom(plain) } func pkcs7UnpadWeCom(data []byte) ([]byte, error) { if len(data) == 0 { return nil, fmt.Errorf("wecom-ws: empty padded data") } padLen := int(data[len(data)-1]) if padLen < 1 || padLen > 32 || padLen > len(data) { return nil, fmt.Errorf("wecom-ws: invalid pkcs7 pad length %d", padLen) } for i := len(data) - padLen; i < len(data); i++ { if int(data[i]) != padLen { return nil, fmt.Errorf("wecom-ws: invalid pkcs7 padding") } } return data[:len(data)-padLen], nil } func parseContentDispositionFilename(h string) string { h = strings.TrimSpace(h) if h == "" { return "" } lower := strings.ToLower(h) // RFC 5987: filename*=UTF-8''percent-encoded if idx := strings.Index(lower, "filename*="); idx >= 0 { val := strings.TrimSpace(h[idx+len("filename*="):]) val = strings.TrimSuffix(strings.TrimSpace(val), ";") if after, ok := strings.CutPrefix(val, "UTF-8''"); ok { if dec, err := url.QueryUnescape(after); err == nil { return filepath.Base(dec) } return filepath.Base(after) } } if idx := strings.Index(lower, "filename="); idx >= 0 { val := strings.TrimSpace(h[idx+len("filename="):]) val = strings.TrimSuffix(val, ";") val = strings.Trim(val, `"`) if dec, err := url.QueryUnescape(val); err == nil { return filepath.Base(dec) } return filepath.Base(val) } return "" } func downloadWeComWSMedia(ctx context.Context, urlStr, aesKey string) (data []byte, fileName string, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) if err != nil { return nil, "", err } client := &http.Client{Timeout: 90 * time.Second} resp, err := client.Do(req) if err != nil { return nil, "", err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, "", fmt.Errorf("wecom-ws: download HTTP %s", resp.Status) } fileName = parseContentDispositionFilename(resp.Header.Get("Content-Disposition")) lim := io.LimitReader(resp.Body, wecomWSMediaMaxBytes+1) raw, err := io.ReadAll(lim) if err != nil { return nil, "", err } if len(raw) > wecomWSMediaMaxBytes { return nil, "", fmt.Errorf("wecom-ws: media larger than %d bytes", wecomWSMediaMaxBytes) } if aesKey != "" { raw, err = wecomDecryptFile(raw, aesKey) if err != nil { return nil, "", err } } return raw, fileName, nil } // deliverWSMediaInbound downloads image/file refs and forwards one core.Message. func (p *WSPlatform) deliverWSMediaInbound(body *wsMsgCallbackBody, sessionKey, chatName string, rctx wsReplyContext, texts []string, imgs, files []wsMediaRef) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() var images []core.ImageAttachment var fileAtts []core.FileAttachment for _, im := range imgs { buf, fn, err := downloadWeComWSMedia(ctx, im.URL, im.Aeskey) if err != nil { slog.Error("wecom-ws: download image failed", "error", err) continue } base := filepath.Base(strings.TrimSpace(fn)) if base == "" || base == "." { base = "image.bin" } mt := wecomInboundFileMime(base, buf) if !strings.HasPrefix(mt, "image/") { mt = http.DetectContentType(buf) if !strings.HasPrefix(mt, "image/") { mt = "image/jpeg" } } images = append(images, core.ImageAttachment{MimeType: mt, Data: buf, FileName: base}) slog.Info("wecom-ws: image downloaded", "bytes", len(buf), "mime", mt, "name", base) } for _, f := range files { buf, fn, err := downloadWeComWSMedia(ctx, f.URL, f.Aeskey) if err != nil { slog.Error("wecom-ws: download file failed", "error", err) continue } base := filepath.Base(strings.TrimSpace(fn)) if base == "" || base == "." { base = "attachment" } mt := wecomInboundFileMime(base, buf) fileAtts = append(fileAtts, core.FileAttachment{MimeType: mt, Data: buf, FileName: base}) slog.Info("wecom-ws: file downloaded", "bytes", len(buf), "mime", mt, "name", base) } content := strings.Join(texts, "\n") content = stripWeComAtMentions(content, p.botID, body.AibotID) if content == "" && len(images) == 0 && len(fileAtts) == 0 { slog.Warn("wecom-ws: media inbound empty after downloads", "msg_id", body.MsgID) return } p.handler(p, &core.Message{ SessionKey: sessionKey, Platform: "wecom", MessageID: body.MsgID, UserID: body.From.UserID, UserName: body.From.UserID, ChatName: chatName, Content: content, Images: images, Files: fileAtts, ReplyCtx: rctx, }) }