Files
Memoh/internal/channel/adapters/weixin/outbound.go
T
晨苒 e2e3b69acf feat(channel): add WeChat (weixin) adapter with QR code (#278)
* feat(channel): add WeChat (weixin) adapter with QR code

* fix(channel): fix weixin block streaming

* chore(channel): update weixin logo
2026-03-22 23:28:57 +08:00

262 lines
7.5 KiB
Go

// Derived from @tencent-weixin/openclaw-weixin (MIT License, Copyright (c) 2026 Tencent Inc.)
// See LICENSE in this directory for the full license text.
package weixin
import (
"context"
"crypto/md5" //nolint:gosec
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"strconv"
"strings"
"github.com/memohai/memoh/internal/media"
)
type assetOpener interface {
Open(ctx context.Context, botID, contentHash string) (io.ReadCloser, media.Asset, error)
}
// sendText sends a plain text message through the WeChat API.
func sendText(ctx context.Context, client *Client, cfg adapterConfig, target, text, contextToken string) error {
if strings.TrimSpace(contextToken) == "" {
return errors.New("weixin: context_token is required to send messages")
}
clientID := generateClientID()
req := SendMessageRequest{
Msg: WeixinMessage{
ToUserID: target,
ClientID: clientID,
MessageType: MessageTypeBot,
MessageState: MessageStateFinish,
ItemList: []MessageItem{
{Type: ItemTypeText, TextItem: &TextItem{Text: text}},
},
ContextToken: contextToken,
},
}
return client.SendMessage(ctx, cfg, req)
}
// sendImageFromReader uploads an image and sends it.
func sendImageFromReader(ctx context.Context, client *Client, cfg adapterConfig, target, contextToken, text string, r io.Reader, logger *slog.Logger) error {
data, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("weixin: read image: %w", err)
}
return sendMediaBytes(ctx, client, cfg, target, contextToken, text, data, UploadMediaImage, ItemTypeImage, logger)
}
// sendFileFromReader uploads a file and sends it.
func sendFileFromReader(ctx context.Context, client *Client, cfg adapterConfig, target, contextToken, text, fileName string, r io.Reader, logger *slog.Logger) error {
data, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("weixin: read file: %w", err)
}
return sendMediaBytesAsFile(ctx, client, cfg, target, contextToken, text, fileName, data, logger)
}
func sendMediaBytes(ctx context.Context, client *Client, cfg adapterConfig, target, contextToken, text string, data []byte, uploadType, itemType int, logger *slog.Logger) error {
if strings.TrimSpace(contextToken) == "" {
return errors.New("weixin: context_token is required for media send")
}
aesKey := make([]byte, 16)
if _, err := rand.Read(aesKey); err != nil {
return fmt.Errorf("weixin: gen aes key: %w", err)
}
filekey := make([]byte, 16)
if _, err := rand.Read(filekey); err != nil {
return fmt.Errorf("weixin: gen filekey: %w", err)
}
filekeyHex := hex.EncodeToString(filekey)
rawMD5 := md5.Sum(data) //nolint:gosec
rawMD5Hex := hex.EncodeToString(rawMD5[:])
fileSize := aesECBPaddedSize(len(data))
uploadResp, err := client.GetUploadURL(ctx, cfg, GetUploadURLRequest{
FileKey: filekeyHex,
MediaType: uploadType,
ToUserID: target,
RawSize: len(data),
RawFileMD5: rawMD5Hex,
FileSize: fileSize,
NoNeedThumb: true,
AESKey: hex.EncodeToString(aesKey),
})
if err != nil {
return fmt.Errorf("weixin: get upload url: %w", err)
}
if strings.TrimSpace(uploadResp.UploadParam) == "" {
return errors.New("weixin: empty upload_param")
}
downloadParam, err := uploadToCDN(cfg.CDNBaseURL, uploadResp.UploadParam, filekeyHex, data, aesKey)
if err != nil {
return fmt.Errorf("weixin: cdn upload: %w", err)
}
var mediaItem MessageItem
switch itemType {
case ItemTypeImage:
mediaItem = MessageItem{
Type: ItemTypeImage,
ImageItem: &ImageItem{
Media: &CDNMedia{
EncryptQueryParam: downloadParam,
AESKey: encodeAESKeyForSend(aesKey),
EncryptType: 1,
},
MidSize: fileSize,
},
}
case ItemTypeVideo:
mediaItem = MessageItem{
Type: ItemTypeVideo,
VideoItem: &VideoItem{
Media: &CDNMedia{
EncryptQueryParam: downloadParam,
AESKey: encodeAESKeyForSend(aesKey),
EncryptType: 1,
},
VideoSize: fileSize,
},
}
default:
return fmt.Errorf("weixin: unsupported media item type %d", itemType)
}
if logger != nil {
logger.Debug("weixin media uploaded",
slog.String("filekey", filekeyHex),
slog.Int("raw_size", len(data)),
slog.Int("cipher_size", fileSize),
)
}
items := make([]MessageItem, 0, 2)
if strings.TrimSpace(text) != "" {
items = append(items, MessageItem{Type: ItemTypeText, TextItem: &TextItem{Text: text}})
}
items = append(items, mediaItem)
for _, it := range items {
req := SendMessageRequest{
Msg: WeixinMessage{
ToUserID: target,
ClientID: generateClientID(),
MessageType: MessageTypeBot,
MessageState: MessageStateFinish,
ItemList: []MessageItem{it},
ContextToken: contextToken,
},
}
if err := client.SendMessage(ctx, cfg, req); err != nil {
return fmt.Errorf("weixin: send media item: %w", err)
}
}
return nil
}
func sendMediaBytesAsFile(ctx context.Context, client *Client, cfg adapterConfig, target, contextToken, text, fileName string, data []byte, logger *slog.Logger) error {
if strings.TrimSpace(contextToken) == "" {
return errors.New("weixin: context_token is required for file send")
}
aesKey := make([]byte, 16)
if _, err := rand.Read(aesKey); err != nil {
return fmt.Errorf("weixin: gen aes key: %w", err)
}
filekey := make([]byte, 16)
if _, err := rand.Read(filekey); err != nil {
return fmt.Errorf("weixin: gen filekey: %w", err)
}
filekeyHex := hex.EncodeToString(filekey)
rawMD5 := md5.Sum(data) //nolint:gosec
rawMD5Hex := hex.EncodeToString(rawMD5[:])
fileSize := aesECBPaddedSize(len(data))
uploadResp, err := client.GetUploadURL(ctx, cfg, GetUploadURLRequest{
FileKey: filekeyHex,
MediaType: UploadMediaFile,
ToUserID: target,
RawSize: len(data),
RawFileMD5: rawMD5Hex,
FileSize: fileSize,
NoNeedThumb: true,
AESKey: hex.EncodeToString(aesKey),
})
if err != nil {
return fmt.Errorf("weixin: get upload url: %w", err)
}
if strings.TrimSpace(uploadResp.UploadParam) == "" {
return errors.New("weixin: empty upload_param")
}
downloadParam, err := uploadToCDN(cfg.CDNBaseURL, uploadResp.UploadParam, filekeyHex, data, aesKey)
if err != nil {
return fmt.Errorf("weixin: cdn upload: %w", err)
}
if logger != nil {
logger.Debug("weixin file uploaded",
slog.String("filekey", filekeyHex),
slog.String("filename", fileName),
slog.Int("raw_size", len(data)),
)
}
fileItem := MessageItem{
Type: ItemTypeFile,
FileItem: &FileItem{
Media: &CDNMedia{
EncryptQueryParam: downloadParam,
AESKey: encodeAESKeyForSend(aesKey),
EncryptType: 1,
},
FileName: fileName,
Len: strconv.Itoa(len(data)),
},
}
items := make([]MessageItem, 0, 2)
if strings.TrimSpace(text) != "" {
items = append(items, MessageItem{Type: ItemTypeText, TextItem: &TextItem{Text: text}})
}
items = append(items, fileItem)
for _, it := range items {
req := SendMessageRequest{
Msg: WeixinMessage{
ToUserID: target,
ClientID: generateClientID(),
MessageType: MessageTypeBot,
MessageState: MessageStateFinish,
ItemList: []MessageItem{it},
ContextToken: contextToken,
},
}
if err := client.SendMessage(ctx, cfg, req); err != nil {
return fmt.Errorf("weixin: send file item: %w", err)
}
}
return nil
}
// encodeAESKeyForSend encodes a raw 16-byte AES key for the sendmessage protocol.
func encodeAESKeyForSend(key []byte) string {
hexStr := hex.EncodeToString(key)
return strings.TrimSpace(hexStr)
}
func generateClientID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return "memoh-weixin-" + hex.EncodeToString(b)
}