diff --git a/pkg/channels/weixin/api.go b/pkg/channels/weixin/api.go index 9f4d49ad0..7f9b3b5c6 100644 --- a/pkg/channels/weixin/api.go +++ b/pkg/channels/weixin/api.go @@ -128,10 +128,13 @@ func (c *ApiClient) GetUpdates(ctx context.Context, req GetUpdatesReq) (*GetUpda return &resp, nil } -func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) error { +func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) (*SendMessageResp, error) { req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} var resp SendMessageResp - return c.post(ctx, "ilink/bot/sendmessage", req, &resp) + if err := c.post(ctx, "ilink/bot/sendmessage", req, &resp); err != nil { + return nil, err + } + return &resp, nil } func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*GetUploadUrlResp, error) { @@ -144,9 +147,22 @@ func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*Get return &resp, nil } -func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) error { +func (c *ApiClient) GetConfig(ctx context.Context, req GetConfigReq) (*GetConfigResp, error) { req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} - return c.post(ctx, "ilink/bot/sendtyping", req, nil) + var resp GetConfigResp + if err := c.post(ctx, "ilink/bot/getconfig", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) (*SendTypingResp, error) { + req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} + var resp SendTypingResp + if err := c.post(ctx, "ilink/bot/sendtyping", req, &resp); err != nil { + return nil, err + } + return &resp, nil } func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) { diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go new file mode 100644 index 000000000..0332f48f6 --- /dev/null +++ b/pkg/channels/weixin/media.go @@ -0,0 +1,1037 @@ +package weixin + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/md5" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/h2non/filetype" + + "github.com/sipeed/picoclaw/pkg/bus" + basechannels "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" +) + +const ( + weixinMediaMaxBytes = 100 << 20 + weixinTypingKeepAlive = 5 * time.Second + weixinUploadRetryMax = 3 + weixinVoiceTranscodeTimeout = 15 * time.Second +) + +type uploadedFileInfo struct { + downloadParam string + aesKeyHex string + fileSize int64 + cipherSize int64 + filename string +} + +func pkcs7Pad(src []byte, blockSize int) []byte { + padding := blockSize - len(src)%blockSize + if padding == 0 { + padding = blockSize + } + out := make([]byte, len(src)+padding) + copy(out, src) + for i := len(src); i < len(out); i++ { + out[i] = byte(padding) + } + return out +} + +func pkcs7Unpad(src []byte, blockSize int) ([]byte, error) { + if len(src) == 0 || len(src)%blockSize != 0 { + return nil, fmt.Errorf("invalid padded data size %d", len(src)) + } + padding := int(src[len(src)-1]) + if padding <= 0 || padding > blockSize || padding > len(src) { + return nil, fmt.Errorf("invalid padding size %d", padding) + } + for i := len(src) - padding; i < len(src); i++ { + if src[i] != byte(padding) { + return nil, fmt.Errorf("invalid padding content") + } + } + return src[:len(src)-padding], nil +} + +func encryptAESECB(plaintext, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + padded := pkcs7Pad(plaintext, block.BlockSize()) + out := make([]byte, len(padded)) + for i := 0; i < len(padded); i += block.BlockSize() { + block.Encrypt(out[i:i+block.BlockSize()], padded[i:i+block.BlockSize()]) + } + return out, nil +} + +func decryptAESECB(ciphertext, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + if len(ciphertext)%block.BlockSize() != 0 { + return nil, fmt.Errorf("invalid ciphertext size %d", len(ciphertext)) + } + out := make([]byte, len(ciphertext)) + for i := 0; i < len(ciphertext); i += block.BlockSize() { + block.Decrypt(out[i:i+block.BlockSize()], ciphertext[i:i+block.BlockSize()]) + } + return pkcs7Unpad(out, block.BlockSize()) +} + +func parseWeixinMediaAESKey(aesKeyBase64 string) ([]byte, error) { + decoded, err := base64.StdEncoding.DecodeString(aesKeyBase64) + if err != nil { + return nil, err + } + if len(decoded) == 16 { + return decoded, nil + } + if len(decoded) == 32 { + if raw, err := hex.DecodeString(string(decoded)); err == nil && len(raw) == 16 { + return raw, nil + } + } + return nil, fmt.Errorf("unsupported aes_key length %d", len(decoded)) +} + +func imageAESKey(img *ImageItem) ([]byte, bool, error) { + if img == nil { + return nil, false, nil + } + if img.Aeskey != "" { + raw, err := hex.DecodeString(img.Aeskey) + if err != nil { + return nil, false, err + } + return raw, true, nil + } + if img.Media != nil && img.Media.AesKey != "" { + raw, err := parseWeixinMediaAESKey(img.Media.AesKey) + if err != nil { + return nil, false, err + } + return raw, true, nil + } + return nil, false, nil +} + +func genericMediaAESKey(mediaRef *CDNMedia) ([]byte, error) { + if mediaRef == nil || mediaRef.AesKey == "" { + return nil, fmt.Errorf("missing aes_key") + } + return parseWeixinMediaAESKey(mediaRef.AesKey) +} + +func aesEcbPaddedSize(size int64) int64 { + return (size/16 + 1) * 16 +} + +func randomHex(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func buildCDNDownloadURL(base, encryptedQueryParam string) string { + return strings.TrimRight(base, "/") + + "/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam) +} + +func buildCDNUploadURL(base, uploadParam, filekey string) string { + return strings.TrimRight(base, "/") + + "/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) + + "&filekey=" + url.QueryEscape(filekey) +} + +func (c *WeixinChannel) downloadCDNBuffer(ctx context.Context, encryptedQueryParam string) ([]byte, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam), + nil, + ) + if err != nil { + return nil, err + } + resp, err := c.api.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("cdn download HTTP %d: %s", resp.StatusCode, string(body)) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1)) + if err != nil { + return nil, err + } + if len(data) > weixinMediaMaxBytes { + return nil, fmt.Errorf("cdn media too large: %d bytes", len(data)) + } + return data, nil +} + +func (c *WeixinChannel) downloadAndDecryptCDNBuffer( + ctx context.Context, + encryptedQueryParam string, + key []byte, +) ([]byte, error) { + data, err := c.downloadCDNBuffer(ctx, encryptedQueryParam) + if err != nil { + return nil, err + } + if len(key) == 0 { + return data, nil + } + return decryptAESECB(data, key) +} + +func detectMediaMetadata(data []byte, fallbackName, fallbackContentType string) (string, string) { + contentType := strings.TrimSpace(fallbackContentType) + ext := filepath.Ext(fallbackName) + if kind, err := filetype.Match(data); err == nil && kind != filetype.Unknown { + contentType = kind.MIME.Value + if kind.Extension != "" { + ext = "." + kind.Extension + } + } + if contentType == "" && ext != "" { + contentType = mime.TypeByExtension(strings.ToLower(ext)) + } + if contentType == "" { + contentType = http.DetectContentType(data) + } + if ext == "" && contentType != "" { + if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 { + ext = exts[0] + } + } + + filename := sanitizeFilename(fallbackName) + if filename == "" { + filename = "media" + } + if filepath.Ext(filename) == "" && ext != "" { + filename += ext + } + return filename, contentType +} + +func sanitizeFilename(name string) string { + name = filepath.Base(strings.TrimSpace(name)) + if name == "." || name == "/" || name == "" { + return "" + } + return name +} + +func writeManagedTempFile(prefix, filename string, data []byte) (string, error) { + if err := os.MkdirAll(media.TempDir(), 0o700); err != nil { + return "", err + } + pattern := prefix + "-*" + if ext := filepath.Ext(filename); ext != "" { + pattern += ext + } + f, err := os.CreateTemp(media.TempDir(), pattern) + if err != nil { + return "", err + } + defer f.Close() + if _, err := f.Write(data); err != nil { + os.Remove(f.Name()) + return "", err + } + return f.Name(), nil +} + +func (c *WeixinChannel) storeInboundBytes( + chatID, + messageID, + filename, + contentType string, + data []byte, +) (string, error) { + store := c.GetMediaStore() + if store == nil { + return "", fmt.Errorf("no media store available") + } + filename, contentType = detectMediaMetadata(data, filename, contentType) + tmpPath, err := writeManagedTempFile("weixin-inbound", filename, data) + if err != nil { + return "", err + } + ref, err := store.Store(tmpPath, media.MediaMeta{ + Filename: filename, + ContentType: contentType, + Source: "weixin", + }, basechannels.BuildMediaScope("weixin", chatID, messageID)) + if err != nil { + os.Remove(tmpPath) + return "", err + } + return ref, nil +} + +func isDownloadableMediaItem(item *MessageItem) bool { + if item == nil { + return false + } + + switch item.Type { + case MessageItemTypeImage: + return item.ImageItem != nil && item.ImageItem.Media != nil && item.ImageItem.Media.EncryptQueryParam != "" + case MessageItemTypeVideo: + return item.VideoItem != nil && item.VideoItem.Media != nil && item.VideoItem.Media.EncryptQueryParam != "" + case MessageItemTypeFile: + return item.FileItem != nil && item.FileItem.Media != nil && item.FileItem.Media.EncryptQueryParam != "" + case MessageItemTypeVoice: + return item.VoiceItem != nil && + item.VoiceItem.Media != nil && + item.VoiceItem.Media.EncryptQueryParam != "" && + strings.TrimSpace(item.VoiceItem.Text) == "" + default: + return false + } +} + +func selectInboundMediaItem(msg WeixinMessage) *MessageItem { + priorities := []int{ + MessageItemTypeImage, + MessageItemTypeVideo, + MessageItemTypeFile, + MessageItemTypeVoice, + } + + for _, want := range priorities { + for i := range msg.ItemList { + item := &msg.ItemList[i] + if item.Type == want && isDownloadableMediaItem(item) { + return item + } + } + } + + for i := range msg.ItemList { + item := &msg.ItemList[i] + if item.Type != MessageItemTypeText || item.RefMsg == nil || item.RefMsg.MessageItem == nil { + continue + } + if isDownloadableMediaItem(item.RefMsg.MessageItem) { + return item.RefMsg.MessageItem + } + } + + return nil +} + +func tryTranscodeSilkToWAV(ctx context.Context, silk []byte) ([]byte, error) { + decoders := []struct { + name string + args func(inputPath, outputPath string) []string + }{ + { + name: "silk_v3_decoder", + args: func(inputPath, outputPath string) []string { return []string{inputPath, outputPath, "24000"} }, + }, + { + name: "silk_decoder", + args: func(inputPath, outputPath string) []string { return []string{inputPath, outputPath, "24000"} }, + }, + { + name: "ffmpeg", + args: func(inputPath, outputPath string) []string { + return []string{"-y", "-i", inputPath, outputPath} + }, + }, + } + + for _, decoder := range decoders { + bin, err := exec.LookPath(decoder.name) + if err != nil { + continue + } + + tmpIn, err := writeManagedTempFile("weixin-voice", "voice.silk", silk) + if err != nil { + return nil, err + } + tmpOut := filepath.Join(media.TempDir(), "weixin-voice-"+uuid.New().String()+".wav") + wav, ok := func() ([]byte, bool) { + defer os.Remove(tmpIn) + defer os.Remove(tmpOut) + + runCtx, cancel := context.WithTimeout(ctx, weixinVoiceTranscodeTimeout) + cmd := exec.CommandContext(runCtx, bin, decoder.args(tmpIn, tmpOut)...) + out, runErr := cmd.CombinedOutput() + cancel() + if runErr != nil { + logger.DebugCF("weixin", "SILK transcode command failed", map[string]any{ + "decoder": decoder.name, + "error": runErr.Error(), + "output": strings.TrimSpace(string(out)), + }) + return nil, false + } + + wav, readErr := os.ReadFile(tmpOut) + if readErr != nil { + logger.DebugCF("weixin", "Failed to read transcoded WAV", map[string]any{ + "decoder": decoder.name, + "error": readErr.Error(), + }) + return nil, false + } + return wav, len(wav) > 0 + }() + if ok { + return wav, nil + } + } + + return nil, fmt.Errorf("no SILK decoder available") +} + +func (c *WeixinChannel) downloadMediaFromItem( + ctx context.Context, + chatID, + messageID string, + item *MessageItem, +) (string, error) { + if item == nil { + return "", nil + } + + switch item.Type { + case MessageItemTypeImage: + key, ok, err := imageAESKey(item.ImageItem) + if err != nil { + return "", err + } + data, err := c.downloadAndDecryptCDNBuffer(ctx, item.ImageItem.Media.EncryptQueryParam, func() []byte { + if ok { + return key + } + return nil + }()) + if err != nil { + return "", err + } + return c.storeInboundBytes(chatID, messageID, "image", "", data) + + case MessageItemTypeVoice: + key, err := genericMediaAESKey(item.VoiceItem.Media) + if err != nil { + return "", err + } + silk, err := c.downloadAndDecryptCDNBuffer(ctx, item.VoiceItem.Media.EncryptQueryParam, key) + if err != nil { + return "", err + } + if wav, err := tryTranscodeSilkToWAV(ctx, silk); err == nil && len(wav) > 0 { + return c.storeInboundBytes(chatID, messageID, "voice.wav", "audio/wav", wav) + } + return c.storeInboundBytes(chatID, messageID, "voice.silk", "audio/silk", silk) + + case MessageItemTypeFile: + key, err := genericMediaAESKey(item.FileItem.Media) + if err != nil { + return "", err + } + data, err := c.downloadAndDecryptCDNBuffer(ctx, item.FileItem.Media.EncryptQueryParam, key) + if err != nil { + return "", err + } + filename := item.FileItem.FileName + if filename == "" { + filename = "file.bin" + } + contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename))) + return c.storeInboundBytes(chatID, messageID, filename, contentType, data) + + case MessageItemTypeVideo: + key, err := genericMediaAESKey(item.VideoItem.Media) + if err != nil { + return "", err + } + data, err := c.downloadAndDecryptCDNBuffer(ctx, item.VideoItem.Media.EncryptQueryParam, key) + if err != nil { + return "", err + } + return c.storeInboundBytes(chatID, messageID, "video.mp4", "video/mp4", data) + } + + return "", nil +} + +func outboundMediaKind(partType, filename, contentType string) int { + switch strings.ToLower(strings.TrimSpace(partType)) { + case "image": + return UploadMediaTypeImage + case "video": + return UploadMediaTypeVideo + } + + ct := strings.ToLower(contentType) + switch { + case strings.HasPrefix(ct, "image/"): + return UploadMediaTypeImage + case strings.HasPrefix(ct, "video/"): + return UploadMediaTypeVideo + default: + return UploadMediaTypeFile + } +} + +func detectLocalContentType(localPath, hintContentType string) string { + if strings.TrimSpace(hintContentType) != "" { + return hintContentType + } + if kind, err := filetype.MatchFile(localPath); err == nil && kind != filetype.Unknown { + return kind.MIME.Value + } + if ext := filepath.Ext(localPath); ext != "" { + if ct := mime.TypeByExtension(strings.ToLower(ext)); ct != "" { + return ct + } + } + return "application/octet-stream" +} + +func downloadFilenameFromURL(rawURL, fallback string) string { + if fallback = sanitizeFilename(fallback); fallback != "" { + return fallback + } + parsed, err := url.Parse(rawURL) + if err == nil { + if base := sanitizeFilename(path.Base(parsed.Path)); base != "" { + return base + } + } + return "remote-media" +} + +func (c *WeixinChannel) downloadRemoteMediaToTemp( + ctx context.Context, + rawURL, + fallbackName string, +) (string, string, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "", "", "", err + } + resp, err := c.api.HttpClient.Do(req) + if err != nil { + return "", "", "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", "", "", fmt.Errorf("remote media HTTP %d: %s", resp.StatusCode, string(body)) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1)) + if err != nil { + return "", "", "", err + } + if len(data) > weixinMediaMaxBytes { + return "", "", "", fmt.Errorf("remote media too large: %d bytes", len(data)) + } + + filename, contentType := detectMediaMetadata( + data, + downloadFilenameFromURL(rawURL, fallbackName), + resp.Header.Get("Content-Type"), + ) + tmpPath, err := writeManagedTempFile("weixin-remote", filename, data) + if err != nil { + return "", "", "", err + } + return tmpPath, filename, contentType, nil +} + +func (c *WeixinChannel) resolveOutboundPart( + ctx context.Context, + part bus.MediaPart, +) (string, string, string, func(), error) { + cleanup := func() {} + filename := sanitizeFilename(part.Filename) + contentType := strings.TrimSpace(part.ContentType) + + switch { + case strings.HasPrefix(part.Ref, "http://") || strings.HasPrefix(part.Ref, "https://"): + localPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, part.Ref, filename) + if err != nil { + return "", "", "", cleanup, err + } + return localPath, name, ct, func() { os.Remove(localPath) }, nil + + case strings.HasPrefix(part.Ref, "media://"): + store := c.GetMediaStore() + if store == nil { + return "", "", "", cleanup, fmt.Errorf("no media store available") + } + localPath, meta, err := store.ResolveWithMeta(part.Ref) + if err != nil { + return "", "", "", cleanup, err + } + if filename == "" { + filename = sanitizeFilename(meta.Filename) + } + if contentType == "" { + contentType = meta.ContentType + } + if strings.HasPrefix(localPath, "http://") || strings.HasPrefix(localPath, "https://") { + tmpPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, localPath, filename) + if err != nil { + return "", "", "", cleanup, err + } + return tmpPath, name, ct, func() { os.Remove(tmpPath) }, nil + } + if filename == "" { + filename = sanitizeFilename(filepath.Base(localPath)) + } + if contentType == "" { + contentType = detectLocalContentType(localPath, "") + } + return localPath, filename, contentType, cleanup, nil + + case strings.HasPrefix(part.Ref, "file://"): + u, err := url.Parse(part.Ref) + if err != nil { + return "", "", "", cleanup, err + } + localPath := u.Path + if filename == "" { + filename = sanitizeFilename(filepath.Base(localPath)) + } + if contentType == "" { + contentType = detectLocalContentType(localPath, "") + } + return localPath, filename, contentType, cleanup, nil + + default: + localPath := part.Ref + if filename == "" { + filename = sanitizeFilename(filepath.Base(localPath)) + } + if contentType == "" { + contentType = detectLocalContentType(localPath, "") + } + return localPath, filename, contentType, cleanup, nil + } +} + +func (c *WeixinChannel) uploadLocalFile( + ctx context.Context, + localPath, + filename, + toUserID string, + mediaType int, +) (*uploadedFileInfo, error) { + data, err := os.ReadFile(localPath) + if err != nil { + return nil, err + } + if len(data) > weixinMediaMaxBytes { + return nil, fmt.Errorf("media too large: %d bytes", len(data)) + } + + filekey, err := randomHex(16) + if err != nil { + return nil, err + } + aesKey := make([]byte, 16) + if _, readErr := rand.Read(aesKey); readErr != nil { + return nil, readErr + } + aesKeyHex := hex.EncodeToString(aesKey) + rawMD5 := md5.Sum(data) + + resp, err := c.api.GetUploadUrl(ctx, GetUploadUrlReq{ + Filekey: filekey, + MediaType: mediaType, + ToUserID: toUserID, + Rawsize: int64(len(data)), + RawfileMD5: hex.EncodeToString(rawMD5[:]), + Filesize: aesEcbPaddedSize(int64(len(data))), + NoNeedThumb: true, + Aeskey: aesKeyHex, + }) + if err != nil { + return nil, err + } + if resp == nil { + return nil, fmt.Errorf("getuploadurl returned nil response") + } + if resp.Ret != 0 || resp.Errcode != 0 { + if isSessionExpiredStatus(resp.Ret, resp.Errcode) { + c.pauseSession("getuploadurl", resp.Ret, resp.Errcode, resp.Errmsg) + } + return nil, fmt.Errorf("getuploadurl failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg) + } + if strings.TrimSpace(resp.UploadParam) == "" { + return nil, fmt.Errorf("getuploadurl returned empty upload_param") + } + + downloadParam, err := c.uploadBufferToCDN(ctx, data, resp.UploadParam, filekey, aesKey) + if err != nil { + return nil, err + } + + return &uploadedFileInfo{ + downloadParam: downloadParam, + aesKeyHex: aesKeyHex, + fileSize: int64(len(data)), + cipherSize: aesEcbPaddedSize(int64(len(data))), + filename: filename, + }, nil +} + +func (c *WeixinChannel) uploadBufferToCDN( + ctx context.Context, + plaintext []byte, + uploadParam, + filekey string, + aesKey []byte, +) (string, error) { + ciphertext, err := encryptAESECB(plaintext, aesKey) + if err != nil { + return "", err + } + + uploadURL := buildCDNUploadURL(c.cdnBaseURL(), uploadParam, filekey) + var lastErr error + + for attempt := 1; attempt <= weixinUploadRetryMax; attempt++ { + req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(ciphertext)) + if reqErr != nil { + return "", reqErr + } + req.Header.Set("Content-Type", "application/octet-stream") + + resp, doErr := c.api.HttpClient.Do(req) + if doErr != nil { + lastErr = doErr + } else { + func() { + defer resp.Body.Close() + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + lastErr = fmt.Errorf( + "cdn upload client error %d: %s", + resp.StatusCode, + strings.TrimSpace(string(body)), + ) + return + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + lastErr = fmt.Errorf( + "cdn upload server error %d: %s", + resp.StatusCode, + strings.TrimSpace(string(body)), + ) + return + } + if encrypted := strings.TrimSpace(resp.Header.Get("X-Encrypted-Param")); encrypted != "" { + lastErr = nil + uploadParam = encrypted + return + } + lastErr = fmt.Errorf("cdn upload missing x-encrypted-param header") + }() + } + + if lastErr == nil { + return uploadParam, nil + } + if strings.Contains(lastErr.Error(), "client error") || attempt == weixinUploadRetryMax { + break + } + } + + return "", lastErr +} + +func (c *WeixinChannel) sendMessageItem( + ctx context.Context, + toUserID, + contextToken string, + item MessageItem, +) error { + resp, err := c.api.SendMessage(ctx, SendMessageReq{ + Msg: WeixinMessage{ + ToUserID: toUserID, + ClientID: "picoclaw-" + uuid.New().String(), + MessageType: MessageTypeBot, + MessageState: MessageStateFinish, + ItemList: []MessageItem{item}, + ContextToken: contextToken, + }, + }) + if err != nil { + return err + } + if resp == nil { + return fmt.Errorf("sendmessage returned nil response") + } + if resp.Ret != 0 || resp.Errcode != 0 { + if isSessionExpiredStatus(resp.Ret, resp.Errcode) { + c.pauseSession("sendmessage", resp.Ret, resp.Errcode, resp.Errmsg) + } + return fmt.Errorf("sendmessage failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg) + } + return nil +} + +func (c *WeixinChannel) sendTextMessage( + ctx context.Context, + toUserID, + contextToken, + text string, +) error { + if strings.TrimSpace(text) == "" { + return nil + } + return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{ + Type: MessageItemTypeText, + TextItem: &TextItem{ + Text: text, + }, + }) +} + +func encodeWeixinOutboundAESKey(aesKeyHex string) string { + return base64.StdEncoding.EncodeToString([]byte(aesKeyHex)) +} + +func (c *WeixinChannel) sendUploadedMedia( + ctx context.Context, + toUserID, + contextToken, + caption string, + mediaType int, + uploaded *uploadedFileInfo, +) error { + if err := c.sendTextMessage(ctx, toUserID, contextToken, caption); err != nil { + return err + } + + mediaRef := &CDNMedia{ + EncryptQueryParam: uploaded.downloadParam, + AesKey: encodeWeixinOutboundAESKey(uploaded.aesKeyHex), + EncryptType: 1, + } + + switch mediaType { + case UploadMediaTypeImage: + return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{ + Type: MessageItemTypeImage, + ImageItem: &ImageItem{ + Media: mediaRef, + MidSize: uploaded.cipherSize, + }, + }) + + case UploadMediaTypeVideo: + return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{ + Type: MessageItemTypeVideo, + VideoItem: &VideoItem{ + Media: mediaRef, + VideoSize: uploaded.cipherSize, + }, + }) + + default: + return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{ + Type: MessageItemTypeFile, + FileItem: &FileItem{ + Media: mediaRef, + FileName: uploaded.filename, + Len: fmt.Sprintf("%d", uploaded.fileSize), + }, + }) + } +} + +func (c *WeixinChannel) sendTypingStatus( + ctx context.Context, + chatID, + typingTicket string, + status int, +) error { + resp, err := c.api.SendTyping(ctx, SendTypingReq{ + IlinkUserID: chatID, + TypingTicket: typingTicket, + Status: status, + }) + if err != nil { + return err + } + if resp == nil { + return fmt.Errorf("sendtyping returned nil response") + } + if resp.Ret != 0 || resp.Errcode != 0 { + if isSessionExpiredStatus(resp.Ret, resp.Errcode) { + c.pauseSession("sendtyping", resp.Ret, resp.Errcode, resp.Errmsg) + } + return fmt.Errorf("sendtyping failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg) + } + return nil +} + +// StartTyping implements channels.TypingCapable. +func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + if strings.TrimSpace(chatID) == "" { + return func() {}, nil + } + if c.remainingPause() > 0 { + return func() {}, nil + } + + ticket, err := c.getTypingTicket(ctx, chatID) + if err != nil { + if ticket == "" { + return func() {}, err + } + logger.DebugCF("weixin", "GetConfig refresh failed; using cached typing ticket", map[string]any{ + "chat_id": chatID, + "error": err.Error(), + }) + } + if ticket == "" { + return func() {}, nil + } + + typingCtx, cancel := context.WithCancel(ctx) + var once sync.Once + stop := func() { + once.Do(func() { + cancel() + stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer stopCancel() + if err := c.sendTypingStatus(stopCtx, chatID, ticket, TypingStatusCancel); err != nil { + logger.DebugCF("weixin", "Failed to cancel typing indicator", map[string]any{ + "chat_id": chatID, + "error": err.Error(), + }) + } + }) + } + + if err := c.sendTypingStatus(typingCtx, chatID, ticket, TypingStatusTyping); err != nil { + stop() + return func() {}, err + } + + ticker := time.NewTicker(weixinTypingKeepAlive) + go func() { + defer ticker.Stop() + for { + select { + case <-typingCtx.Done(): + return + case <-ticker.C: + if err := c.sendTypingStatus(typingCtx, chatID, ticket, TypingStatusTyping); err != nil { + logger.DebugCF("weixin", "Failed to refresh typing indicator", map[string]any{ + "chat_id": chatID, + "error": err.Error(), + }) + } + } + } + }() + + return stop, nil +} + +// SendMedia implements channels.MediaSender. +func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return basechannels.ErrNotRunning + } + if err := c.ensureSessionActive(); err != nil { + return err + } + + contextToken := "" + if v, ok := c.contextTokens.Load(msg.ChatID); ok { + contextToken, _ = v.(string) + } + if contextToken == "" { + return fmt.Errorf( + "weixin send media: missing context token for chat %s: %w", + msg.ChatID, + basechannels.ErrSendFailed, + ) + } + + for _, part := range msg.Parts { + localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part) + if err != nil { + logger.ErrorCF("weixin", "Failed to resolve outbound media", map[string]any{ + "chat_id": msg.ChatID, + "ref": part.Ref, + "error": err.Error(), + }) + return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) + } + func() { + if cleanup != nil { + defer cleanup() + } + + kind := outboundMediaKind(part.Type, filename, contentType) + uploaded, uploadErr := c.uploadLocalFile(ctx, localPath, filename, msg.ChatID, kind) + if uploadErr != nil { + err = uploadErr + return + } + err = c.sendUploadedMedia(ctx, msg.ChatID, contextToken, part.Caption, kind, uploaded) + }() + if err != nil { + logger.ErrorCF("weixin", "Failed to send outbound media", map[string]any{ + "chat_id": msg.ChatID, + "ref": part.Ref, + "error": err.Error(), + }) + if c.remainingPause() > 0 { + return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) + } + return fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary) + } + } + + return nil +} diff --git a/pkg/channels/weixin/state.go b/pkg/channels/weixin/state.go new file mode 100644 index 000000000..02c137b83 --- /dev/null +++ b/pkg/channels/weixin/state.go @@ -0,0 +1,226 @@ +package weixin + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + basechannels "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + weixinDefaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c" + weixinConfigCacheTTL = 24 * time.Hour + weixinConfigRetryInitial = 2 * time.Second + weixinConfigRetryMax = time.Hour + weixinSessionPauseDuration = time.Hour + weixinSessionExpiredCode = -14 +) + +type typingTicketCacheEntry struct { + ticket string + nextFetchAt time.Time + retryDelay time.Duration +} + +type syncCursorFile struct { + GetUpdatesBuf string `json:"get_updates_buf"` +} + +func picoclawHomeDir() string { + if home := os.Getenv(config.EnvHome); home != "" { + return home + } + userHome, _ := os.UserHomeDir() + return filepath.Join(userHome, ".picoclaw") +} + +func buildWeixinSyncBufPath(cfg config.WeixinConfig) string { + key := "default" + token := strings.TrimSpace(cfg.Token) + if token != "" { + sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token)) + key = hex.EncodeToString(sum[:8]) + } + return filepath.Join(picoclawHomeDir(), "channels", "weixin", "sync", key+".json") +} + +func loadGetUpdatesBuf(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + + var decoded syncCursorFile + if err := json.Unmarshal(data, &decoded); err != nil { + return "", err + } + + return decoded.GetUpdatesBuf, nil +} + +func saveGetUpdatesBuf(path, cursor string) error { + data, err := json.Marshal(syncCursorFile{GetUpdatesBuf: cursor}) + if err != nil { + return err + } + return fileutil.WriteFileAtomic(path, data, 0o600) +} + +func (c *WeixinChannel) cdnBaseURL() string { + if base := strings.TrimSpace(c.config.CDNBaseURL); base != "" { + return strings.TrimRight(base, "/") + } + return weixinDefaultCDNBaseURL +} + +func isSessionExpiredStatus(ret, errcode int) bool { + return ret == weixinSessionExpiredCode || errcode == weixinSessionExpiredCode +} + +func (c *WeixinChannel) pauseSession(operation string, ret, errcode int, errmsg string) time.Duration { + c.pauseMu.Lock() + defer c.pauseMu.Unlock() + + until := time.Now().Add(weixinSessionPauseDuration) + if until.After(c.pauseUntil) { + c.pauseUntil = until + } + + remaining := time.Until(c.pauseUntil) + logger.ErrorCF("weixin", "Session expired; pausing Weixin channel", map[string]any{ + "operation": operation, + "ret": ret, + "errcode": errcode, + "errmsg": errmsg, + "until": c.pauseUntil.Format(time.RFC3339), + "minutes": int((remaining + time.Minute - 1) / time.Minute), + }) + return remaining +} + +func (c *WeixinChannel) remainingPause() time.Duration { + c.pauseMu.Lock() + defer c.pauseMu.Unlock() + + if c.pauseUntil.IsZero() { + return 0 + } + remaining := time.Until(c.pauseUntil) + if remaining <= 0 { + c.pauseUntil = time.Time{} + return 0 + } + return remaining +} + +func (c *WeixinChannel) waitWhileSessionPaused(ctx context.Context) error { + remaining := c.remainingPause() + if remaining <= 0 { + return nil + } + + timer := time.NewTimer(remaining) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func (c *WeixinChannel) ensureSessionActive() error { + remaining := c.remainingPause() + if remaining <= 0 { + return nil + } + return fmt.Errorf( + "weixin session paused (%d min remaining): %w", + int((remaining+time.Minute-1)/time.Minute), + basechannels.ErrSendFailed, + ) +} + +func (c *WeixinChannel) getTypingTicket(ctx context.Context, userID string) (string, error) { + now := time.Now() + + c.typingMu.Lock() + entry, ok := c.typingCache[userID] + if ok && now.Before(entry.nextFetchAt) { + ticket := entry.ticket + c.typingMu.Unlock() + return ticket, nil + } + cachedTicket := entry.ticket + retryDelay := entry.retryDelay + c.typingMu.Unlock() + + contextToken := "" + if v, ok := c.contextTokens.Load(userID); ok { + contextToken, _ = v.(string) + } + + resp, err := c.api.GetConfig(ctx, GetConfigReq{ + IlinkUserID: userID, + ContextToken: contextToken, + }) + if err == nil && resp != nil && resp.Ret == 0 && resp.Errcode == 0 { + ticket := strings.TrimSpace(resp.TypingTicket) + c.typingMu.Lock() + c.typingCache[userID] = typingTicketCacheEntry{ + ticket: ticket, + nextFetchAt: now.Add(weixinConfigCacheTTL), + retryDelay: weixinConfigRetryInitial, + } + c.typingMu.Unlock() + return ticket, nil + } + + if resp != nil && isSessionExpiredStatus(resp.Ret, resp.Errcode) { + c.pauseSession("getconfig", resp.Ret, resp.Errcode, resp.Errmsg) + } + + if retryDelay <= 0 { + retryDelay = weixinConfigRetryInitial + } else { + retryDelay *= 2 + if retryDelay > weixinConfigRetryMax { + retryDelay = weixinConfigRetryMax + } + } + + c.typingMu.Lock() + c.typingCache[userID] = typingTicketCacheEntry{ + ticket: cachedTicket, + nextFetchAt: now.Add(retryDelay), + retryDelay: retryDelay, + } + c.typingMu.Unlock() + + if err != nil { + return cachedTicket, err + } + if resp == nil { + return cachedTicket, fmt.Errorf("getconfig returned nil response") + } + return cachedTicket, fmt.Errorf( + "getconfig failed: ret=%d errcode=%d errmsg=%s", + resp.Ret, + resp.Errcode, + resp.Errmsg, + ) +} diff --git a/pkg/channels/weixin/types.go b/pkg/channels/weixin/types.go index 06f80ab89..74c6e63c3 100644 --- a/pkg/channels/weixin/types.go +++ b/pkg/channels/weixin/types.go @@ -5,6 +5,12 @@ type BaseInfo struct { ChannelVersion string `json:"channel_version,omitempty"` } +type APIStatus struct { + Ret int `json:"ret,omitempty"` + Errcode int `json:"errcode,omitempty"` + Errmsg string `json:"errmsg,omitempty"` +} + // UploadMediaType constants const ( UploadMediaTypeImage = 1 @@ -24,11 +30,12 @@ type GetUploadUrlReq struct { ThumbRawfileMD5 string `json:"thumb_rawfilemd5,omitempty"` ThumbFilesize int64 `json:"thumb_filesize,omitempty"` NoNeedThumb bool `json:"no_need_thumb,omitempty"` - Aeskey string `json:"aeskey,omitempty"` // base64 + Aeskey string `json:"aeskey,omitempty"` // hex-encoded 16-byte AES key BaseInfo BaseInfo `json:"base_info,omitempty"` } type GetUploadUrlResp struct { + APIStatus UploadParam string `json:"upload_param,omitempty"` ThumbUploadParam string `json:"thumb_upload_param,omitempty"` } @@ -146,9 +153,7 @@ type GetUpdatesReq struct { } type GetUpdatesResp struct { - Ret int `json:"ret,omitempty"` - Errcode int `json:"errcode,omitempty"` - Errmsg string `json:"errmsg,omitempty"` + APIStatus Msgs []WeixinMessage `json:"msgs,omitempty"` SyncBuf string `json:"sync_buf,omitempty"` GetUpdatesBuf string `json:"get_updates_buf,omitempty"` @@ -161,15 +166,25 @@ type SendMessageReq struct { } type SendMessageResp struct { - // Usually empty + APIStatus +} + +type GetConfigReq struct { + IlinkUserID string `json:"ilink_user_id,omitempty"` + ContextToken string `json:"context_token,omitempty"` + BaseInfo BaseInfo `json:"base_info,omitempty"` } type GetConfigResp struct { - Ret int `json:"ret,omitempty"` - Errmsg string `json:"errmsg,omitempty"` + APIStatus TypingTicket string `json:"typing_ticket,omitempty"` } +const ( + TypingStatusTyping = 1 + TypingStatusCancel = 2 +) + type SendTypingReq struct { IlinkUserID string `json:"ilink_user_id,omitempty"` TypingTicket string `json:"typing_ticket,omitempty"` @@ -177,6 +192,10 @@ type SendTypingReq struct { BaseInfo BaseInfo `json:"base_info,omitempty"` } +type SendTypingResp struct { + APIStatus +} + type QRCodeResponse struct { Qrcode string `json:"qrcode"` QrcodeImgContent string `json:"qrcode_img_content"` diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go index 70a999ab5..43c776f98 100644 --- a/pkg/channels/weixin/weixin.go +++ b/pkg/channels/weixin/weixin.go @@ -27,6 +27,11 @@ type WeixinChannel struct { // contextTokens stores the last context_token per user (from_user_id → context_token). // This is required by the iLink API to associate replies with the right chat session. contextTokens sync.Map + typingMu sync.Mutex + typingCache map[string]typingTicketCacheEntry + pauseMu sync.Mutex + pauseUntil time.Time + syncBufPath string } func init() { @@ -56,6 +61,8 @@ func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*Wei api: api, config: cfg, bus: messageBus, + typingCache: make(map[string]typingTicketCacheEntry), + syncBufPath: buildWeixinSyncBufPath(cfg), }, nil } @@ -87,7 +94,20 @@ func (c *WeixinChannel) pollLoop(ctx context.Context) { ) consecutiveFails := 0 - getUpdatesBuf := "" + getUpdatesBuf, err := loadGetUpdatesBuf(c.syncBufPath) + if err != nil { + logger.WarnCF("weixin", "Failed to load persisted get_updates_buf", map[string]any{ + "path": c.syncBufPath, + "error": err.Error(), + }) + getUpdatesBuf = "" + } else if getUpdatesBuf != "" { + logger.InfoCF("weixin", "Resuming persisted get_updates_buf", map[string]any{ + "path": c.syncBufPath, + "bytes": len(getUpdatesBuf), + "source": "disk", + }) + } nextTimeoutMs := defaultPollTimeoutMs for { @@ -98,6 +118,13 @@ func (c *WeixinChannel) pollLoop(ctx context.Context) { default: } + if err := c.waitWhileSessionPaused(ctx); err != nil { + if ctx.Err() != nil { + return + } + continue + } + // Build a context with timeout slightly longer than the long-poll pollCtx, pollCancel := context.WithTimeout(ctx, time.Duration(nextTimeoutMs+5000)*time.Millisecond) @@ -138,9 +165,17 @@ func (c *WeixinChannel) pollLoop(ctx context.Context) { continue } - // Check for API-level error codes (-14 = session expired) - const sessionExpiredErrcode = -14 - if resp.Errcode != 0 || (resp.Ret != 0 && resp.Ret != sessionExpiredErrcode) { + if isSessionExpiredStatus(resp.Ret, resp.Errcode) { + remaining := c.pauseSession("getupdates", resp.Ret, resp.Errcode, resp.Errmsg) + select { + case <-ctx.Done(): + return + case <-time.After(remaining): + } + continue + } + + if resp.Errcode != 0 || resp.Ret != 0 { consecutiveFails++ logger.ErrorCF("weixin", "getUpdates API error", map[string]any{ "ret": resp.Ret, @@ -155,17 +190,6 @@ func (c *WeixinChannel) pollLoop(ctx context.Context) { continue } - if resp.Errcode == sessionExpiredErrcode || resp.Ret == sessionExpiredErrcode { - logger.ErrorC("weixin", "Session expired — please re-run login") - // Pause for a long time to avoid hammering with a bad token - select { - case <-ctx.Done(): - return - case <-time.After(10 * time.Minute): - } - continue - } - consecutiveFails = 0 // Update the long-poll timeout from server hint @@ -176,6 +200,12 @@ func (c *WeixinChannel) pollLoop(ctx context.Context) { // Advance cursor if resp.GetUpdatesBuf != "" { getUpdatesBuf = resp.GetUpdatesBuf + if err := saveGetUpdatesBuf(c.syncBufPath, getUpdatesBuf); err != nil { + logger.WarnCF("weixin", "Failed to persist get_updates_buf", map[string]any{ + "path": c.syncBufPath, + "error": err.Error(), + }) + } } // Dispatch messages @@ -192,6 +222,11 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess return } + messageID := msg.ClientID + if messageID == "" { + messageID = uuid.New().String() + } + // Build text content from item_list var parts []string for _, item := range msg.ItemList { @@ -205,7 +240,7 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess // Use voice → text transcription from server parts = append(parts, item.VoiceItem.Text) } else { - parts = append(parts, "[voice message]") + parts = append(parts, "[audio]") } case MessageItemTypeImage: parts = append(parts, "[image]") @@ -220,8 +255,23 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess } } + var mediaRefs []string + if mediaItem := selectInboundMediaItem(msg); mediaItem != nil { + ref, err := c.downloadMediaFromItem(ctx, fromUserID, messageID, mediaItem) + if err != nil { + logger.ErrorCF("weixin", "Failed to download inbound media", map[string]any{ + "from_user_id": fromUserID, + "message_id": messageID, + "type": mediaItem.Type, + "error": err.Error(), + }) + } else if ref != "" { + mediaRefs = append(mediaRefs, ref) + } + } + content := strings.Join(parts, "\n") - if content == "" { + if content == "" && len(mediaRefs) == 0 { return } @@ -240,11 +290,6 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess return } - messageID := msg.ClientID - if messageID == "" { - messageID = uuid.New().String() - } - peer := bus.Peer{Kind: "direct", ID: fromUserID} metadata := map[string]string{ @@ -256,6 +301,7 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess logger.DebugCF("weixin", "Received message", map[string]any{ "from_user_id": fromUserID, "content_len": len(content), + "media_count": len(mediaRefs), }) // Store context_token for outbound reply association @@ -263,7 +309,7 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess c.contextTokens.Store(fromUserID, msg.ContextToken) } - c.HandleMessage(ctx, peer, messageID, fromUserID, fromUserID, content, nil, metadata, sender) + c.HandleMessage(ctx, peer, messageID, fromUserID, fromUserID, content, mediaRefs, metadata, sender) } // Send implements channels.Channel by sending a text message to the WeChat user. @@ -271,6 +317,9 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error if !c.IsRunning() { return channels.ErrNotRunning } + if err := c.ensureSessionActive(); err != nil { + return err + } if msg.Content == "" { return nil @@ -294,32 +343,15 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error }) return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID) } - clientID := "picoclaw-" + uuid.New().String() - req := SendMessageReq{ - Msg: WeixinMessage{ - FromUserID: "", - ToUserID: toUserID, - ClientID: clientID, - MessageType: MessageTypeBot, - MessageState: MessageStateFinish, - ItemList: []MessageItem{ - { - Type: MessageItemTypeText, - TextItem: &TextItem{ - Text: msg.Content, - }, - }, - }, - ContextToken: contextToken, - }, - } - - if err := c.api.SendMessage(ctx, req); err != nil { + if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil { logger.ErrorCF("weixin", "Failed to send message", map[string]any{ "to_user_id": toUserID, "error": err.Error(), }) + if c.remainingPause() > 0 { + return fmt.Errorf("weixin send: %w", channels.ErrSendFailed) + } return fmt.Errorf("weixin send: %w", channels.ErrTemporary) } diff --git a/pkg/config/config.go b/pkg/config/config.go index ab981998b..3def60a25 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -504,6 +504,7 @@ type WeixinConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"` BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"` + CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 0d2141ae1..ee295931a 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -174,6 +174,14 @@ func DefaultConfig() *Config { WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?", ProcessingMessage: DefaultWeComAIBotProcessingMessage, }, + Weixin: WeixinConfig{ + Enabled: false, + Token: "", + BaseURL: "https://ilinkai.weixin.qq.com/", + CDNBaseURL: "https://novac2c.cdn.weixin.qq.com/c2c", + AllowFrom: FlexibleStringSlice{}, + Proxy: "", + }, Pico: PicoConfig{ Enabled: false, Token: "",