2026-03-24 07:03:41 +00:00
|
|
|
package wecom
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-03-24 08:12:28 +00:00
|
|
|
"encoding/json"
|
2026-03-24 07:03:41 +00:00
|
|
|
"errors"
|
2026-03-24 08:12:28 +00:00
|
|
|
"os"
|
2026-03-24 07:03:41 +00:00
|
|
|
"path/filepath"
|
2026-03-24 12:17:16 +00:00
|
|
|
"strings"
|
2026-03-24 07:03:41 +00:00
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
2026-03-24 08:12:28 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
2026-03-24 07:03:41 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestDispatchIncoming_UsesActualChatIDAndStoresReqIDRoute(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
messageBus := bus.NewMessageBus()
|
|
|
|
|
ch := newTestWeComChannel(t, messageBus)
|
|
|
|
|
|
|
|
|
|
var commands []wecomCommand
|
2026-03-24 08:12:28 +00:00
|
|
|
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
2026-03-24 07:03:41 +00:00
|
|
|
commands = append(commands, cmd)
|
2026-03-24 08:12:28 +00:00
|
|
|
return wecomTestAck(nil), nil
|
2026-03-24 07:03:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-04-01 13:34:24 +00:00
|
|
|
if inbound.Context.ChatType != "direct" {
|
|
|
|
|
t.Fatalf("inbound Context.ChatType = %q, want direct", inbound.Context.ChatType)
|
2026-03-24 07:03:41 +00:00
|
|
|
}
|
2026-04-01 13:34:24 +00:00
|
|
|
if inbound.Context.ReplyHandles["req_id"] != "req-1" {
|
|
|
|
|
t.Fatalf("inbound req_id = %q, want req-1", inbound.Context.ReplyHandles["req_id"])
|
2026-03-24 07:03:41 +00:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 12:17:16 +00:00
|
|
|
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())
|
2026-05-19 08:38:47 +00:00
|
|
|
ch.config.Streaming.Enabled = true
|
2026-03-24 12:17:16 +00:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
func TestBeginStream_RequiresStreamingEnabled(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(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if _, err := ch.BeginStream(context.Background(), "chat-1"); err == nil {
|
|
|
|
|
t.Fatal("BeginStream() error = nil, want disabled streaming error")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 07:03:41 +00:00
|
|
|
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
|
2026-03-24 08:12:28 +00:00
|
|
|
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
2026-03-24 07:03:41 +00:00
|
|
|
commands = append(commands, cmd)
|
|
|
|
|
if len(commands) == 1 && cmd.Cmd == wecomCmdRespondMsg {
|
2026-03-24 08:12:28 +00:00
|
|
|
return wecomEnvelope{}, errors.New("stream send failed")
|
2026-03-24 07:03:41 +00:00
|
|
|
}
|
2026-03-24 08:12:28 +00:00
|
|
|
return wecomTestAck(nil), nil
|
2026-03-24 07:03:41 +00:00
|
|
|
}
|
|
|
|
|
|
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 03:07:32 +00:00
|
|
|
if _, err := ch.Send(context.Background(), bus.OutboundMessage{
|
2026-03-24 07:03:41 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 12:17:16 +00:00
|
|
|
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)
|
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 03:07:32 +00:00
|
|
|
if _, err := ch.Send(context.Background(), bus.OutboundMessage{
|
2026-03-24 12:17:16 +00:00
|
|
|
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)
|
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 03:07:32 +00:00
|
|
|
if _, err := ch.Send(context.Background(), bus.OutboundMessage{
|
2026-03-24 12:17:16 +00:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 08:12:28 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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 03:07:32 +00:00
|
|
|
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
2026-03-24 08:12:28 +00:00
|
|
|
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(),
|
|
|
|
|
})
|
2026-03-24 08:34:21 +00:00
|
|
|
putErr := ch.routes.Put("chat-1", "req-1", 1, time.Hour)
|
|
|
|
|
if putErr != nil {
|
|
|
|
|
t.Fatalf("Put() error = %v", putErr)
|
2026-03-24 08:12:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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 03:07:32 +00:00
|
|
|
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
2026-03-24 08:12:28 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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 03:07:32 +00:00
|
|
|
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
2026-03-24 08:12:28 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 07:03:41 +00:00
|
|
|
func newTestWeComChannel(t *testing.T, messageBus *bus.MessageBus) *WeComChannel {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
cfg := &config.WeComSettings{BotID: "bot-1"}
|
2026-03-24 07:03:41 +00:00
|
|
|
cfg.SetSecret("secret-1")
|
2026-04-11 16:57:26 +00:00
|
|
|
bc := &config.Channel{Type: config.ChannelWeCom, Enabled: true}
|
|
|
|
|
ch, err := NewChannel(bc, cfg, messageBus)
|
2026-03-24 07:03:41 +00:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewChannel() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
ch.ctx = context.Background()
|
|
|
|
|
ch.routes = newReqIDStore(filepath.Join(t.TempDir(), "reqids.json"))
|
|
|
|
|
return ch
|
|
|
|
|
}
|
2026-03-24 08:12:28 +00:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|