mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 14:15:00 +00:00
Follow-up to #1443. For a media-only quoted message, im.dialog.messages.get frequently returns a blank type/name (observed live for a voice note), so the reply note fell back to the generic "một tệp" even though the file was correctly downloaded and tagged <media:audio>. Build the note AFTER downloading and derive the kind label from the downloaded file's MIME (reusing classifyMediaType), so it reads "một tin nhắn thoại" / "một hình ảnh" / "một video" to match the <media:*> tag the agent receives. Falls back to the Bitrix file type, then "tệp", when no download is available (e.g. oversized). Also adopts the downloaded filename when messages.get omitted it. Adds tests for the MIME-preference path and mediaKindVN. Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
309 lines
10 KiB
Go
309 lines
10 KiB
Go
package bitrix24
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
)
|
|
|
|
func TestSanitizeQuotedText(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"empty", "", ""},
|
|
{"whitespace only", " \n\t ", ""},
|
|
{"plain", "báo giá cho anh nhé", "báo giá cho anh nhé"},
|
|
{"user mention", "[USER=62]Đặng Văn Tình[/USER] xong chưa", "@Đặng Văn Tình (ID:62) xong chưa"},
|
|
{"strip formatting", "[b]quan trọng[/b] [i]lắm[/i]", "quan trọng lắm"},
|
|
{"strip quote+context", "[quote][CONTEXT=chat123]cũ[/quote] mới", "cũ mới"},
|
|
{"collapse whitespace", "dòng 1\n\n dòng 2", "dòng 1 dòng 2"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := sanitizeQuotedText(tc.in); got != tc.want {
|
|
t.Errorf("sanitizeQuotedText(%q) = %q; want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSanitizeQuotedText_Truncates(t *testing.T) {
|
|
long := strings.Repeat("ă", quotedTextMaxRunes+50) // multibyte on purpose
|
|
got := sanitizeQuotedText(long)
|
|
gotRunes := []rune(got)
|
|
// quotedTextMaxRunes content runes + 1 ellipsis rune.
|
|
if len(gotRunes) != quotedTextMaxRunes+1 {
|
|
t.Fatalf("truncated len = %d runes; want %d", len(gotRunes), quotedTextMaxRunes+1)
|
|
}
|
|
if !strings.HasSuffix(got, "…") {
|
|
t.Errorf("truncated text should end with ellipsis, got %q", got[len(got)-6:])
|
|
}
|
|
}
|
|
|
|
func TestTruncateRunes(t *testing.T) {
|
|
if got := truncateRunes("abc", 5); got != "abc" {
|
|
t.Errorf("no truncation expected, got %q", got)
|
|
}
|
|
if got := truncateRunes("abcdef", 3); got != "abc…" {
|
|
t.Errorf("truncateRunes = %q; want %q", got, "abc…")
|
|
}
|
|
}
|
|
|
|
func TestQuotedKindVN(t *testing.T) {
|
|
cases := map[string]string{
|
|
"image": "hình ảnh",
|
|
"audio": "tin nhắn thoại",
|
|
"video": "video",
|
|
"file": "tệp",
|
|
"": "tệp",
|
|
"WEIRD": "tệp",
|
|
}
|
|
for in, want := range cases {
|
|
if got := quotedKindVN(in); got != want {
|
|
t.Errorf("quotedKindVN(%q) = %q; want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQuotedMediaNote(t *testing.T) {
|
|
single := quotedMediaNote([]EventFile{{Name: "voice.ogg", Type: "audio"}}, nil, "Tình")
|
|
if want := "[Đang trả lời một tin nhắn thoại \"voice.ogg\" của Tình]\n"; single != want {
|
|
t.Errorf("single = %q; want %q", single, want)
|
|
}
|
|
noName := quotedMediaNote([]EventFile{{Type: "image"}}, nil, "Tình")
|
|
if want := "[Đang trả lời một hình ảnh của Tình]\n"; noName != want {
|
|
t.Errorf("noName = %q; want %q", noName, want)
|
|
}
|
|
multi := quotedMediaNote([]EventFile{{Name: "a"}, {Name: "b"}}, nil, "Tình")
|
|
if want := "[Đang trả lời 2 tệp đính kèm của Tình]\n"; multi != want {
|
|
t.Errorf("multi = %q; want %q", multi, want)
|
|
}
|
|
}
|
|
|
|
// Regression: im.dialog.messages.get returns blank type/name for voice notes,
|
|
// so the note must derive the kind (and filename) from the downloaded MIME
|
|
// instead of falling back to the generic "tệp".
|
|
func TestQuotedMediaNote_PrefersDownloadedMIME(t *testing.T) {
|
|
files := []EventFile{{ID: "32728"}} // blank Type + Name (as messages.get returned)
|
|
downloaded := []bus.MediaFile{{MimeType: "audio/x-m4a", Filename: "voice.m4a"}}
|
|
got := quotedMediaNote(files, downloaded, "Đình Chinh")
|
|
if want := "[Đang trả lời một tin nhắn thoại \"voice.m4a\" của Đình Chinh]\n"; got != want {
|
|
t.Errorf("got %q; want %q", got, want)
|
|
}
|
|
|
|
// Blank downloaded filename → kind still upgraded, no filename shown.
|
|
got2 := quotedMediaNote([]EventFile{{ID: "1"}}, []bus.MediaFile{{MimeType: "image/png"}}, "Tình")
|
|
if want := "[Đang trả lời một hình ảnh của Tình]\n"; got2 != want {
|
|
t.Errorf("got2 %q; want %q", got2, want)
|
|
}
|
|
}
|
|
|
|
func TestMediaKindVN(t *testing.T) {
|
|
cases := map[string]string{
|
|
"audio/x-m4a": "tin nhắn thoại",
|
|
"image/png": "hình ảnh",
|
|
"video/mp4": "video",
|
|
"application/pdf": "tệp",
|
|
"": "tệp",
|
|
}
|
|
for mime, want := range cases {
|
|
if got := mediaKindVN(mime); got != want {
|
|
t.Errorf("mediaKindVN(%q) = %q; want %q", mime, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractFileIDs(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want []string
|
|
}{
|
|
{"array", `{"FILE_ID":[32728,40]}`, []string{"32728", "40"}},
|
|
{"scalar", `{"FILE_ID":32728}`, []string{"32728"}},
|
|
{"string ids", `{"FILE_ID":["a","b"]}`, []string{"a", "b"}},
|
|
{"filters zero", `{"FILE_ID":[0,32728]}`, []string{"32728"}},
|
|
{"absent", `{"TEXT":"hi"}`, nil},
|
|
{"empty params array", `[]`, nil},
|
|
{"empty raw", ``, nil},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := extractFileIDs(json.RawMessage(tc.in))
|
|
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
|
t.Errorf("extractFileIDs(%s) = %v; want %v", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseReplyFiles(t *testing.T) {
|
|
// Realistic im.dialog.messages.get result: numeric ids, files keyed by id.
|
|
result := `{
|
|
"messages": [
|
|
{"id": 315968, "author_id": 62, "params": {"FILE_ID": [32728]}, "text": ""},
|
|
{"id": 315969, "author_id": 62, "text": "câu hỏi"}
|
|
],
|
|
"files": {
|
|
"32728": {"id": 32728, "type": "audio", "name": "voice.ogg", "size": 6789}
|
|
}
|
|
}`
|
|
files, err := parseReplyFiles(json.RawMessage(result), "315968")
|
|
if err != nil {
|
|
t.Fatalf("parseReplyFiles: %v", err)
|
|
}
|
|
if len(files) != 1 {
|
|
t.Fatalf("want 1 file, got %d: %+v", len(files), files)
|
|
}
|
|
if files[0].ID != "32728" || files[0].Name != "voice.ogg" || files[0].Type != "audio" || files[0].Size != 6789 {
|
|
t.Errorf("file mismatch: %+v", files[0])
|
|
}
|
|
}
|
|
|
|
func TestParseReplyFiles_NoMatchingMessage(t *testing.T) {
|
|
result := `{"messages":[{"id":999,"params":{"FILE_ID":[1]}}],"files":{"1":{"name":"x"}}}`
|
|
files, err := parseReplyFiles(json.RawMessage(result), "315968")
|
|
if err != nil {
|
|
t.Fatalf("parseReplyFiles: %v", err)
|
|
}
|
|
if files != nil {
|
|
t.Errorf("want nil (no message matched id), got %+v", files)
|
|
}
|
|
}
|
|
|
|
func TestParseReplyFiles_MissingFileMetadata_FallsBackToBareID(t *testing.T) {
|
|
// FILE_ID present on the message but no files map entry → still return the id
|
|
// so downloadEventFiles can fetch by id (imbot.v2.File.download needs only id).
|
|
result := `{"messages":[{"id":315968,"params":{"FILE_ID":[32728]}}],"files":[]}`
|
|
files, err := parseReplyFiles(json.RawMessage(result), "315968")
|
|
if err != nil {
|
|
t.Fatalf("parseReplyFiles: %v", err)
|
|
}
|
|
if len(files) != 1 || files[0].ID != "32728" || files[0].Name != "" {
|
|
t.Errorf("want bare-id file, got %+v", files)
|
|
}
|
|
}
|
|
|
|
func TestParseReplyFiles_EmptyResult(t *testing.T) {
|
|
if _, err := parseReplyFiles(json.RawMessage(``), "1"); err == nil {
|
|
t.Error("want error on empty result")
|
|
}
|
|
}
|
|
|
|
// replyMessagesHandler stubs im.dialog.messages.get, recording FIRST_ID and
|
|
// returning the supplied result object.
|
|
func replyMessagesHandler(t *testing.T, calls *atomic.Int32, gotFirstID *string, result any) http.HandlerFunc {
|
|
t.Helper()
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.HasSuffix(r.URL.Path, "/rest/im.dialog.messages.get.json") {
|
|
t.Errorf("unexpected REST path: %q", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
calls.Add(1)
|
|
_ = r.ParseForm()
|
|
if gotFirstID != nil {
|
|
*gotFirstID = r.Form.Get("FIRST_ID")
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"result": result})
|
|
}
|
|
}
|
|
|
|
func TestFetchReplyMessageFiles_HappyPath(t *testing.T) {
|
|
var calls atomic.Int32
|
|
var firstID string
|
|
result := map[string]any{
|
|
"messages": []map[string]any{
|
|
{"id": 315968, "params": map[string]any{"FILE_ID": []int{32728}}},
|
|
},
|
|
"files": map[string]any{
|
|
"32728": map[string]any{"id": 32728, "type": "audio", "name": "voice.ogg", "size": 6789},
|
|
},
|
|
}
|
|
srv := httptest.NewServer(replyMessagesHandler(t, &calls, &firstID, result))
|
|
defer srv.Close()
|
|
bc := newChannelWithBoundPortal(t, srv)
|
|
|
|
files, err := bc.fetchReplyMessageFiles(context.Background(), "chat1234", "315968")
|
|
if err != nil {
|
|
t.Fatalf("fetchReplyMessageFiles: %v", err)
|
|
}
|
|
// FIRST_ID must be msgID-1 so the quoted message itself is returned.
|
|
if firstID != "315967" {
|
|
t.Errorf("FIRST_ID = %q; want 315967", firstID)
|
|
}
|
|
if len(files) != 1 || files[0].Name != "voice.ogg" || files[0].Type != "audio" {
|
|
t.Fatalf("files mismatch: %+v", files)
|
|
}
|
|
}
|
|
|
|
func TestFetchReplyMessageFiles_NilClient(t *testing.T) {
|
|
bc := &Channel{}
|
|
if _, err := bc.fetchReplyMessageFiles(context.Background(), "chat1", "1"); err == nil {
|
|
t.Error("want error when no REST client bound")
|
|
}
|
|
}
|
|
|
|
func TestFetchReplyMessageFiles_EmptyArgs(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(replyMessagesHandler(t, &calls, nil, map[string]any{}))
|
|
defer srv.Close()
|
|
bc := newChannelWithBoundPortal(t, srv)
|
|
|
|
if _, err := bc.fetchReplyMessageFiles(context.Background(), "", "1"); err == nil {
|
|
t.Error("want error on empty dialog id")
|
|
}
|
|
if _, err := bc.fetchReplyMessageFiles(context.Background(), "chat1", ""); err == nil {
|
|
t.Error("want error on empty msg id")
|
|
}
|
|
if calls.Load() != 0 {
|
|
t.Errorf("should not call REST for empty args, calls=%d", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestResolveReplyContext_NotAReply(t *testing.T) {
|
|
bc := &Channel{}
|
|
evt := &Event{Params: EventParams{ReplyMessage: nil}}
|
|
prefix, media := bc.resolveReplyContext(context.Background(), evt)
|
|
if prefix != "" || media != nil {
|
|
t.Errorf("non-reply should be no-op, got prefix=%q media=%v", prefix, media)
|
|
}
|
|
}
|
|
|
|
func TestResolveReplyContext_TextBranch(t *testing.T) {
|
|
var calls atomic.Int32
|
|
// Author id 62 resolves via user.get to a display name for the note.
|
|
srv := httptest.NewServer(userGetHandler(t, &calls, map[string]userGetRaw{
|
|
"62": {ID: "62", Name: "Đặng", LastName: "Tình"},
|
|
}))
|
|
defer srv.Close()
|
|
bc := newChannelWithBoundPortal(t, srv)
|
|
|
|
evt := &Event{Params: EventParams{
|
|
DialogID: "chat1234",
|
|
ReplyMessage: &EventReplyMessage{ID: "42", AuthorID: "62", Text: "[b]báo giá[/b] chưa em"},
|
|
}}
|
|
prefix, media := bc.resolveReplyContext(context.Background(), evt)
|
|
if media != nil {
|
|
t.Errorf("text branch should not fetch media, got %v", media)
|
|
}
|
|
if !strings.Contains(prefix, "báo giá chưa em") {
|
|
t.Errorf("prefix missing sanitized quote: %q", prefix)
|
|
}
|
|
if !strings.HasPrefix(prefix, "[Đang trả lời tin của ") {
|
|
t.Errorf("prefix format wrong: %q", prefix)
|
|
}
|
|
if !strings.Contains(prefix, "Đặng Tình") {
|
|
t.Errorf("prefix should carry resolved author name: %q", prefix)
|
|
}
|
|
}
|