395 lines
11 KiB
Go
395 lines
11 KiB
Go
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,
|
|
})
|
|
}
|