diff --git a/platform/wecom/websocket.go b/platform/wecom/websocket.go index d600c78f36..8ac998fd32 100644 --- a/platform/wecom/websocket.go +++ b/platform/wecom/websocket.go @@ -407,56 +407,56 @@ func (p *WSPlatform) handleMsgCallback(frame wsFrame) { chatName = body.ChatID } - texts, imgRefs, fileRefs := wsCollectInboundParts(&body) + current, quoted := wsCollectInboundParts(&body) + quotedContent := formatWSQuotedContent(quoted) switch body.MsgType { case "voice": vt := stripWeComAtMentions(wsVoiceText(body.Voice), p.botID, body.AibotID) - if vt == "" && len(imgRefs) == 0 && len(fileRefs) == 0 { + if vt != "" { + current.content = append([]string{vt}, current.content...) + } + if len(current.content) == 0 && quotedContent == "" && !current.hasMedia() && !quoted.hasMedia() { slog.Debug("wecom-ws: voice message with empty transcription, ignoring") return } - if len(imgRefs) > 0 || len(fileRefs) > 0 { - out := []string{} - if vt != "" { - out = append(out, vt) - } - out = append(out, texts...) - slog.Info("wecom-ws: voice + media", "user", body.From.UserID, "images", len(imgRefs), "files", len(fileRefs)) - go p.deliverWSMediaInbound(&body, sessionKey, chatName, rctx, out, imgRefs, fileRefs) + if current.hasMedia() || quoted.hasMedia() { + slog.Info("wecom-ws: voice + media", "user", body.From.UserID, "images", len(current.images)+len(quoted.images), "files", len(current.files)+len(quoted.files)) + go p.deliverWSMediaInbound(&body, sessionKey, chatName, rctx, current, quoted, true) return } - slog.Debug("wecom-ws: voice received (transcribed)", "user", body.From.UserID, "len", len(vt)) + content := stripWeComAtMentions(strings.Join(current.content, "\n"), p.botID, body.AibotID) + slog.Debug("wecom-ws: voice received (transcribed)", "user", body.From.UserID, "len", len(content)) go p.handler(p, &core.Message{ SessionKey: sessionKey, Platform: "wecom", MessageID: body.MsgID, UserID: body.From.UserID, UserName: body.From.UserID, ChatName: chatName, - Content: vt, ReplyCtx: rctx, FromVoice: true, + Content: content, ExtraContent: quotedContent, ReplyCtx: rctx, FromVoice: true, }) return } - if len(imgRefs) == 0 && len(fileRefs) == 0 { - if len(texts) == 0 { + if !current.hasMedia() && !quoted.hasMedia() { + if len(current.content) == 0 && quotedContent == "" { slog.Warn("wecom-ws: no text or media in message", "msg_type", body.MsgType, "msg_id", body.MsgID) return } - content := stripWeComAtMentions(strings.Join(texts, "\n"), p.botID, body.AibotID) + content := stripWeComAtMentions(strings.Join(current.content, "\n"), p.botID, body.AibotID) slog.Debug("wecom-ws: text received", "user", body.From.UserID, "len", len(content)) go p.handler(p, &core.Message{ SessionKey: sessionKey, Platform: "wecom", MessageID: body.MsgID, UserID: body.From.UserID, UserName: body.From.UserID, ChatName: chatName, - Content: content, ReplyCtx: rctx, + Content: content, ExtraContent: quotedContent, ReplyCtx: rctx, }) return } slog.Info("wecom-ws: media message", "msg_type", body.MsgType, "user", body.From.UserID, - "images", len(imgRefs), "files", len(fileRefs), "text_parts", len(texts)) - go p.deliverWSMediaInbound(&body, sessionKey, chatName, rctx, texts, imgRefs, fileRefs) + "images", len(current.images)+len(quoted.images), "files", len(current.files)+len(quoted.files), "text_parts", len(current.content)) + go p.deliverWSMediaInbound(&body, sessionKey, chatName, rctx, current, quoted, false) } // Reply sends a response message via aibot_respond_msg using the stream format. diff --git a/platform/wecom/websocket_media.go b/platform/wecom/websocket_media.go index 437759ac1f..067d10e667 100644 --- a/platform/wecom/websocket_media.go +++ b/platform/wecom/websocket_media.go @@ -67,27 +67,52 @@ type wsQuoteBlock struct { Mixed *wsMixedBlock `json:"mixed,omitempty"` } -// wsCollectInboundParts extracts text lines and media refs (main message + quote + mixed), -// matching @wecom/aibot-node-sdk message parsing. Does not include the top-level voice -// transcription (handled separately via wsVoiceText). -func wsCollectInboundParts(body *wsMsgCallbackBody) (texts []string, imgs, files []wsMediaRef) { - appendText := func(s string) { +// wsInboundParts keeps the current message separate from its quoted context. +// content contains readable text and, for quotes, attachment markers in display order. +type wsInboundParts struct { + content []string + images []wsMediaRef + files []wsMediaRef +} + +func (p wsInboundParts) hasMedia() bool { + return len(p.images) > 0 || len(p.files) > 0 +} + +func formatWSQuotedContent(parts wsInboundParts) string { + if len(parts.content) == 0 { + return "" + } + return "[Quoted message]:\n" + strings.Join(parts.content, "\n") + "\n\n" +} + +// wsCollectInboundParts separates main-message and quote content so the engine can +// deliver the latter as explicit context instead of treating it as a new instruction. +// It does not include the top-level voice transcription, which is handled by wsVoiceText. +func wsCollectInboundParts(body *wsMsgCallbackBody) (current, quoted wsInboundParts) { + appendText := func(parts *wsInboundParts, s string) { s = strings.TrimSpace(s) if s != "" { - texts = append(texts, s) + parts.content = append(parts.content, s) } } - appendImage := func(url, aeskey string) { + appendImage := func(parts *wsInboundParts, url, aeskey string, marker bool) { if url != "" { - imgs = append(imgs, wsMediaRef{URL: url, Aeskey: aeskey}) + parts.images = append(parts.images, wsMediaRef{URL: url, Aeskey: aeskey}) + } + if marker { + parts.content = append(parts.content, "[image]") } } - appendFile := func(url, aeskey string) { + appendFile := func(parts *wsInboundParts, url, aeskey string, marker bool) { if url != "" { - files = append(files, wsMediaRef{URL: url, Aeskey: aeskey}) + parts.files = append(parts.files, wsMediaRef{URL: url, Aeskey: aeskey}) + } + if marker { + parts.content = append(parts.content, "[file]") } } - walkMixed := func(m *wsMixedBlock) { + walkMixed := func(parts *wsInboundParts, m *wsMixedBlock, markers bool) { if m == nil { return } @@ -95,15 +120,19 @@ func wsCollectInboundParts(body *wsMsgCallbackBody) (texts []string, imgs, files switch item.MsgType { case "text": if item.Text != nil { - appendText(item.Text.Content) + appendText(parts, item.Text.Content) } case "image": if item.Image != nil { - appendImage(item.Image.URL, item.Image.Aeskey) + appendImage(parts, item.Image.URL, item.Image.Aeskey, markers) + } else if markers { + parts.content = append(parts.content, "[image]") } case "file": if item.File != nil { - appendFile(item.File.URL, item.File.Aeskey) + appendFile(parts, item.File.URL, item.File.Aeskey, markers) + } else if markers { + parts.content = append(parts.content, "[file]") } } } @@ -115,48 +144,57 @@ func wsCollectInboundParts(body *wsMsgCallbackBody) (texts []string, imgs, files switch q.MsgType { case "text": if q.Text != nil { - appendText(q.Text.Content) + appendText("ed, q.Text.Content) } case "voice": if q.Voice != nil { - appendText(q.Voice.Content) + appendText("ed, q.Voice.Content) + } + if len(quoted.content) == 0 { + quoted.content = append(quoted.content, "[voice]") } case "image": if q.Image != nil { - appendImage(q.Image.URL, q.Image.Aeskey) + appendImage("ed, q.Image.URL, q.Image.Aeskey, true) + } else { + quoted.content = append(quoted.content, "[image]") } case "file": if q.File != nil { - appendFile(q.File.URL, q.File.Aeskey) + appendFile("ed, q.File.URL, q.File.Aeskey, true) + } else { + quoted.content = append(quoted.content, "[file]") } case "mixed": - walkMixed(q.Mixed) + walkMixed("ed, q.Mixed, true) } } if body.Mixed != nil && len(body.Mixed.MsgItem) > 0 { - walkMixed(body.Mixed) + walkMixed(¤t, body.Mixed, false) } else { - appendText(body.Text.Content) + if body.MsgType != "voice" { + appendText(¤t, body.Text.Content) + } if body.Image != nil { - appendImage(body.Image.URL, body.Image.Aeskey) + appendImage(¤t, body.Image.URL, body.Image.Aeskey, false) } if body.MsgType == "file" && body.File != nil { - appendFile(body.File.URL, body.File.Aeskey) + appendFile(¤t, body.File.URL, body.File.Aeskey, false) } } // WeCom may send msgtype=file (or image) together with a non-empty mixed block; the real // download url is then only on the top-level file/image object. Merge those here. if body.Mixed != nil && len(body.Mixed.MsgItem) > 0 { if body.MsgType == "file" && body.File != nil { - appendFile(body.File.URL, body.File.Aeskey) + appendFile(¤t, body.File.URL, body.File.Aeskey, false) } if body.MsgType == "image" && body.Image != nil { - appendImage(body.Image.URL, body.Image.Aeskey) + appendImage(¤t, body.Image.URL, body.Image.Aeskey, false) } } walkQuote(body.Quote) - return texts, imgs, files + return current, quoted } // decodeWeComAESKey normalizes and decodes the aeskey from WeCom WS callbacks. @@ -329,54 +367,65 @@ func downloadWeComWSMedia(ctx context.Context, urlStr, aesKey string) (data []by return raw, fileName, nil } -// deliverWSMediaInbound downloads image/file refs and forwards one core.Message. -func (p *WSPlatform) deliverWSMediaInbound(body *wsMsgCallbackBody, sessionKey, chatName string, rctx wsReplyContext, texts []string, imgs, files []wsMediaRef) { +// deliverWSMediaInbound downloads media and forwards one core.Message. Quoted media +// is downloaded first so attachment order mirrors the quoted-context prompt. +func (p *WSPlatform) deliverWSMediaInbound(body *wsMsgCallbackBody, sessionKey, chatName string, rctx wsReplyContext, current, quoted wsInboundParts, fromVoice bool) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() var images []core.ImageAttachment var fileAtts []core.FileAttachment - for _, im := range imgs { - buf, fn, err := downloadWeComWSMedia(ctx, im.URL, im.Aeskey) - if err != nil { - slog.Error("wecom-ws: download image failed", "error", err) - continue - } - base := filepath.Base(strings.TrimSpace(fn)) - if base == "" || base == "." { - base = "image.bin" - } - mt := wecomInboundFileMime(base, buf) - if !strings.HasPrefix(mt, "image/") { - mt = http.DetectContentType(buf) + downloadImages := func(refs []wsMediaRef) { + for _, im := range refs { + buf, fn, err := downloadWeComWSMedia(ctx, im.URL, im.Aeskey) + if err != nil { + slog.Error("wecom-ws: download image failed", "error", err) + continue + } + base := filepath.Base(strings.TrimSpace(fn)) + if base == "" || base == "." { + base = "image.bin" + } + mt := wecomInboundFileMime(base, buf) if !strings.HasPrefix(mt, "image/") { - mt = "image/jpeg" + mt = http.DetectContentType(buf) + if !strings.HasPrefix(mt, "image/") { + mt = "image/jpeg" + } } + images = append(images, core.ImageAttachment{MimeType: mt, Data: buf, FileName: base}) + slog.Info("wecom-ws: image downloaded", "bytes", len(buf), "mime", mt, "name", base) } - images = append(images, core.ImageAttachment{MimeType: mt, Data: buf, FileName: base}) - slog.Info("wecom-ws: image downloaded", "bytes", len(buf), "mime", mt, "name", base) } - for _, f := range files { - buf, fn, err := downloadWeComWSMedia(ctx, f.URL, f.Aeskey) - if err != nil { - slog.Error("wecom-ws: download file failed", "error", err) - continue - } - base := filepath.Base(strings.TrimSpace(fn)) - if base == "" || base == "." { - base = "attachment" + downloadFiles := func(refs []wsMediaRef) { + for _, f := range refs { + buf, fn, err := downloadWeComWSMedia(ctx, f.URL, f.Aeskey) + if err != nil { + slog.Error("wecom-ws: download file failed", "error", err) + continue + } + base := filepath.Base(strings.TrimSpace(fn)) + if base == "" || base == "." { + base = "attachment" + } + mt := wecomInboundFileMime(base, buf) + fileAtts = append(fileAtts, core.FileAttachment{MimeType: mt, Data: buf, FileName: base}) + slog.Info("wecom-ws: file downloaded", "bytes", len(buf), "mime", mt, "name", base) } - mt := wecomInboundFileMime(base, buf) - fileAtts = append(fileAtts, core.FileAttachment{MimeType: mt, Data: buf, FileName: base}) - slog.Info("wecom-ws: file downloaded", "bytes", len(buf), "mime", mt, "name", base) } - content := strings.Join(texts, "\n") + downloadImages(quoted.images) + downloadImages(current.images) + downloadFiles(quoted.files) + downloadFiles(current.files) + + content := strings.Join(current.content, "\n") content = stripWeComAtMentions(content, p.botID, body.AibotID) + quotedContent := formatWSQuotedContent(quoted) - if content == "" && len(images) == 0 && len(fileAtts) == 0 { + if content == "" && quotedContent == "" && len(images) == 0 && len(fileAtts) == 0 { slog.Warn("wecom-ws: media inbound empty after downloads", "msg_id", body.MsgID) return } @@ -385,10 +434,11 @@ func (p *WSPlatform) deliverWSMediaInbound(body *wsMsgCallbackBody, sessionKey, SessionKey: sessionKey, Platform: "wecom", MessageID: body.MsgID, UserID: body.From.UserID, UserName: body.From.UserID, - ChatName: chatName, - Content: content, - Images: images, - Files: fileAtts, - ReplyCtx: rctx, + ChatName: chatName, + Content: content, + ExtraContent: quotedContent, + Images: images, + Files: fileAtts, + ReplyCtx: rctx, FromVoice: fromVoice, }) } diff --git a/platform/wecom/websocket_media_test.go b/platform/wecom/websocket_media_test.go index 10efbb7c23..336c31d8e3 100644 --- a/platform/wecom/websocket_media_test.go +++ b/platform/wecom/websocket_media_test.go @@ -7,6 +7,8 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "net/http" + "net/http/httptest" "strings" "testing" ) @@ -38,9 +40,9 @@ func TestWsCollectInboundParts_fileAndQuote(t *testing.T) { if err := json.Unmarshal([]byte(raw), &body); err != nil { t.Fatal(err) } - texts, imgs, files := wsCollectInboundParts(&body) - if len(texts) != 0 || len(imgs) != 0 || len(files) != 1 || files[0].URL != "https://example.com/f" { - t.Fatalf("files=%v texts=%v imgs=%v", files, texts, imgs) + current, quoted := wsCollectInboundParts(&body) + if len(current.content) != 0 || len(current.images) != 0 || len(current.files) != 1 || current.files[0].URL != "https://example.com/f" || len(quoted.content) != 0 { + t.Fatalf("current=%+v quoted=%+v", current, quoted) } } @@ -64,9 +66,9 @@ func TestWsCollectInboundParts_mixed(t *testing.T) { if err := json.Unmarshal([]byte(raw), &body); err != nil { t.Fatal(err) } - texts, imgs, files := wsCollectInboundParts(&body) - if len(texts) != 1 || texts[0] != "see" || len(imgs) != 1 || imgs[0].URL != "https://i" || len(files) != 0 { - t.Fatalf("texts=%v imgs=%v files=%v", texts, imgs, files) + current, quoted := wsCollectInboundParts(&body) + if len(current.content) != 1 || current.content[0] != "see" || len(current.images) != 1 || current.images[0].URL != "https://i" || len(current.files) != 0 || len(quoted.content) != 0 { + t.Fatalf("current=%+v quoted=%+v", current, quoted) } } @@ -89,9 +91,9 @@ func TestWsCollectInboundParts_fileWithNonEmptyMixedUsesTopLevelFile(t *testing. if err := json.Unmarshal([]byte(raw), &body); err != nil { t.Fatal(err) } - texts, imgs, files := wsCollectInboundParts(&body) - if len(files) != 1 || files[0].URL != "https://example.com/doc.pdf" || len(imgs) != 0 { - t.Fatalf("texts=%v imgs=%v files=%v", texts, imgs, files) + current, quoted := wsCollectInboundParts(&body) + if len(current.files) != 1 || current.files[0].URL != "https://example.com/doc.pdf" || len(current.images) != 0 || len(quoted.content) != 0 { + t.Fatalf("current=%+v quoted=%+v", current, quoted) } } @@ -115,9 +117,110 @@ func TestWsCollectInboundParts_mixedContainsFile(t *testing.T) { if err := json.Unmarshal([]byte(raw), &body); err != nil { t.Fatal(err) } - texts, imgs, files := wsCollectInboundParts(&body) - if len(texts) != 1 || len(imgs) != 0 || len(files) != 1 || files[0].URL != "https://f" { - t.Fatalf("texts=%v imgs=%v files=%v", texts, imgs, files) + current, quoted := wsCollectInboundParts(&body) + if len(current.content) != 1 || len(current.images) != 0 || len(current.files) != 1 || current.files[0].URL != "https://f" || len(quoted.content) != 0 { + t.Fatalf("current=%+v quoted=%+v", current, quoted) + } +} + +func TestWsCollectInboundParts_SeparatesQuotedMixedContent(t *testing.T) { + t.Parallel() + raw := `{ + "msgid": "5", + "msgtype": "mixed", + "mixed": {"msg_item": [ + {"msgtype": "text", "text": {"content": "new instruction"}}, + {"msgtype": "file", "file": {"url": "https://current-file", "aeskey": "current-key"}} + ]}, + "quote": {"msgtype": "mixed", "mixed": {"msg_item": [ + {"msgtype": "text", "text": {"content": "quoted text"}}, + {"msgtype": "image", "image": {"url": "https://quoted-image", "aeskey": "image-key"}}, + {"msgtype": "file", "file": {"url": "https://quoted-file", "aeskey": "file-key"}} + ]}} + }` + var body wsMsgCallbackBody + if err := json.Unmarshal([]byte(raw), &body); err != nil { + t.Fatal(err) + } + + current, quoted := wsCollectInboundParts(&body) + if got, want := strings.Join(current.content, "\n"), "new instruction"; got != want { + t.Fatalf("current content = %q, want %q", got, want) + } + if len(current.files) != 1 || current.files[0].URL != "https://current-file" { + t.Fatalf("current files = %+v", current.files) + } + if got, want := strings.Join(quoted.content, "\n"), "quoted text\n[image]\n[file]"; got != want { + t.Fatalf("quoted content = %q, want %q", got, want) + } + if len(quoted.images) != 1 || quoted.images[0].URL != "https://quoted-image" || len(quoted.files) != 1 || quoted.files[0].URL != "https://quoted-file" { + t.Fatalf("quoted media = %+v %+v", quoted.images, quoted.files) + } + if got, want := formatWSQuotedContent(quoted), "[Quoted message]:\nquoted text\n[image]\n[file]\n\n"; got != want { + t.Fatalf("quoted context = %q, want %q", got, want) + } +} + +func TestDeliverWSMediaInbound_QuotedAttachmentsPrecedeCurrentAttachments(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/quoted": + w.Header().Set("Content-Disposition", `attachment; filename="quoted.png"`) + _, _ = w.Write([]byte("quoted image")) + case "/current": + w.Header().Set("Content-Disposition", `attachment; filename="current.png"`) + _, _ = w.Write([]byte("current image")) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + p, captured := newCapturedWSPlatform() + body := &wsMsgCallbackBody{MsgID: "quoted-media", AibotID: "bot"} + p.deliverWSMediaInbound( + body, + "wecom:chat:user", + "", + wsReplyContext{}, + wsInboundParts{content: []string{"inspect this"}, images: []wsMediaRef{{URL: srv.URL + "/current"}}}, + wsInboundParts{content: []string{"[image]"}, images: []wsMediaRef{{URL: srv.URL + "/quoted"}}}, + false, + ) + + msg := <-captured + if msg.Content != "inspect this" { + t.Fatalf("Content = %q", msg.Content) + } + if got, want := msg.ExtraContent, "[Quoted message]:\n[image]\n\n"; got != want { + t.Fatalf("ExtraContent = %q, want %q", got, want) + } + if len(msg.Images) != 2 || string(msg.Images[0].Data) != "quoted image" || string(msg.Images[1].Data) != "current image" { + t.Fatalf("image order = %#v", msg.Images) + } +} + +func TestDeliverWSMediaInbound_QuotedDownloadFailureKeepsContextMarker(t *testing.T) { + t.Parallel() + p, captured := newCapturedWSPlatform() + body := &wsMsgCallbackBody{MsgID: "quoted-media-failure"} + p.deliverWSMediaInbound( + body, + "wecom:chat:user", + "", + wsReplyContext{}, + wsInboundParts{content: []string{"what is this?"}}, + wsInboundParts{content: []string{"[image]"}, images: []wsMediaRef{{URL: "http://127.0.0.1:1/unavailable"}}}, + false, + ) + + msg := <-captured + if got, want := msg.ExtraContent, "[Quoted message]:\n[image]\n\n"; got != want { + t.Fatalf("ExtraContent = %q, want %q", got, want) + } + if len(msg.Images) != 0 { + t.Fatalf("Images = %#v, want no downloaded images", msg.Images) } } diff --git a/platform/wecom/websocket_test.go b/platform/wecom/websocket_test.go index 1b8b4a601a..132d45f755 100644 --- a/platform/wecom/websocket_test.go +++ b/platform/wecom/websocket_test.go @@ -390,6 +390,78 @@ func TestHandleMsgCallback_StripsBotMention(t *testing.T) { } } +func TestHandleMsgCallback_SeparatesQuotedTextFromCurrentInstruction(t *testing.T) { + p, captured := newCapturedWSPlatform() + p.botID = "robot01" + + body := wsMsgCallbackBody{ + MsgID: "msg_quote", + ChatID: "grp1", + ChatType: "group", + MsgType: "text", + AibotID: "robot01", + CreateTime: time.Now().Unix(), + Quote: &wsQuoteBlock{ + MsgType: "text", + Text: &struct { + Content string `json:"content"` + }{Content: "请检查这段旧代码"}, + }, + } + body.From.UserID = "u1" + body.Text.Content = "@Robot01 修复这个问题" + + p.handleMsgCallback(wsCallbackFrame(t, "req_quote", body)) + + select { + case msg := <-captured: + if got, want := msg.Content, "修复这个问题"; got != want { + t.Fatalf("Content = %q, want %q", got, want) + } + if got, want := msg.ExtraContent, "[Quoted message]:\n请检查这段旧代码\n\n"; got != want { + t.Fatalf("ExtraContent = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatal("handler not called") + } +} + +func TestHandleMsgCallback_MentionOnlyWithQuoteStillDispatches(t *testing.T) { + p, captured := newCapturedWSPlatform() + p.botID = "robot01" + + body := wsMsgCallbackBody{ + MsgID: "msg_quote_mention_only", + ChatID: "grp1", + ChatType: "group", + MsgType: "text", + AibotID: "robot01", + CreateTime: time.Now().Unix(), + Quote: &wsQuoteBlock{ + MsgType: "text", + Text: &struct { + Content string `json:"content"` + }{Content: "总结这段内容"}, + }, + } + body.From.UserID = "u1" + body.Text.Content = "@Robot01" + + p.handleMsgCallback(wsCallbackFrame(t, "req_quote_mention_only", body)) + + select { + case msg := <-captured: + if msg.Content != "" { + t.Fatalf("Content = %q, want empty current instruction", msg.Content) + } + if got, want := msg.ExtraContent, "[Quoted message]:\n总结这段内容\n\n"; got != want { + t.Fatalf("ExtraContent = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatal("handler not called") + } +} + // --------------------------------------------------------------------------- // ReconstructReplyCtx // ---------------------------------------------------------------------------