mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
e2e3b69acf
* feat(channel): add WeChat (weixin) adapter with QR code * fix(channel): fix weixin block streaming * chore(channel): update weixin logo
262 lines
7.5 KiB
Go
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)
|
|
}
|