feat(wecom): send media via temp uploads
This commit is contained in:
parent
e760cb737c
commit
c3631d84ba
4 changed files with 1037 additions and 37 deletions
|
|
@ -4,7 +4,10 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
|
"crypto/md5"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
|
|
@ -13,12 +16,73 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/h2non/filetype"
|
"github.com/h2non/filetype"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wecomOutboundMediaMaxBytes = 20 << 20
|
||||||
|
wecomOutboundImageMaxBytes = 2 << 20
|
||||||
|
wecomOutboundVoiceMaxBytes = 2 << 20
|
||||||
|
wecomOutboundVideoMaxBytes = 10 << 20
|
||||||
|
wecomUploadChunkMaxBytes = 512 << 10
|
||||||
|
wecomUploadMaxChunks = 100
|
||||||
|
wecomUploadMinBytes = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
type wecomOutboundMedia struct {
|
||||||
|
MsgType string
|
||||||
|
MediaID string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *wecomOutboundMedia) respondBody() wecomRespondMsgBody {
|
||||||
|
body := wecomRespondMsgBody{MsgType: m.MsgType}
|
||||||
|
switch m.MsgType {
|
||||||
|
case "file":
|
||||||
|
body.File = &wecomMediaRefContent{MediaID: m.MediaID}
|
||||||
|
case "image":
|
||||||
|
body.Image = &wecomMediaRefContent{MediaID: m.MediaID}
|
||||||
|
case "voice":
|
||||||
|
body.Voice = &wecomMediaRefContent{MediaID: m.MediaID}
|
||||||
|
case "video":
|
||||||
|
body.Video = &wecomVideoContent{
|
||||||
|
MediaID: m.MediaID,
|
||||||
|
Title: m.Title,
|
||||||
|
Description: m.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *wecomOutboundMedia) sendBody(chatID string, chatType uint32) wecomSendMsgBody {
|
||||||
|
body := wecomSendMsgBody{
|
||||||
|
ChatID: chatID,
|
||||||
|
ChatType: chatType,
|
||||||
|
MsgType: m.MsgType,
|
||||||
|
}
|
||||||
|
switch m.MsgType {
|
||||||
|
case "file":
|
||||||
|
body.File = &wecomMediaRefContent{MediaID: m.MediaID}
|
||||||
|
case "image":
|
||||||
|
body.Image = &wecomMediaRefContent{MediaID: m.MediaID}
|
||||||
|
case "voice":
|
||||||
|
body.Voice = &wecomMediaRefContent{MediaID: m.MediaID}
|
||||||
|
case "video":
|
||||||
|
body.Video = &wecomVideoContent{
|
||||||
|
MediaID: m.MediaID,
|
||||||
|
Title: m.Title,
|
||||||
|
Description: m.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
func decodeMediaAESKey(value string) ([]byte, error) {
|
func decodeMediaAESKey(value string) ([]byte, error) {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
@ -227,12 +291,11 @@ func (c *WeComChannel) storeRemoteMedia(
|
||||||
return "", fmt.Errorf("download media returned HTTP %d", resp.StatusCode)
|
return "", fmt.Errorf("download media returned HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSize = 20 << 20
|
data, err := io.ReadAll(io.LimitReader(resp.Body, wecomOutboundMediaMaxBytes+1))
|
||||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read media: %w", err)
|
return "", fmt.Errorf("read media: %w", err)
|
||||||
}
|
}
|
||||||
if len(data) > maxSize {
|
if len(data) > wecomOutboundMediaMaxBytes {
|
||||||
return "", fmt.Errorf("media too large")
|
return "", fmt.Errorf("media too large")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,3 +352,447 @@ func (c *WeComChannel) storeRemoteMedia(
|
||||||
}
|
}
|
||||||
return ref, nil
|
return ref, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectLocalWeComContentType(localPath, hint string) string {
|
||||||
|
contentType := normalizeWeComContentType(hint)
|
||||||
|
if !isGenericWeComContentType(contentType) {
|
||||||
|
return contentType
|
||||||
|
}
|
||||||
|
|
||||||
|
if kind, err := filetype.MatchFile(localPath); err == nil && kind != filetype.Unknown {
|
||||||
|
return normalizeWeComContentType(kind.MIME.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext := strings.ToLower(filepath.Ext(localPath)); ext != "" {
|
||||||
|
if byExt := normalizeWeComContentType(mime.TypeByExtension(ext)); byExt != "" {
|
||||||
|
return byExt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return contentType
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
n, err := file.Read(buf)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return contentType
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return contentType
|
||||||
|
}
|
||||||
|
return normalizeWeComContentType(http.DetectContentType(buf[:n]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeWeComTempFile(prefix, filename string, data []byte) (string, error) {
|
||||||
|
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
|
||||||
|
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
|
||||||
|
return "", fmt.Errorf("mkdir media dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
tmpFile, err := os.CreateTemp(mediaDir, prefix+"-*"+ext)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
|
if _, err := tmpFile.Write(data); err != nil {
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("write temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmpFile.Close(); err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("close temp file: %w", err)
|
||||||
|
}
|
||||||
|
return tmpPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeComChannel) downloadRemoteMediaToTemp(
|
||||||
|
ctx context.Context,
|
||||||
|
resourceURL, fallbackName string,
|
||||||
|
) (string, string, string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.mediaClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("download media: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return "", "", "", fmt.Errorf("download media returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, wecomOutboundMediaMaxBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("read media: %w", err)
|
||||||
|
}
|
||||||
|
if len(data) > wecomOutboundMediaMaxBytes {
|
||||||
|
return "", "", "", fmt.Errorf("media too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
filename, contentType := detectWeComMediaMetadata(
|
||||||
|
data,
|
||||||
|
fallbackName,
|
||||||
|
resp.Header.Get("Content-Type"),
|
||||||
|
resourceURL,
|
||||||
|
resp.Header.Get("Content-Disposition"),
|
||||||
|
)
|
||||||
|
tmpPath, err := writeWeComTempFile("wecom-outbound", filename, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
return tmpPath, filename, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeComChannel) resolveOutboundPart(
|
||||||
|
ctx context.Context,
|
||||||
|
part bus.MediaPart,
|
||||||
|
) (string, string, string, func(), error) {
|
||||||
|
cleanup := func() {}
|
||||||
|
filename := sanitizeWeComFilename(part.Filename)
|
||||||
|
contentType := normalizeWeComContentType(part.ContentType)
|
||||||
|
ref := strings.TrimSpace(part.Ref)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case ref == "":
|
||||||
|
return "", filename, contentType, cleanup, nil
|
||||||
|
|
||||||
|
case strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://"):
|
||||||
|
localPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, ref, filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", cleanup, err
|
||||||
|
}
|
||||||
|
return localPath, name, ct, func() { _ = os.Remove(localPath) }, nil
|
||||||
|
|
||||||
|
case strings.HasPrefix(ref, "media://"):
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return "", "", "", cleanup, fmt.Errorf("no media store available")
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath, meta, err := store.ResolveWithMeta(ref)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", cleanup, err
|
||||||
|
}
|
||||||
|
if filename == "" {
|
||||||
|
filename = sanitizeWeComFilename(meta.Filename)
|
||||||
|
}
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = normalizeWeComContentType(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 _, err := os.Stat(localPath); err != nil {
|
||||||
|
return "", "", "", cleanup, err
|
||||||
|
}
|
||||||
|
if filename == "" {
|
||||||
|
filename = sanitizeWeComFilename(filepath.Base(localPath))
|
||||||
|
}
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = detectLocalWeComContentType(localPath, "")
|
||||||
|
}
|
||||||
|
return localPath, filename, contentType, cleanup, nil
|
||||||
|
|
||||||
|
case strings.HasPrefix(ref, "file://"):
|
||||||
|
u, err := url.Parse(ref)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", cleanup, err
|
||||||
|
}
|
||||||
|
localPath := u.Path
|
||||||
|
if _, err := os.Stat(localPath); err != nil {
|
||||||
|
return "", "", "", cleanup, err
|
||||||
|
}
|
||||||
|
if filename == "" {
|
||||||
|
filename = sanitizeWeComFilename(filepath.Base(localPath))
|
||||||
|
}
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = detectLocalWeComContentType(localPath, "")
|
||||||
|
}
|
||||||
|
return localPath, filename, contentType, cleanup, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
if _, err := os.Stat(ref); err != nil {
|
||||||
|
return "", "", "", cleanup, err
|
||||||
|
}
|
||||||
|
if filename == "" {
|
||||||
|
filename = sanitizeWeComFilename(filepath.Base(ref))
|
||||||
|
}
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = detectLocalWeComContentType(ref, "")
|
||||||
|
}
|
||||||
|
return ref, filename, contentType, cleanup, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canWeComSendImage(contentType, ext string, size int64) bool {
|
||||||
|
if size > wecomOutboundImageMaxBytes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch normalizeWeComContentType(contentType) {
|
||||||
|
case "image/jpeg", "image/jpg", "image/png", "image/gif":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch strings.ToLower(ext) {
|
||||||
|
case ".jpg", ".jpeg", ".png", ".gif":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canWeComSendVoice(contentType, ext string, size int64) bool {
|
||||||
|
if size > wecomOutboundVoiceMaxBytes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
contentType = normalizeWeComContentType(contentType)
|
||||||
|
return strings.Contains(contentType, "amr") || strings.EqualFold(ext, ".amr")
|
||||||
|
}
|
||||||
|
|
||||||
|
func canWeComSendVideo(contentType, ext string, size int64) bool {
|
||||||
|
if size > wecomOutboundVideoMaxBytes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return normalizeWeComContentType(contentType) == "video/mp4" || strings.EqualFold(ext, ".mp4")
|
||||||
|
}
|
||||||
|
|
||||||
|
func outboundWeComMediaKind(partType, filename, contentType string, size int64) string {
|
||||||
|
if size < wecomUploadMinBytes {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
partType = strings.ToLower(strings.TrimSpace(partType))
|
||||||
|
contentType = normalizeWeComContentType(contentType)
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
|
||||||
|
if partType == "file" {
|
||||||
|
if size <= wecomOutboundMediaMaxBytes {
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partType == "image" || partType == "") && canWeComSendImage(contentType, ext, size) {
|
||||||
|
return "image"
|
||||||
|
}
|
||||||
|
if (partType == "audio" || partType == "voice" || partType == "") && canWeComSendVoice(contentType, ext, size) {
|
||||||
|
return "voice"
|
||||||
|
}
|
||||||
|
if (partType == "video" || partType == "") && canWeComSendVideo(contentType, ext, size) {
|
||||||
|
return "video"
|
||||||
|
}
|
||||||
|
if size <= wecomOutboundMediaMaxBytes {
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimWeComBytes(value string, limit int) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if limit <= 0 || len(value) <= limit {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
size := 0
|
||||||
|
var out strings.Builder
|
||||||
|
for _, r := range value {
|
||||||
|
width := len(string(r))
|
||||||
|
if size+width > limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
size += width
|
||||||
|
out.WriteRune(r)
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureWeComOutboundFilename(filename, localPath, contentType string) string {
|
||||||
|
filename = sanitizeWeComFilename(filename)
|
||||||
|
if filename == "" {
|
||||||
|
filename = sanitizeWeComFilename(filepath.Base(localPath))
|
||||||
|
}
|
||||||
|
if filename == "" {
|
||||||
|
filename = "media"
|
||||||
|
}
|
||||||
|
if filepath.Ext(filename) == "" {
|
||||||
|
fallbackExt := inferMediaExt(contentType, strings.ToLower(filepath.Ext(localPath)))
|
||||||
|
if fallbackExt != "" {
|
||||||
|
filename += fallbackExt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filename = trimWeComBytes(filename, 256)
|
||||||
|
if filename == "" {
|
||||||
|
return "media"
|
||||||
|
}
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWeComVideoContent(mediaID, filename, description string) *wecomVideoContent {
|
||||||
|
title := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
|
title = trimWeComBytes(title, 64)
|
||||||
|
if title == "" {
|
||||||
|
title = "video"
|
||||||
|
}
|
||||||
|
description = trimWeComBytes(description, 512)
|
||||||
|
return &wecomVideoContent{
|
||||||
|
MediaID: mediaID,
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeWeComEnvelopeBody[T any](env wecomEnvelope) (T, error) {
|
||||||
|
var out T
|
||||||
|
if len(env.Body) == 0 {
|
||||||
|
return out, fmt.Errorf("wecom response body is empty")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(env.Body, &out); err != nil {
|
||||||
|
return out, fmt.Errorf("decode wecom response body: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeComChannel) uploadOutboundMedia(
|
||||||
|
ctx context.Context,
|
||||||
|
localPath, filename, contentType string,
|
||||||
|
part bus.MediaPart,
|
||||||
|
) (*wecomOutboundMedia, error) {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
contentType = detectLocalWeComContentType(localPath, contentType)
|
||||||
|
filename = ensureWeComOutboundFilename(filename, localPath, contentType)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read media file: %w", err)
|
||||||
|
}
|
||||||
|
size := int64(len(data))
|
||||||
|
kind := outboundWeComMediaKind(part.Type, filename, contentType, size)
|
||||||
|
if kind == "" {
|
||||||
|
return nil, fmt.Errorf("unsupported wecom media type or size for %q", filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalChunks := (len(data) + wecomUploadChunkMaxBytes - 1) / wecomUploadChunkMaxBytes
|
||||||
|
if totalChunks <= 0 || totalChunks > wecomUploadMaxChunks {
|
||||||
|
return nil, fmt.Errorf("wecom upload requires 1-%d chunks, got %d", wecomUploadMaxChunks, totalChunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := md5.Sum(data)
|
||||||
|
initEnv, err := c.sendCommandAck(wecomCommand{
|
||||||
|
Cmd: wecomCmdUploadMediaInit,
|
||||||
|
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||||
|
Body: wecomUploadMediaInitBody{
|
||||||
|
Type: kind,
|
||||||
|
Filename: filename,
|
||||||
|
TotalSize: size,
|
||||||
|
TotalChunks: totalChunks,
|
||||||
|
MD5: hex.EncodeToString(sum[:]),
|
||||||
|
},
|
||||||
|
}, wecomUploadTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
initResp, err := decodeWeComEnvelopeBody[wecomUploadMediaInitResponse](initEnv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(initResp.UploadID) == "" {
|
||||||
|
return nil, fmt.Errorf("wecom upload init returned empty upload_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx, offset := 0, 0; offset < len(data); idx, offset = idx+1, offset+wecomUploadChunkMaxBytes {
|
||||||
|
end := offset + wecomUploadChunkMaxBytes
|
||||||
|
if end > len(data) {
|
||||||
|
end = len(data)
|
||||||
|
}
|
||||||
|
if err := c.sendCommand(wecomCommand{
|
||||||
|
Cmd: wecomCmdUploadMediaChunk,
|
||||||
|
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||||
|
Body: wecomUploadMediaChunkBody{
|
||||||
|
UploadID: initResp.UploadID,
|
||||||
|
ChunkIndex: idx,
|
||||||
|
Base64Data: base64.StdEncoding.EncodeToString(data[offset:end]),
|
||||||
|
},
|
||||||
|
}, wecomUploadTimeout); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finishEnv, err := c.sendCommandAck(wecomCommand{
|
||||||
|
Cmd: wecomCmdUploadMediaEnd,
|
||||||
|
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||||
|
Body: wecomUploadMediaFinishBody{
|
||||||
|
UploadID: initResp.UploadID,
|
||||||
|
},
|
||||||
|
}, wecomUploadTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
finishResp, err := decodeWeComEnvelopeBody[wecomUploadMediaFinishResponse](finishEnv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(finishResp.MediaID) == "" {
|
||||||
|
return nil, fmt.Errorf("wecom upload finish returned empty media_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
uploaded := &wecomOutboundMedia{
|
||||||
|
MsgType: kind,
|
||||||
|
MediaID: finishResp.MediaID,
|
||||||
|
}
|
||||||
|
if kind == "video" {
|
||||||
|
video := buildWeComVideoContent(finishResp.MediaID, filename, part.Caption)
|
||||||
|
uploaded.Title = video.Title
|
||||||
|
uploaded.Description = video.Description
|
||||||
|
}
|
||||||
|
return uploaded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackWeComMediaText(part bus.MediaPart, kind, filename string) string {
|
||||||
|
var lines []string
|
||||||
|
if caption := strings.TrimSpace(part.Caption); caption != "" {
|
||||||
|
lines = append(lines, caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
label := kind
|
||||||
|
if label == "" {
|
||||||
|
label = "media"
|
||||||
|
}
|
||||||
|
if filename != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("[%s: %s]", label, filename))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, fmt.Sprintf("[%s attachment]", label))
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := strings.TrimSpace(part.Ref)
|
||||||
|
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
|
||||||
|
lines = append(lines, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeComChannel) resolveMediaRoute(chatID string) (wecomTurn, uint32, bool) {
|
||||||
|
if turn, ok := c.getTurn(chatID); ok {
|
||||||
|
if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration {
|
||||||
|
return turn, turn.ChatType, true
|
||||||
|
}
|
||||||
|
c.deleteTurn(chatID)
|
||||||
|
}
|
||||||
|
if route, ok := c.routes.Get(chatID); ok {
|
||||||
|
return wecomTurn{ChatID: route.ChatID, ChatType: route.ChatType}, route.ChatType, false
|
||||||
|
}
|
||||||
|
return wecomTurn{ChatID: chatID}, 0, false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ const (
|
||||||
wecomCmdEventCallback = "aibot_event_callback"
|
wecomCmdEventCallback = "aibot_event_callback"
|
||||||
wecomCmdRespondMsg = "aibot_respond_msg"
|
wecomCmdRespondMsg = "aibot_respond_msg"
|
||||||
wecomCmdSendMsg = "aibot_send_msg"
|
wecomCmdSendMsg = "aibot_send_msg"
|
||||||
|
wecomCmdUploadMediaInit = "aibot_upload_media_init"
|
||||||
|
wecomCmdUploadMediaChunk = "aibot_upload_media_chunk"
|
||||||
|
wecomCmdUploadMediaEnd = "aibot_upload_media_finish"
|
||||||
wecomMaxContentBytes = 20480
|
wecomMaxContentBytes = 20480
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -32,15 +35,26 @@ type wecomCommand struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type wecomSendMsgBody struct {
|
type wecomSendMsgBody struct {
|
||||||
ChatID string `json:"chatid"`
|
ChatID string `json:"chatid"`
|
||||||
ChatType uint32 `json:"chat_type,omitempty"`
|
ChatType uint32 `json:"chat_type,omitempty"`
|
||||||
MsgType string `json:"msgtype"`
|
MsgType string `json:"msgtype"`
|
||||||
Markdown *wecomMarkdownContent `json:"markdown,omitempty"`
|
Markdown *wecomMarkdownContent `json:"markdown,omitempty"`
|
||||||
|
File *wecomMediaRefContent `json:"file,omitempty"`
|
||||||
|
Image *wecomMediaRefContent `json:"image,omitempty"`
|
||||||
|
Voice *wecomMediaRefContent `json:"voice,omitempty"`
|
||||||
|
Video *wecomVideoContent `json:"video,omitempty"`
|
||||||
|
TemplateCard map[string]any `json:"template_card,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type wecomRespondMsgBody struct {
|
type wecomRespondMsgBody struct {
|
||||||
MsgType string `json:"msgtype"`
|
MsgType string `json:"msgtype"`
|
||||||
Stream *wecomStreamContent `json:"stream,omitempty"`
|
Stream *wecomStreamContent `json:"stream,omitempty"`
|
||||||
|
Markdown *wecomMarkdownContent `json:"markdown,omitempty"`
|
||||||
|
File *wecomMediaRefContent `json:"file,omitempty"`
|
||||||
|
Image *wecomMediaRefContent `json:"image,omitempty"`
|
||||||
|
Voice *wecomMediaRefContent `json:"voice,omitempty"`
|
||||||
|
Video *wecomVideoContent `json:"video,omitempty"`
|
||||||
|
TemplateCard map[string]any `json:"template_card,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type wecomStreamContent struct {
|
type wecomStreamContent struct {
|
||||||
|
|
@ -53,6 +67,44 @@ type wecomMarkdownContent struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type wecomMediaRefContent struct {
|
||||||
|
MediaID string `json:"media_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomVideoContent struct {
|
||||||
|
MediaID string `json:"media_id"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomUploadMediaInitBody struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
TotalSize int64 `json:"total_size"`
|
||||||
|
TotalChunks int `json:"total_chunks"`
|
||||||
|
MD5 string `json:"md5,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomUploadMediaInitResponse struct {
|
||||||
|
UploadID string `json:"upload_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomUploadMediaChunkBody struct {
|
||||||
|
UploadID string `json:"upload_id"`
|
||||||
|
ChunkIndex int `json:"chunk_index"`
|
||||||
|
Base64Data string `json:"base64_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomUploadMediaFinishBody struct {
|
||||||
|
UploadID string `json:"upload_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomUploadMediaFinishResponse struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
MediaID string `json:"media_id"`
|
||||||
|
CreatedAt json.RawMessage `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type wecomIncomingMessage struct {
|
type wecomIncomingMessage struct {
|
||||||
MsgID string `json:"msgid"`
|
MsgID string `json:"msgid"`
|
||||||
AIBotID string `json:"aibotid"`
|
AIBotID string `json:"aibotid"`
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import (
|
||||||
const (
|
const (
|
||||||
wecomConnectTimeout = 15 * time.Second
|
wecomConnectTimeout = 15 * time.Second
|
||||||
wecomCommandTimeout = 10 * time.Second
|
wecomCommandTimeout = 10 * time.Second
|
||||||
|
wecomUploadTimeout = 30 * time.Second
|
||||||
wecomHeartbeatInterval = 30 * time.Second
|
wecomHeartbeatInterval = 30 * time.Second
|
||||||
wecomStreamMaxDuration = 5*time.Minute + 30*time.Second
|
wecomStreamMaxDuration = 5*time.Minute + 30*time.Second
|
||||||
wecomRouteTTL = 30 * time.Minute
|
wecomRouteTTL = 30 * time.Minute
|
||||||
|
|
@ -49,7 +50,7 @@ type WeComChannel struct {
|
||||||
recent *recentMessageSet
|
recent *recentMessageSet
|
||||||
routes *reqIDStore
|
routes *reqIDStore
|
||||||
mediaClient *http.Client
|
mediaClient *http.Client
|
||||||
commandSend func(wecomCommand, time.Duration) error
|
commandSend func(wecomCommand, time.Duration) (wecomEnvelope, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type wecomTurn struct {
|
type wecomTurn struct {
|
||||||
|
|
@ -187,22 +188,74 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
var parts []string
|
|
||||||
|
route, chatType, hasTurn := c.resolveMediaRoute(msg.ChatID)
|
||||||
|
chatID := route.ChatID
|
||||||
|
if chatID == "" {
|
||||||
|
chatID = msg.ChatID
|
||||||
|
}
|
||||||
|
|
||||||
for _, part := range msg.Parts {
|
for _, part := range msg.Parts {
|
||||||
switch {
|
if strings.TrimSpace(part.Ref) == "" {
|
||||||
case part.Caption != "":
|
if caption := strings.TrimSpace(part.Caption); caption != "" {
|
||||||
parts = append(parts, part.Caption)
|
if err := c.sendActivePush(chatID, chatType, caption); err != nil {
|
||||||
case part.Filename != "":
|
return err
|
||||||
parts = append(parts, fmt.Sprintf("[media: %s]", part.Filename))
|
}
|
||||||
default:
|
}
|
||||||
parts = append(parts, "[media attachments are not yet supported]")
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func() {
|
||||||
|
if cleanup != nil {
|
||||||
|
defer cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
uploaded, uploadErr := c.uploadOutboundMedia(ctx, localPath, filename, contentType, part)
|
||||||
|
if uploadErr != nil {
|
||||||
|
logger.WarnCF("wecom", "Falling back to placeholder after media upload failure", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"ref": part.Ref,
|
||||||
|
"filename": filename,
|
||||||
|
"content_type": contentType,
|
||||||
|
"error": uploadErr.Error(),
|
||||||
|
})
|
||||||
|
if hasTurn {
|
||||||
|
if finishErr := c.sendStreamChunk(route, true, ""); finishErr != nil {
|
||||||
|
err = finishErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.deleteTurn(msg.ChatID)
|
||||||
|
hasTurn = false
|
||||||
|
}
|
||||||
|
err = c.sendActivePush(chatID, chatType, fallbackWeComMediaText(part, "", filename))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasTurn {
|
||||||
|
err = c.sendTurnMedia(route, uploaded)
|
||||||
|
c.deleteTurn(msg.ChatID)
|
||||||
|
hasTurn = false
|
||||||
|
} else {
|
||||||
|
err = c.sendActiveMedia(chatID, chatType, uploaded)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if caption := strings.TrimSpace(part.Caption); caption != "" {
|
||||||
|
err = c.sendActivePush(chatID, chatType, caption)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return c.Send(ctx, bus.OutboundMessage{
|
|
||||||
Channel: msg.Channel,
|
return nil
|
||||||
ChatID: msg.ChatID,
|
|
||||||
Content: strings.Join(parts, "\n"),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WeComChannel) connectLoop() {
|
func (c *WeComChannel) connectLoop() {
|
||||||
|
|
@ -620,6 +673,20 @@ func (c *WeComChannel) sendStreamChunk(turn wecomTurn, finish bool, content stri
|
||||||
}, wecomCommandTimeout)
|
}, wecomCommandTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WeComChannel) sendTurnMedia(turn wecomTurn, uploaded *wecomOutboundMedia) error {
|
||||||
|
if uploaded == nil {
|
||||||
|
return fmt.Errorf("wecom outbound media is nil: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
if err := c.sendCommand(wecomCommand{
|
||||||
|
Cmd: wecomCmdRespondMsg,
|
||||||
|
Headers: wecomHeaders{ReqID: turn.ReqID},
|
||||||
|
Body: uploaded.respondBody(),
|
||||||
|
}, wecomCommandTimeout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.sendStreamChunk(turn, true, "")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WeComChannel) sendActivePush(chatID string, chatType uint32, content string) error {
|
func (c *WeComChannel) sendActivePush(chatID string, chatType uint32, content string) error {
|
||||||
if strings.TrimSpace(chatID) == "" {
|
if strings.TrimSpace(chatID) == "" {
|
||||||
return fmt.Errorf("empty chat ID: %w", channels.ErrSendFailed)
|
return fmt.Errorf("empty chat ID: %w", channels.ErrSendFailed)
|
||||||
|
|
@ -641,24 +708,57 @@ func (c *WeComChannel) sendActivePush(chatID string, chatType uint32, content st
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WeComChannel) sendActiveMedia(chatID string, chatType uint32, uploaded *wecomOutboundMedia) error {
|
||||||
|
if strings.TrimSpace(chatID) == "" {
|
||||||
|
return fmt.Errorf("empty chat ID: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
if uploaded == nil {
|
||||||
|
return fmt.Errorf("wecom outbound media is nil: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
return c.sendCommand(wecomCommand{
|
||||||
|
Cmd: wecomCmdSendMsg,
|
||||||
|
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||||
|
Body: uploaded.sendBody(chatID, chatType),
|
||||||
|
}, wecomCommandTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WeComChannel) sendCommand(cmd wecomCommand, timeout time.Duration) error {
|
func (c *WeComChannel) sendCommand(cmd wecomCommand, timeout time.Duration) error {
|
||||||
|
_, err := c.sendCommandAck(cmd, timeout)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeComChannel) sendCommandAck(cmd wecomCommand, timeout time.Duration) (wecomEnvelope, error) {
|
||||||
if c.commandSend != nil {
|
if c.commandSend != nil {
|
||||||
return c.commandSend(cmd, timeout)
|
return c.commandSend(cmd, timeout)
|
||||||
}
|
}
|
||||||
return c.writeCurrent(cmd, timeout)
|
return c.writeCurrentAck(cmd, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WeComChannel) writeCurrent(cmd wecomCommand, timeout time.Duration) error {
|
func (c *WeComChannel) writeCurrent(cmd wecomCommand, timeout time.Duration) error {
|
||||||
|
_, err := c.writeCurrentAck(cmd, timeout)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeComChannel) writeCurrentAck(cmd wecomCommand, timeout time.Duration) (wecomEnvelope, error) {
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
conn := c.conn
|
conn := c.conn
|
||||||
c.connMu.Unlock()
|
c.connMu.Unlock()
|
||||||
if conn == nil {
|
if conn == nil {
|
||||||
return fmt.Errorf("wecom websocket not connected: %w", channels.ErrTemporary)
|
return wecomEnvelope{}, fmt.Errorf("wecom websocket not connected: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
return c.writeAndWait(conn, cmd, timeout)
|
return c.writeAndWaitAck(conn, cmd, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WeComChannel) writeAndWait(conn *websocket.Conn, cmd wecomCommand, timeout time.Duration) error {
|
func (c *WeComChannel) writeAndWait(conn *websocket.Conn, cmd wecomCommand, timeout time.Duration) error {
|
||||||
|
_, err := c.writeAndWaitAck(conn, cmd, timeout)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeComChannel) writeAndWaitAck(
|
||||||
|
conn *websocket.Conn,
|
||||||
|
cmd wecomCommand,
|
||||||
|
timeout time.Duration,
|
||||||
|
) (wecomEnvelope, error) {
|
||||||
if cmd.Headers.ReqID == "" {
|
if cmd.Headers.ReqID == "" {
|
||||||
cmd.Headers.ReqID = randomID(10)
|
cmd.Headers.ReqID = randomID(10)
|
||||||
}
|
}
|
||||||
|
|
@ -674,13 +774,13 @@ func (c *WeComChannel) writeAndWait(conn *websocket.Conn, cmd wecomCommand, time
|
||||||
|
|
||||||
data, err := json.Marshal(cmd)
|
data, err := json.Marshal(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %v", channels.ErrSendFailed, err)
|
return wecomEnvelope{}, fmt.Errorf("%w: %v", channels.ErrSendFailed, err)
|
||||||
}
|
}
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
err = conn.WriteMessage(websocket.TextMessage, data)
|
err = conn.WriteMessage(websocket.TextMessage, data)
|
||||||
c.connMu.Unlock()
|
c.connMu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %v", channels.ErrTemporary, err)
|
return wecomEnvelope{}, fmt.Errorf("%w: %v", channels.ErrTemporary, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
timer := time.NewTimer(timeout)
|
timer := time.NewTimer(timeout)
|
||||||
|
|
@ -688,13 +788,13 @@ func (c *WeComChannel) writeAndWait(conn *websocket.Conn, cmd wecomCommand, time
|
||||||
select {
|
select {
|
||||||
case env := <-waitCh:
|
case env := <-waitCh:
|
||||||
if env.ErrCode != 0 {
|
if env.ErrCode != 0 {
|
||||||
return fmt.Errorf("%w: wecom errcode=%d errmsg=%s", channels.ErrTemporary, env.ErrCode, env.ErrMsg)
|
return wecomEnvelope{}, fmt.Errorf("%w: wecom errcode=%d errmsg=%s", channels.ErrTemporary, env.ErrCode, env.ErrMsg)
|
||||||
}
|
}
|
||||||
return nil
|
return env, nil
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
return fmt.Errorf("%w: timeout waiting for WeCom ack", channels.ErrTemporary)
|
return wecomEnvelope{}, fmt.Errorf("%w: timeout waiting for WeCom ack", channels.ErrTemporary)
|
||||||
case <-c.ctx.Done():
|
case <-c.ctx.Done():
|
||||||
return c.ctx.Err()
|
return wecomEnvelope{}, c.ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@ package wecom
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDispatchIncoming_UsesActualChatIDAndStoresReqIDRoute(t *testing.T) {
|
func TestDispatchIncoming_UsesActualChatIDAndStoresReqIDRoute(t *testing.T) {
|
||||||
|
|
@ -18,9 +21,9 @@ func TestDispatchIncoming_UsesActualChatIDAndStoresReqIDRoute(t *testing.T) {
|
||||||
ch := newTestWeComChannel(t, messageBus)
|
ch := newTestWeComChannel(t, messageBus)
|
||||||
|
|
||||||
var commands []wecomCommand
|
var commands []wecomCommand
|
||||||
ch.commandSend = func(cmd wecomCommand, _ time.Duration) error {
|
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||||
commands = append(commands, cmd)
|
commands = append(commands, cmd)
|
||||||
return nil
|
return wecomTestAck(nil), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := wecomIncomingMessage{
|
msg := wecomIncomingMessage{
|
||||||
|
|
@ -107,12 +110,12 @@ func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var commands []wecomCommand
|
var commands []wecomCommand
|
||||||
ch.commandSend = func(cmd wecomCommand, _ time.Duration) error {
|
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||||
commands = append(commands, cmd)
|
commands = append(commands, cmd)
|
||||||
if len(commands) == 1 && cmd.Cmd == wecomCmdRespondMsg {
|
if len(commands) == 1 && cmd.Cmd == wecomCmdRespondMsg {
|
||||||
return errors.New("stream send failed")
|
return wecomEnvelope{}, errors.New("stream send failed")
|
||||||
}
|
}
|
||||||
return nil
|
return wecomTestAck(nil), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ch.Send(context.Background(), bus.OutboundMessage{
|
if err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
|
@ -152,6 +155,301 @@ func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_SendsActiveImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ch := newTestWeComChannel(t, bus.NewMessageBus())
|
||||||
|
ch.SetRunning(true)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
|
||||||
|
imageData := wecomTestJPEGData(t)
|
||||||
|
imagePath := filepath.Join(t.TempDir(), "photo.jpg")
|
||||||
|
if err := os.WriteFile(imagePath, imageData, 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
ref, err := store.Store(imagePath, media.MediaMeta{
|
||||||
|
Filename: "photo.jpg",
|
||||||
|
ContentType: "image/jpeg",
|
||||||
|
Source: "test",
|
||||||
|
CleanupPolicy: media.CleanupPolicyForgetOnly,
|
||||||
|
}, "scope-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var commands []wecomCommand
|
||||||
|
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||||
|
commands = append(commands, cmd)
|
||||||
|
switch cmd.Cmd {
|
||||||
|
case wecomCmdUploadMediaInit:
|
||||||
|
return wecomTestAck(wecomUploadMediaInitResponse{UploadID: "upload-1"}), nil
|
||||||
|
case wecomCmdUploadMediaEnd:
|
||||||
|
return wecomTestAck(wecomUploadMediaFinishResponse{
|
||||||
|
Type: "image",
|
||||||
|
MediaID: "media-1",
|
||||||
|
}), nil
|
||||||
|
default:
|
||||||
|
return wecomTestAck(nil), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
Channel: "wecom",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Parts: []bus.MediaPart{{
|
||||||
|
Ref: ref,
|
||||||
|
Type: "image",
|
||||||
|
Filename: "photo.jpg",
|
||||||
|
ContentType: "image/jpeg",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendMedia() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(commands) != 4 {
|
||||||
|
t.Fatalf("expected 4 commands, got %d", len(commands))
|
||||||
|
}
|
||||||
|
if commands[0].Cmd != wecomCmdUploadMediaInit {
|
||||||
|
t.Fatalf("first command = %q, want %q", commands[0].Cmd, wecomCmdUploadMediaInit)
|
||||||
|
}
|
||||||
|
initBody, ok := commands[0].Body.(wecomUploadMediaInitBody)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected init body type %T", commands[0].Body)
|
||||||
|
}
|
||||||
|
if initBody.Type != "image" || initBody.Filename != "photo.jpg" || initBody.TotalChunks != 1 {
|
||||||
|
t.Fatalf("init body = %+v", initBody)
|
||||||
|
}
|
||||||
|
if commands[1].Cmd != wecomCmdUploadMediaChunk {
|
||||||
|
t.Fatalf("second command = %q, want %q", commands[1].Cmd, wecomCmdUploadMediaChunk)
|
||||||
|
}
|
||||||
|
chunkBody, ok := commands[1].Body.(wecomUploadMediaChunkBody)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected chunk body type %T", commands[1].Body)
|
||||||
|
}
|
||||||
|
if chunkBody.UploadID != "upload-1" || chunkBody.ChunkIndex != 0 || chunkBody.Base64Data == "" {
|
||||||
|
t.Fatalf("chunk body = %+v", chunkBody)
|
||||||
|
}
|
||||||
|
if commands[2].Cmd != wecomCmdUploadMediaEnd {
|
||||||
|
t.Fatalf("third command = %q, want %q", commands[2].Cmd, wecomCmdUploadMediaEnd)
|
||||||
|
}
|
||||||
|
if commands[3].Cmd != wecomCmdSendMsg {
|
||||||
|
t.Fatalf("fourth command = %q, want %q", commands[3].Cmd, wecomCmdSendMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, ok := commands[3].Body.(wecomSendMsgBody)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected send body type %T", commands[3].Body)
|
||||||
|
}
|
||||||
|
if body.MsgType != "image" || body.Image == nil {
|
||||||
|
t.Fatalf("send body = %+v", body)
|
||||||
|
}
|
||||||
|
if body.ChatID != "chat-1" {
|
||||||
|
t.Fatalf("send chatid = %q, want chat-1", body.ChatID)
|
||||||
|
}
|
||||||
|
if body.Image.MediaID != "media-1" {
|
||||||
|
t.Fatalf("image media_id = %q, want media-1", body.Image.MediaID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_UsesTurnImageAndFinishesStream(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ch := newTestWeComChannel(t, bus.NewMessageBus())
|
||||||
|
ch.SetRunning(true)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
|
||||||
|
imageData := wecomTestJPEGData(t)
|
||||||
|
imagePath := filepath.Join(t.TempDir(), "reply.jpg")
|
||||||
|
if err := os.WriteFile(imagePath, imageData, 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
ref, err := store.Store(imagePath, media.MediaMeta{
|
||||||
|
Filename: "reply.jpg",
|
||||||
|
ContentType: "image/jpeg",
|
||||||
|
Source: "test",
|
||||||
|
CleanupPolicy: media.CleanupPolicyForgetOnly,
|
||||||
|
}, "scope-2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.queueTurn("chat-1", wecomTurn{
|
||||||
|
ReqID: "req-1",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
ChatType: 1,
|
||||||
|
StreamID: "stream-1",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if err := ch.routes.Put("chat-1", "req-1", 1, time.Hour); err != nil {
|
||||||
|
t.Fatalf("Put() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var commands []wecomCommand
|
||||||
|
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||||
|
commands = append(commands, cmd)
|
||||||
|
switch cmd.Cmd {
|
||||||
|
case wecomCmdUploadMediaInit:
|
||||||
|
return wecomTestAck(wecomUploadMediaInitResponse{UploadID: "upload-2"}), nil
|
||||||
|
case wecomCmdUploadMediaEnd:
|
||||||
|
return wecomTestAck(wecomUploadMediaFinishResponse{
|
||||||
|
Type: "image",
|
||||||
|
MediaID: "media-2",
|
||||||
|
}), nil
|
||||||
|
default:
|
||||||
|
return wecomTestAck(nil), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
Channel: "wecom",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Parts: []bus.MediaPart{{
|
||||||
|
Ref: ref,
|
||||||
|
Type: "image",
|
||||||
|
Filename: "reply.jpg",
|
||||||
|
ContentType: "image/jpeg",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendMedia() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(commands) != 5 {
|
||||||
|
t.Fatalf("expected 5 commands, got %d", len(commands))
|
||||||
|
}
|
||||||
|
if commands[0].Cmd != wecomCmdUploadMediaInit {
|
||||||
|
t.Fatalf("first command = %+v", commands[0])
|
||||||
|
}
|
||||||
|
if commands[1].Cmd != wecomCmdUploadMediaChunk {
|
||||||
|
t.Fatalf("second command = %+v", commands[1])
|
||||||
|
}
|
||||||
|
if commands[2].Cmd != wecomCmdUploadMediaEnd {
|
||||||
|
t.Fatalf("third command = %+v", commands[2])
|
||||||
|
}
|
||||||
|
if commands[3].Cmd != wecomCmdRespondMsg || commands[3].Headers.ReqID != "req-1" {
|
||||||
|
t.Fatalf("fourth command = %+v", commands[3])
|
||||||
|
}
|
||||||
|
if commands[4].Cmd != wecomCmdRespondMsg || commands[4].Headers.ReqID != "req-1" {
|
||||||
|
t.Fatalf("fifth command = %+v", commands[4])
|
||||||
|
}
|
||||||
|
|
||||||
|
imageBody, ok := commands[3].Body.(wecomRespondMsgBody)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected image body type %T", commands[3].Body)
|
||||||
|
}
|
||||||
|
if imageBody.MsgType != "image" || imageBody.Image == nil {
|
||||||
|
t.Fatalf("image body = %+v", imageBody)
|
||||||
|
}
|
||||||
|
if imageBody.Image.MediaID != "media-2" {
|
||||||
|
t.Fatalf("image media_id = %q, want media-2", imageBody.Image.MediaID)
|
||||||
|
}
|
||||||
|
|
||||||
|
streamBody, ok := commands[4].Body.(wecomRespondMsgBody)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected finish body type %T", commands[4].Body)
|
||||||
|
}
|
||||||
|
if streamBody.MsgType != "stream" || streamBody.Stream == nil || !streamBody.Stream.Finish {
|
||||||
|
t.Fatalf("finish body = %+v", streamBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := ch.getTurn("chat-1"); ok {
|
||||||
|
t.Fatal("expected turn to be removed after media send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_SendsActiveFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ch := newTestWeComChannel(t, bus.NewMessageBus())
|
||||||
|
ch.SetRunning(true)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
|
||||||
|
filePath := filepath.Join(t.TempDir(), "report.pdf")
|
||||||
|
if err := os.WriteFile(filePath, []byte("%PDF-1.4"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
ref, err := store.Store(filePath, media.MediaMeta{
|
||||||
|
Filename: "report.pdf",
|
||||||
|
ContentType: "application/pdf",
|
||||||
|
Source: "test",
|
||||||
|
CleanupPolicy: media.CleanupPolicyForgetOnly,
|
||||||
|
}, "scope-3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var commands []wecomCommand
|
||||||
|
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||||
|
commands = append(commands, cmd)
|
||||||
|
switch cmd.Cmd {
|
||||||
|
case wecomCmdUploadMediaInit:
|
||||||
|
return wecomTestAck(wecomUploadMediaInitResponse{UploadID: "upload-3"}), nil
|
||||||
|
case wecomCmdUploadMediaEnd:
|
||||||
|
return wecomTestAck(wecomUploadMediaFinishResponse{
|
||||||
|
Type: "file",
|
||||||
|
MediaID: "media-3",
|
||||||
|
}), nil
|
||||||
|
default:
|
||||||
|
return wecomTestAck(nil), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
Channel: "wecom",
|
||||||
|
ChatID: "chat-2",
|
||||||
|
Parts: []bus.MediaPart{{
|
||||||
|
Ref: ref,
|
||||||
|
Type: "file",
|
||||||
|
Filename: "report.pdf",
|
||||||
|
ContentType: "application/pdf",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendMedia() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(commands) != 4 {
|
||||||
|
t.Fatalf("expected 4 commands, got %d", len(commands))
|
||||||
|
}
|
||||||
|
if commands[0].Cmd != wecomCmdUploadMediaInit {
|
||||||
|
t.Fatalf("first command = %q, want %q", commands[0].Cmd, wecomCmdUploadMediaInit)
|
||||||
|
}
|
||||||
|
initBody, ok := commands[0].Body.(wecomUploadMediaInitBody)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected init body type %T", commands[0].Body)
|
||||||
|
}
|
||||||
|
if initBody.Type != "file" || initBody.Filename != "report.pdf" {
|
||||||
|
t.Fatalf("init body = %+v", initBody)
|
||||||
|
}
|
||||||
|
if commands[1].Cmd != wecomCmdUploadMediaChunk {
|
||||||
|
t.Fatalf("second command = %q, want %q", commands[1].Cmd, wecomCmdUploadMediaChunk)
|
||||||
|
}
|
||||||
|
if commands[2].Cmd != wecomCmdUploadMediaEnd {
|
||||||
|
t.Fatalf("third command = %q, want %q", commands[2].Cmd, wecomCmdUploadMediaEnd)
|
||||||
|
}
|
||||||
|
if commands[3].Cmd != wecomCmdSendMsg {
|
||||||
|
t.Fatalf("fourth command = %q, want %q", commands[3].Cmd, wecomCmdSendMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, ok := commands[3].Body.(wecomSendMsgBody)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected body type %T", commands[3].Body)
|
||||||
|
}
|
||||||
|
if body.MsgType != "file" || body.File == nil {
|
||||||
|
t.Fatalf("body = %+v", body)
|
||||||
|
}
|
||||||
|
if body.File.MediaID != "media-3" {
|
||||||
|
t.Fatalf("file media_id = %q, want media-3", body.File.MediaID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newTestWeComChannel(t *testing.T, messageBus *bus.MessageBus) *WeComChannel {
|
func newTestWeComChannel(t *testing.T, messageBus *bus.MessageBus) *WeComChannel {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|
@ -165,3 +463,46 @@ func newTestWeComChannel(t *testing.T, messageBus *bus.MessageBus) *WeComChannel
|
||||||
ch.routes = newReqIDStore(filepath.Join(t.TempDir(), "reqids.json"))
|
ch.routes = newReqIDStore(filepath.Join(t.TempDir(), "reqids.json"))
|
||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func wecomTestJPEGData(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const jpegBase64 = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////" +
|
||||||
|
"//////////////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////" +
|
||||||
|
"//////////////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAVEQEBAAAAAAAAAAAAAAAAAAAABf/aAAwDAQACEAMQAAAB6A//xAAVEAEBAAAAAAAAAAAAAAAAAAAAEf/aAAgBAQABBQJf/8QAFBEBAAAAAAAAAAAAAAAAAAAAEP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAAAAAAEP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAEP/aAAgBAQAGPwJf/8QAFBABAAAAAAAAAAAAAAAAAAAAEP/aAAgBAQABPyFf/9k="
|
||||||
|
|
||||||
|
return decodeTestBase64(t, jpegBase64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeWeComUploadFinish_AcceptsNumericCreatedAt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
resp, err := decodeWeComEnvelopeBody[wecomUploadMediaFinishResponse](wecomEnvelope{
|
||||||
|
Body: json.RawMessage(`{"type":"file","media_id":"media-1","created_at":1380000000}`),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decodeWeComEnvelopeBody() error = %v", err)
|
||||||
|
}
|
||||||
|
if resp.Type != "file" || resp.MediaID != "media-1" {
|
||||||
|
t.Fatalf("response = %+v", resp)
|
||||||
|
}
|
||||||
|
if string(resp.CreatedAt) != "1380000000" {
|
||||||
|
t.Fatalf("created_at = %s, want 1380000000", string(resp.CreatedAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wecomTestAck(body any) wecomEnvelope {
|
||||||
|
var raw []byte
|
||||||
|
if body != nil {
|
||||||
|
encoded, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
raw = encoded
|
||||||
|
}
|
||||||
|
return wecomEnvelope{
|
||||||
|
ErrCode: 0,
|
||||||
|
ErrMsg: "ok",
|
||||||
|
Body: raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue