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, } }