Files
picoclaw/pkg/channels/wecom/wecom_test.go
T
DimonB 6c0798ca3f feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs

Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.

Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.

preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.

* style: apply golangci-lint fixes (golines)

* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 11:07:32 +08:00

661 lines
19 KiB
Go

package wecom
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
)
func TestDispatchIncoming_UsesActualChatIDAndStoresReqIDRoute(t *testing.T) {
t.Parallel()
messageBus := bus.NewMessageBus()
ch := newTestWeComChannel(t, messageBus)
var commands []wecomCommand
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
commands = append(commands, cmd)
return wecomTestAck(nil), nil
}
msg := wecomIncomingMessage{
MsgID: "msg-1",
ChatID: "chat-1",
ChatType: "direct",
MsgType: "text",
Text: &struct {
Content string `json:"content"`
}{Content: "hello"},
}
msg.From.UserID = "user-1"
if err := ch.dispatchIncoming("req-1", msg); err != nil {
t.Fatalf("dispatchIncoming() error = %v", err)
}
select {
case inbound := <-messageBus.InboundChan():
if inbound.ChatID != "chat-1" {
t.Fatalf("inbound ChatID = %q, want chat-1", inbound.ChatID)
}
if inbound.MessageID != "msg-1" {
t.Fatalf("inbound MessageID = %q, want msg-1", inbound.MessageID)
}
if inbound.Peer.ID != "chat-1" {
t.Fatalf("inbound Peer.ID = %q, want chat-1", inbound.Peer.ID)
}
if inbound.Metadata["req_id"] != "req-1" {
t.Fatalf("inbound req_id = %q, want req-1", inbound.Metadata["req_id"])
}
default:
t.Fatal("expected inbound message to be published")
}
turn, ok := ch.getTurn("chat-1")
if !ok {
t.Fatal("expected queued turn for chat-1")
}
if turn.ReqID != "req-1" {
t.Fatalf("turn.ReqID = %q, want req-1", turn.ReqID)
}
route, ok := ch.routes.Get("chat-1")
if !ok {
t.Fatal("expected persisted route for chat-1")
}
if route.ReqID != "req-1" || route.ChatType != 1 {
t.Fatalf("route = %+v", route)
}
if len(commands) != 1 {
t.Fatalf("expected 1 opening command, got %d", len(commands))
}
if commands[0].Cmd != wecomCmdRespondMsg {
t.Fatalf("opening command = %q, want %q", commands[0].Cmd, wecomCmdRespondMsg)
}
if commands[0].Headers.ReqID != "req-1" {
t.Fatalf("opening req_id = %q, want req-1", commands[0].Headers.ReqID)
}
}
func TestNewChannel_DoesNotRegisterMessageSplitLimit(t *testing.T) {
t.Parallel()
ch := newTestWeComChannel(t, bus.NewMessageBus())
if got := ch.MaxMessageLength(); got != 0 {
t.Fatalf("MaxMessageLength() = %d, want 0", got)
}
}
func TestBeginStream_UpdateAndFinalize(t *testing.T) {
t.Parallel()
ch := newTestWeComChannel(t, bus.NewMessageBus())
ch.SetRunning(true)
ch.queueTurn("chat-1", wecomTurn{
ReqID: "req-1",
ChatID: "chat-1",
ChatType: 1,
StreamID: "stream-1",
CreatedAt: time.Now(),
})
var commands []wecomCommand
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
commands = append(commands, cmd)
return wecomTestAck(nil), nil
}
streamer, err := ch.BeginStream(context.Background(), "chat-1")
if err != nil {
t.Fatalf("BeginStream() error = %v", err)
}
if err := streamer.Update(context.Background(), "draft"); err != nil {
t.Fatalf("Update() error = %v", err)
}
if err := streamer.Finalize(context.Background(), "final"); err != nil {
t.Fatalf("Finalize() error = %v", err)
}
if len(commands) != 2 {
t.Fatalf("expected 2 commands, got %d", len(commands))
}
for i, wantFinish := range []bool{false, true} {
if commands[i].Cmd != wecomCmdRespondMsg {
t.Fatalf("command[%d].Cmd = %q, want %q", i, commands[i].Cmd, wecomCmdRespondMsg)
}
body, ok := commands[i].Body.(wecomRespondMsgBody)
if !ok {
t.Fatalf("command[%d] body type = %T", i, commands[i].Body)
}
if body.Stream == nil {
t.Fatalf("command[%d] missing stream body", i)
}
if body.Stream.ID != "stream-1" {
t.Fatalf("command[%d] stream id = %q, want stream-1", i, body.Stream.ID)
}
if body.Stream.Finish != wantFinish {
t.Fatalf("command[%d] finish = %v, want %v", i, body.Stream.Finish, wantFinish)
}
}
if body := commands[0].Body.(wecomRespondMsgBody); body.Stream.Content != "draft" {
t.Fatalf("update content = %q, want draft", body.Stream.Content)
}
if body := commands[1].Body.(wecomRespondMsgBody); body.Stream.Content != "final" {
t.Fatalf("final content = %q, want final", body.Stream.Content)
}
if _, ok := ch.getTurn("chat-1"); ok {
t.Fatal("expected turn to be consumed after Finalize")
}
}
func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) {
t.Parallel()
ch := newTestWeComChannel(t, bus.NewMessageBus())
ch.SetRunning(true)
ch.queueTurn("chat-1", wecomTurn{
ReqID: "req-1",
ChatID: "chat-1",
ChatType: 1,
StreamID: "stream-1",
CreatedAt: time.Now(),
})
ch.queueTurn("chat-1", wecomTurn{
ReqID: "req-2",
ChatID: "chat-1",
ChatType: 1,
StreamID: "stream-2",
CreatedAt: time.Now(),
})
if err := ch.routes.Put("chat-1", "req-2", 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)
if len(commands) == 1 && cmd.Cmd == wecomCmdRespondMsg {
return wecomEnvelope{}, errors.New("stream send failed")
}
return wecomTestAck(nil), nil
}
if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: "hello",
}); err != nil {
t.Fatalf("Send() error = %v", err)
}
if len(commands) != 2 {
t.Fatalf("expected 2 commands, got %d", len(commands))
}
if commands[0].Cmd != wecomCmdRespondMsg || commands[0].Headers.ReqID != "req-1" {
t.Fatalf("first command = %+v", commands[0])
}
if commands[1].Cmd != wecomCmdSendMsg {
t.Fatalf("second command = %q, want %q", commands[1].Cmd, wecomCmdSendMsg)
}
body, ok := commands[1].Body.(wecomSendMsgBody)
if !ok {
t.Fatalf("unexpected send body type %T", commands[1].Body)
}
if body.ChatID != "chat-1" {
t.Fatalf("send chatid = %q, want chat-1", body.ChatID)
}
if body.ChatType != 1 {
t.Fatalf("send chat_type = %d, want 1", body.ChatType)
}
nextTurn, ok := ch.getTurn("chat-1")
if !ok {
t.Fatal("expected second turn to remain queued")
}
if nextTurn.ReqID != "req-2" {
t.Fatalf("next queued req_id = %q, want req-2", nextTurn.ReqID)
}
}
func TestSend_DoesNotSplitStreamReply(t *testing.T) {
t.Parallel()
ch := newTestWeComChannel(t, bus.NewMessageBus())
ch.SetRunning(true)
ch.queueTurn("chat-1", wecomTurn{
ReqID: "req-1",
ChatID: "chat-1",
ChatType: 1,
StreamID: "stream-1",
CreatedAt: time.Now(),
})
var commands []wecomCommand
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
commands = append(commands, cmd)
return wecomTestAck(nil), nil
}
content := strings.Repeat("\u4e2d", 30000)
if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: content,
}); err != nil {
t.Fatalf("Send() error = %v", err)
}
if len(commands) != 1 {
t.Fatalf("expected 1 stream command, got %d", len(commands))
}
body, ok := commands[0].Body.(wecomRespondMsgBody)
if !ok {
t.Fatalf("unexpected body type %T", commands[0].Body)
}
if body.Stream == nil || !body.Stream.Finish {
t.Fatalf("stream body = %+v", body.Stream)
}
if body.Stream.Content != content {
t.Fatalf("stream content length = %d, want %d", len(body.Stream.Content), len(content))
}
}
func TestSend_DoesNotSplitActivePush(t *testing.T) {
t.Parallel()
ch := newTestWeComChannel(t, bus.NewMessageBus())
ch.SetRunning(true)
var commands []wecomCommand
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
commands = append(commands, cmd)
return wecomTestAck(nil), nil
}
content := strings.Repeat("a", 30000)
if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: content,
}); err != nil {
t.Fatalf("Send() error = %v", err)
}
if len(commands) != 1 {
t.Fatalf("expected 1 send command, got %d", len(commands))
}
if commands[0].Cmd != wecomCmdSendMsg {
t.Fatalf("command = %q, want %q", commands[0].Cmd, wecomCmdSendMsg)
}
body, ok := commands[0].Body.(wecomSendMsgBody)
if !ok {
t.Fatalf("unexpected body type %T", commands[0].Body)
}
if body.Markdown == nil || body.Markdown.Content != content {
t.Fatalf("markdown content length = %d, want %d", len(body.Markdown.Content), len(content))
}
}
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(),
})
putErr := ch.routes.Put("chat-1", "req-1", 1, time.Hour)
if putErr != nil {
t.Fatalf("Put() error = %v", putErr)
}
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 {
t.Helper()
cfg := config.WeComConfig{BotID: "bot-1"}
cfg.SetSecret("secret-1")
ch, err := NewChannel(cfg, messageBus)
if err != nil {
t.Fatalf("NewChannel() error = %v", err)
}
ch.ctx = context.Background()
ch.routes = newReqIDStore(filepath.Join(t.TempDir(), "reqids.json"))
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,
}
}