From ea6864ce982ff399468c99bb886b8797d03fdd55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Tue, 14 Jul 2026 22:45:50 +0800 Subject: [PATCH 01/13] fix(lark): handle link-share card content --- .../integrations/lark/content_flatten.go | 41 ++++++++++- .../integrations/lark/content_flatten_test.go | 40 ++++++++++- .../internal/integrations/lark/http_client.go | 20 +++++- .../integrations/lark/http_client_test.go | 68 ++++++++++++++++++- server/internal/integrations/lark/outbound.go | 1 + .../integrations/lark/outbound_test.go | 29 ++++++++ .../integrations/lark/ws_frame_decoder.go | 16 +++-- .../lark/ws_frame_decoder_test.go | 32 +++++++++ 8 files changed, 236 insertions(+), 11 deletions(-) diff --git a/server/internal/integrations/lark/content_flatten.go b/server/internal/integrations/lark/content_flatten.go index 85e1c6dab4d..27dd14e1feb 100644 --- a/server/internal/integrations/lark/content_flatten.go +++ b/server/internal/integrations/lark/content_flatten.go @@ -44,7 +44,7 @@ func flattenContent(msgType, rawContent string) string { case "sticker": return "[Sticker]" case "interactive": - return "[interactive card]" + return flattenInteractiveContent(rawContent) case "share_chat": return "[Shared Chat]" case "share_user": @@ -162,3 +162,42 @@ func flattenPostParagraph(spans []larkPostSpan) string { } return strings.Join(parts, " ") } + +// larkInteractiveContent models the renderable bits of an `interactive` +// card body.content. Lark card schemas vary — a link-share card (the +// shape a forwarded message-link renders as) carries title + card_link.url; +// a generic template card carries an elements[] array we don't model. So +// this is best-effort: anything we can resolve becomes text, the rest +// degrades to the [interactive card] placeholder so the agent at least +// sees that a card was attached. +type larkInteractiveContent struct { + Title string `json:"title"` + CardLink struct { + URL string `json:"url"` + } `json:"card_link"` +} + +// flattenInteractiveContent turns an `interactive` card into "title (url)" +// when both resolve, falling back to whichever is present, and finally to +// the bracketed placeholder. The URL is the load-bearing field for a +// link-share card: without it the agent cannot reach the shared resource, +// so we never silently drop it. +func flattenInteractiveContent(raw string) string { + if raw == "" { + return "" + } + var doc larkInteractiveContent + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return "[interactive card]" + } + switch { + case doc.Title != "" && doc.CardLink.URL != "": + return doc.Title + " (" + doc.CardLink.URL + ")" + case doc.CardLink.URL != "": + return doc.CardLink.URL + case doc.Title != "": + return doc.Title + default: + return "[interactive card]" + } +} diff --git a/server/internal/integrations/lark/content_flatten_test.go b/server/internal/integrations/lark/content_flatten_test.go index f20775fdab0..04a637ae083 100644 --- a/server/internal/integrations/lark/content_flatten_test.go +++ b/server/internal/integrations/lark/content_flatten_test.go @@ -84,7 +84,7 @@ func TestFlattenContent_DispatchByType(t *testing.T) { {"audio", "audio", `{"file_key":"f"}`, "[Audio]"}, {"media", "media", `{"file_key":"f"}`, "[Video]"}, {"sticker", "sticker", `{"file_key":"f"}`, "[Sticker]"}, - {"interactive", "interactive", `{"title":"t"}`, "[interactive card]"}, + {"interactive", "interactive", `{"title":"t","card_link":{"url":"https://x"}}`, "t (https://x)"}, {"share_chat", "share_chat", `{"chat_id":"oc"}`, "[Shared Chat]"}, {"merge_forward", "merge_forward", `{"content":"Merged and Forwarded Message"}`, "[forwarded messages]"}, {"unknown", "totally_new_type", `{}`, ""}, @@ -99,3 +99,41 @@ func TestFlattenContent_DispatchByType(t *testing.T) { }) } } + +// TestFlattenInteractiveContent_LinkShareCard pins the link-share card +// shape — the exact payload a forwarded Lark message-link renders as. +// title + card_link.url must become "title (url)" so the agent can reach +// the shared resource; this is the regression case for the empty-body +// bug where the decoder left interactive cards unflattened. +func TestFlattenInteractiveContent_LinkShareCard(t *testing.T) { + t.Parallel() + raw := `{"title":"Shared project note","card_link":{"url":"https://example.com/shared-note","android_url":"","ios_url":"","pc_url":""},"elements":[]}` + want := "Shared project note (https://example.com/shared-note)" + if got := flattenInteractiveContent(raw); got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestFlattenInteractiveContent_Fallbacks(t *testing.T) { + t.Parallel() + cases := []struct { + name string + raw string + want string + }{ + {"url only", `{"card_link":{"url":"https://x"}}`, "https://x"}, + {"title only", `{"title":"t"}`, "t"}, + {"empty elements no title no url", `{"elements":[]}`, "[interactive card]"}, + {"empty string", "", ""}, + {"malformed", "not json", "[interactive card]"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := flattenInteractiveContent(tc.raw); got != tc.want { + t.Errorf("got %q want %q", got, tc.want) + } + }) + } +} diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index fc1fefc7946..e2aed1bc956 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -10,6 +10,7 @@ import ( "log/slog" "net/http" "net/url" + "regexp" "strconv" "strings" "sync" @@ -62,6 +63,22 @@ const ( codeTokenInvalid = 99991664 ) +var localMarkdownImageRE = regexp.MustCompile(`(?i)!\[([^\]]*)\]\(\s*(?:file:[^)\s]+|/[^)\s]+|[a-z]:[\\/][^)\s]+)\s*\)`) + +func sanitizeMarkdownForLarkCard(markdown string) string { + return localMarkdownImageRE.ReplaceAllStringFunc(markdown, func(match string) string { + parts := localMarkdownImageRE.FindStringSubmatch(match) + if len(parts) != 2 { + return match + } + label := strings.TrimSpace(parts[1]) + if label == "" { + label = "image" + } + return fmt.Sprintf("[%s omitted]", label) + }) +} + // HTTPClientConfig configures the production Lark HTTP APIClient. type HTTPClientConfig struct { // BaseURL is an optional deployment-wide override for the Lark @@ -358,11 +375,12 @@ func (c *httpAPIClient) SendMarkdownCard(ctx context.Context, p SendMarkdownCard if err != nil { return "", err } + markdown := sanitizeMarkdownForLarkCard(p.Markdown) card := map[string]any{ "schema": "2.0", "body": map[string]any{ "elements": []any{ - map[string]any{"tag": "markdown", "content": p.Markdown}, + map[string]any{"tag": "markdown", "content": markdown}, }, }, } diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index db2fad94f90..545790b113d 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -567,6 +567,70 @@ func TestHTTPClient_SendMarkdownCard_HappyPath(t *testing.T) { // then once more implicitly when the outer body is encoded for the // HTTP request. Forgetting either pass corrupts the text Lark renders // (or worse, rejects the message with a body parse error). +func TestHTTPClient_SendMarkdownCard_RewritesLocalImages(t *testing.T) { + fake := newLarkFake(t) + fake.stubToken("tok_md", 7200) + fake.stubSend( + map[string]any{ + "code": 0, + "msg": "ok", + "data": map[string]string{"message_id": "om_md_1"}, + }, + func(r *http.Request, body map[string]string) { + var card map[string]any + if err := json.Unmarshal([]byte(body["content"]), &card); err != nil { + t.Fatalf("content is not valid card JSON: %v", err) + } + bodyDoc, _ := card["body"].(map[string]any) + elements, _ := bodyDoc["elements"].([]any) + el, _ := elements[0].(map[string]any) + content, _ := el["content"].(string) + if strings.Contains(content, "![](/var/run/app/session/card-image.png)") { + t.Fatalf("local image markdown must not be forwarded verbatim: %q", content) + } + if strings.Contains(content, "/var/run/app/session/card-image.png") { + t.Fatalf("local image path must not be exposed in Lark card content: %q", content) + } + if !strings.Contains(content, "[image omitted]") { + t.Fatalf("local image should be replaced with an omission marker: %q", content) + } + }, + ) + + c := newTestClient(fake, time.Now) + _, err := c.SendMarkdownCard(context.Background(), SendMarkdownCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc_chat_42"), + Markdown: "Scan this:\n\n![](/var/run/app/session/card-image.png)", + }) + if err != nil { + t.Fatalf("send markdown card: %v", err) + } +} + +func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { + cases := []struct { + name string + markdown string + want string + }{ + {"unix", `![](/var/run/app/image.png)`, `[image omitted]`}, + {"windows_backslash", `![scan](C:\Users\agent\image.png)`, `[scan omitted]`}, + {"windows_slash", `![](C:/Users/agent/image.png)`, `[image omitted]`}, + {"file_unix", `![](file:///var/run/app/image.png)`, `[image omitted]`}, + {"file_windows", `![](file:///C:/Users/agent/image.png)`, `[image omitted]`}, + {"file_host", `![](file://host/share/image.png)`, `[image omitted]`}, + {"remote_url", `![logo](https://example.com/image.png)`, `![logo](https://example.com/image.png)`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := sanitizeMarkdownForLarkCard(tc.markdown); got != tc.want { + t.Errorf("sanitizeMarkdownForLarkCard(%q) = %q; want %q", tc.markdown, got, tc.want) + } + }) + } +} + func TestHTTPClient_SendTextMessage_EncodesSpecialCharacters(t *testing.T) { cases := []struct { name string @@ -1109,8 +1173,8 @@ func TestHTTPClient_GetBotInfo_HappyPath(t *testing.T) { "code": 0, "msg": "ok", "bot": map[string]any{ - "open_id": "ou_bot_42", - "app_name": "PersonalAgent", + "open_id": "ou_bot_42", + "app_name": "PersonalAgent", "avatar_url": "https://example/avatar.png", }, }) diff --git a/server/internal/integrations/lark/outbound.go b/server/internal/integrations/lark/outbound.go index f84036aa879..913504555bc 100644 --- a/server/internal/integrations/lark/outbound.go +++ b/server/internal/integrations/lark/outbound.go @@ -359,6 +359,7 @@ func (p *Patcher) sendChatReply(ctx context.Context, creds InstallationCredentia if content == "" { return nil } + content = sanitizeMarkdownForLarkCard(content) target := threadReplyTarget(binding) if containsMarkdown(content) { return sendWithThreadFallback(p.cfg.Logger, "send markdown card", target, func(t ReplyTarget) error { diff --git a/server/internal/integrations/lark/outbound_test.go b/server/internal/integrations/lark/outbound_test.go index c03a8a260e9..0e6dfaceb1e 100644 --- a/server/internal/integrations/lark/outbound_test.go +++ b/server/internal/integrations/lark/outbound_test.go @@ -269,6 +269,35 @@ func TestPatcherRoutesMarkdownReplyToCard(t *testing.T) { } } +func TestPatcherRedactsEmptyAltLocalImageBeforeRouting(t *testing.T) { + p, q, api := newTestPatcher(t) + taskID := uuidFromString(t, "ee454545-ee45-ee45-ee45-eeeeeeeeeeee") + localPath := "/var/run/app/session/card-image.png" + + p.handleEvent(events.Event{ + Type: protocol.EventChatDone, + TaskID: uuidString(taskID), + ChatSessionID: uuidString(q.binding.ChatSessionID), + Payload: protocol.ChatDonePayload{ + TaskID: uuidString(taskID), + ChatSessionID: uuidString(q.binding.ChatSessionID), + Content: "Scan this:\n\n![](" + localPath + ")", + }, + }) + + api.mu.Lock() + defer api.mu.Unlock() + if len(api.textSent) != 1 { + t.Fatalf("expected one SendTextMessage call; got %d", len(api.textSent)) + } + if got := api.textSent[0].Text; got != "Scan this:\n\n[image omitted]" { + t.Errorf("local image must be redacted before routing; got %q", got) + } + if len(api.mdCardSent) != 0 { + t.Errorf("redacted plain-text body must not use SendMarkdownCard; got %d", len(api.mdCardSent)) + } +} + // TestPatcherRoutesPlainReplyToText is the inverse: a short prose // reply without any markdown syntax should stay on the cheap // msg_type=text path so the user sees a normal IM bubble. diff --git a/server/internal/integrations/lark/ws_frame_decoder.go b/server/internal/integrations/lark/ws_frame_decoder.go index f09dc2b3417..b4a40224c7b 100644 --- a/server/internal/integrations/lark/ws_frame_decoder.go +++ b/server/internal/integrations/lark/ws_frame_decoder.go @@ -92,13 +92,17 @@ func (d *LarkJSONFrameDecoder) Decode(payload []byte, inst Installation) (Inboun } // text + post are flattened synchronously here (no external calls — - // the decoder must stay fast and dependency-free). merge_forward - // leaves Body empty: it needs an HTTP round-trip to expand and is - // handled downstream by the enricher, which keys off MessageType. - // Other types (image, file, …) also leave Body empty in this MVP; - // attachment ingestion is a separate issue. + // the decoder must stay fast and dependency-free). interactive cards + // are too: the link-share form (a forwarded message-link renders as + // one) carries a title + card_link.url we can extract without a + // round-trip, and without this case the agent receives an empty body + // for every card. merge_forward leaves Body empty: it needs an HTTP + // round-trip to expand and is handled downstream by the enricher, + // which keys off MessageType. Other types (image, file, …) also + // leave Body empty in this MVP; attachment ingestion is a separate + // issue. switch evt.Message.MessageType { - case "text", "post": + case "text", "post", "interactive": msg.Body = resolveMentions(flattenContent(evt.Message.MessageType, evt.Message.Content), evt.Message.Mentions, inst.BotOpenID, botUnionID) } diff --git a/server/internal/integrations/lark/ws_frame_decoder_test.go b/server/internal/integrations/lark/ws_frame_decoder_test.go index dcc51e54aae..f2bdaee5eed 100644 --- a/server/internal/integrations/lark/ws_frame_decoder_test.go +++ b/server/internal/integrations/lark/ws_frame_decoder_test.go @@ -451,6 +451,38 @@ func TestLarkJSONFrameDecoderNonTextMessageHasEmptyBody(t *testing.T) { } } +// TestLarkJSONFrameDecoderInteractiveCardFlattened verifies that a +// link-share interactive card (the shape a forwarded Lark message-link +// renders as) is flattened end-to-end through Decode — regression for +// the empty-body bug where the decoder's switch only handled text/post. +func TestLarkJSONFrameDecoderInteractiveCardFlattened(t *testing.T) { + t.Parallel() + cardContent := `{"title":"Shared project note","card_link":{"url":"https://example.com/shared-note"},"elements":[]}` + escaped, err := json.Marshal(cardContent) + if err != nil { + t.Fatalf("marshal: %v", err) + } + raw := []byte(`{ + "type":"event_callback", + "header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"}, + "event":{ + "sender":{"sender_id":{"open_id":"ou_user"}}, + "message":{"message_id":"m","chat_id":"c","chat_type":"p2p","message_type":"interactive","content":` + string(escaped) + `} + } + }`) + msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{}) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + want := "Shared project note (https://example.com/shared-note)" + if msg.Body != want { + t.Errorf("Body = %q; want %q", msg.Body, want) + } + if msg.CommandBody != want { + t.Errorf("CommandBody = %q; want %q", msg.CommandBody, want) + } +} + // TestLarkJSONFrameDecoderPostMessageFlattened verifies that a rich-text // `post` message is flattened to plain text end-to-end through Decode — // the MUL-2951 example. Body.content is the JSON-encoded post object; we From 1034387f37ac89a283d9b6b4cd61caa1c64fbbe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Thu, 16 Jul 2026 23:11:02 +0800 Subject: [PATCH 02/13] fix(lark): parse local markdown image destinations Co-authored-by: multica-agent --- .../internal/integrations/lark/http_client.go | 157 ++++++++++++++++-- .../integrations/lark/http_client_test.go | 9 + .../integrations/lark/outbound_test.go | 28 ++++ 3 files changed, 184 insertions(+), 10 deletions(-) diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index e2aed1bc956..c2bd730ca2c 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -10,7 +10,6 @@ import ( "log/slog" "net/http" "net/url" - "regexp" "strconv" "strings" "sync" @@ -63,20 +62,158 @@ const ( codeTokenInvalid = 99991664 ) -var localMarkdownImageRE = regexp.MustCompile(`(?i)!\[([^\]]*)\]\(\s*(?:file:[^)\s]+|/[^)\s]+|[a-z]:[\\/][^)\s]+)\s*\)`) - func sanitizeMarkdownForLarkCard(markdown string) string { - return localMarkdownImageRE.ReplaceAllStringFunc(markdown, func(match string) string { - parts := localMarkdownImageRE.FindStringSubmatch(match) - if len(parts) != 2 { - return match + var sanitized strings.Builder + last := 0 + for start := 0; start < len(markdown)-1; { + relative := strings.Index(markdown[start:], "![") + if relative < 0 { + break + } + start += relative + end, label, destination, ok := parseMarkdownImage(markdown, start) + if !ok { + start += 2 + continue } - label := strings.TrimSpace(parts[1]) + if !isLocalMarkdownImageDestination(destination) { + start = end + continue + } + + sanitized.WriteString(markdown[last:start]) + label = strings.TrimSpace(label) if label == "" { label = "image" } - return fmt.Sprintf("[%s omitted]", label) - }) + fmt.Fprintf(&sanitized, "[%s omitted]", label) + last = end + start = end + } + if last == 0 { + return markdown + } + sanitized.WriteString(markdown[last:]) + return sanitized.String() +} + +// parseMarkdownImage returns the full image span and its destination. It uses +// a small scanner instead of a regular expression because destinations may be +// angle-bracketed, followed by a title, or contain balanced/escaped +// parentheses. +func parseMarkdownImage(markdown string, start int) (end int, label, destination string, ok bool) { + if start < 0 || start+2 > len(markdown) || markdown[start:start+2] != "![" { + return 0, "", "", false + } + + labelEnd := -1 + for i := start + 2; i < len(markdown); i++ { + if markdown[i] == '\\' { + i++ + continue + } + if markdown[i] == ']' { + labelEnd = i + break + } + } + if labelEnd < 0 || labelEnd+1 >= len(markdown) || markdown[labelEnd+1] != '(' { + return 0, "", "", false + } + + i := labelEnd + 2 + for i < len(markdown) && isMarkdownSpace(markdown[i]) { + i++ + } + destinationStart := i + destinationEnd := -1 + angleDestination := i < len(markdown) && markdown[i] == '<' + if angleDestination { + destinationStart++ + i++ + } + + depth := 0 + var titleQuote byte + titleParen := false + for i < len(markdown) { + c := markdown[i] + if c == '\\' { + i += 2 + continue + } + if angleDestination { + if c == '>' { + destinationEnd = i + angleDestination = false + } + i++ + continue + } + if titleQuote != 0 { + if c == titleQuote { + titleQuote = 0 + } + i++ + continue + } + if titleParen { + if c == ')' { + titleParen = false + } + i++ + continue + } + if isMarkdownSpace(c) && depth == 0 && destinationEnd < 0 { + next := i + for next < len(markdown) && isMarkdownSpace(markdown[next]) { + next++ + } + if next < len(markdown) && (markdown[next] == '"' || markdown[next] == '\'') { + destinationEnd = i + titleQuote = markdown[next] + i = next + 1 + continue + } + if next < len(markdown) && markdown[next] == '(' { + destinationEnd = i + titleParen = true + i = next + 1 + continue + } + } + switch c { + case '(': + depth++ + case ')': + if depth == 0 { + if destinationEnd < 0 { + destinationEnd = i + } + return i + 1, markdown[start+2 : labelEnd], + strings.TrimSpace(markdown[destinationStart:destinationEnd]), true + } + depth-- + } + i++ + } + return 0, "", "", false +} + +func isMarkdownSpace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' +} + +func isLocalMarkdownImageDestination(destination string) bool { + lower := strings.ToLower(destination) + if strings.HasPrefix(lower, "file:") || strings.HasPrefix(destination, "/") || + strings.HasPrefix(destination, `\\`) { + return true + } + return len(destination) >= 3 && + ((destination[0] >= 'a' && destination[0] <= 'z') || + (destination[0] >= 'A' && destination[0] <= 'Z')) && + destination[1] == ':' && (destination[2] == '/' || destination[2] == '\\') } // HTTPClientConfig configures the production Lark HTTP APIClient. diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index 545790b113d..e74a0cfaa2d 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -620,7 +620,16 @@ func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { {"file_unix", `![](file:///var/run/app/image.png)`, `[image omitted]`}, {"file_windows", `![](file:///C:/Users/agent/image.png)`, `[image omitted]`}, {"file_host", `![](file://host/share/image.png)`, `[image omitted]`}, + {"windows_spaces", `![scan](C:\Program Files\app\x.png)`, `[scan omitted]`}, + {"angle_windows_spaces", `![scan]()`, `[scan omitted]`}, + {"windows_unc", `![scan](\\server\share\x.png)`, `[scan omitted]`}, + {"optional_title", `![scan](/tmp/x.png "preview")`, `[scan omitted]`}, + {"balanced_parentheses", `![scan](/tmp/screenshot(1).png)`, `[scan omitted]`}, + {"escaped_parenthesis", `![scan](/tmp/screenshot\(1\).png)`, `[scan omitted]`}, {"remote_url", `![logo](https://example.com/image.png)`, `![logo](https://example.com/image.png)`}, + {"remote_url_with_title", `![logo](https://example.com/image.png "preview")`, `![logo](https://example.com/image.png "preview")`}, + {"mixed_images", `![logo](https://example.com/logo.png) ![scan](/tmp/scan.png)`, `![logo](https://example.com/logo.png) [scan omitted]`}, + {"malformed_local_image", `![scan](/tmp/scan.png`, `![scan](/tmp/scan.png`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/server/internal/integrations/lark/outbound_test.go b/server/internal/integrations/lark/outbound_test.go index 0e6dfaceb1e..7b4ef980cce 100644 --- a/server/internal/integrations/lark/outbound_test.go +++ b/server/internal/integrations/lark/outbound_test.go @@ -298,6 +298,34 @@ func TestPatcherRedactsEmptyAltLocalImageBeforeRouting(t *testing.T) { } } +func TestPatcherRedactsLocalImageBeforeCardRouting(t *testing.T) { + p, q, api := newTestPatcher(t) + taskID := uuidFromString(t, "ee464646-ee46-ee46-ee46-eeeeeeeeeeee") + + p.handleEvent(events.Event{ + Type: protocol.EventChatDone, + TaskID: uuidString(taskID), + ChatSessionID: uuidString(q.binding.ChatSessionID), + Payload: protocol.ChatDonePayload{ + TaskID: uuidString(taskID), + ChatSessionID: uuidString(q.binding.ChatSessionID), + Content: "**Scan:** ![result](/tmp/screenshot(1).png \"preview\")", + }, + }) + + api.mu.Lock() + defer api.mu.Unlock() + if len(api.mdCardSent) != 1 { + t.Fatalf("expected one SendMarkdownCard call; got %d", len(api.mdCardSent)) + } + if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]" { + t.Errorf("local image must be redacted before card routing; got %q", got) + } + if len(api.textSent) != 0 { + t.Errorf("markdown body must not use SendTextMessage; got %d", len(api.textSent)) + } +} + // TestPatcherRoutesPlainReplyToText is the inverse: a short prose // reply without any markdown syntax should stay on the cheap // msg_type=text path so the user sees a normal IM bubble. From b84055064a962613707671129cb54d706ab76a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Thu, 16 Jul 2026 23:29:08 +0800 Subject: [PATCH 03/13] fix(lark): parse nested markdown image labels Co-Authored-By: Claude Co-authored-by: multica-agent --- server/internal/integrations/lark/http_client.go | 14 +++++++++++--- .../internal/integrations/lark/http_client_test.go | 2 ++ server/internal/integrations/lark/outbound_test.go | 12 ++++++------ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index c2bd730ca2c..eeebdfd4b16 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -107,14 +107,22 @@ func parseMarkdownImage(markdown string, start int) (end int, label, destination } labelEnd := -1 + labelDepth := 0 for i := start + 2; i < len(markdown); i++ { if markdown[i] == '\\' { i++ continue } - if markdown[i] == ']' { - labelEnd = i - break + switch markdown[i] { + case '[': + labelDepth++ + case ']': + if labelDepth == 0 { + labelEnd = i + i = len(markdown) + continue + } + labelDepth-- } } if labelEnd < 0 || labelEnd+1 >= len(markdown) || markdown[labelEnd+1] != '(' { diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index e74a0cfaa2d..7aaae9d0500 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -625,6 +625,8 @@ func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { {"windows_unc", `![scan](\\server\share\x.png)`, `[scan omitted]`}, {"optional_title", `![scan](/tmp/x.png "preview")`, `[scan omitted]`}, {"balanced_parentheses", `![scan](/tmp/screenshot(1).png)`, `[scan omitted]`}, + {"label_balanced_brackets", `![scan [annotated]](/tmp/x.png)`, `[scan [annotated] omitted]`}, + {"label_escaped_bracket", `![scan \] annotated](/tmp/x.png)`, `[scan \] annotated omitted]`}, {"escaped_parenthesis", `![scan](/tmp/screenshot\(1\).png)`, `[scan omitted]`}, {"remote_url", `![logo](https://example.com/image.png)`, `![logo](https://example.com/image.png)`}, {"remote_url_with_title", `![logo](https://example.com/image.png "preview")`, `![logo](https://example.com/image.png "preview")`}, diff --git a/server/internal/integrations/lark/outbound_test.go b/server/internal/integrations/lark/outbound_test.go index 7b4ef980cce..7a5d72d4a71 100644 --- a/server/internal/integrations/lark/outbound_test.go +++ b/server/internal/integrations/lark/outbound_test.go @@ -269,7 +269,7 @@ func TestPatcherRoutesMarkdownReplyToCard(t *testing.T) { } } -func TestPatcherRedactsEmptyAltLocalImageBeforeRouting(t *testing.T) { +func TestPatcherRedactsBalancedLabelLocalImageBeforeTextRouting(t *testing.T) { p, q, api := newTestPatcher(t) taskID := uuidFromString(t, "ee454545-ee45-ee45-ee45-eeeeeeeeeeee") localPath := "/var/run/app/session/card-image.png" @@ -281,7 +281,7 @@ func TestPatcherRedactsEmptyAltLocalImageBeforeRouting(t *testing.T) { Payload: protocol.ChatDonePayload{ TaskID: uuidString(taskID), ChatSessionID: uuidString(q.binding.ChatSessionID), - Content: "Scan this:\n\n![](" + localPath + ")", + Content: "Scan this:\n\n![scan [annotated]](" + localPath + ")", }, }) @@ -290,7 +290,7 @@ func TestPatcherRedactsEmptyAltLocalImageBeforeRouting(t *testing.T) { if len(api.textSent) != 1 { t.Fatalf("expected one SendTextMessage call; got %d", len(api.textSent)) } - if got := api.textSent[0].Text; got != "Scan this:\n\n[image omitted]" { + if got := api.textSent[0].Text; got != "Scan this:\n\n[scan [annotated] omitted]" { t.Errorf("local image must be redacted before routing; got %q", got) } if len(api.mdCardSent) != 0 { @@ -298,7 +298,7 @@ func TestPatcherRedactsEmptyAltLocalImageBeforeRouting(t *testing.T) { } } -func TestPatcherRedactsLocalImageBeforeCardRouting(t *testing.T) { +func TestPatcherRedactsBalancedLabelLocalImageBeforeCardRouting(t *testing.T) { p, q, api := newTestPatcher(t) taskID := uuidFromString(t, "ee464646-ee46-ee46-ee46-eeeeeeeeeeee") @@ -309,7 +309,7 @@ func TestPatcherRedactsLocalImageBeforeCardRouting(t *testing.T) { Payload: protocol.ChatDonePayload{ TaskID: uuidString(taskID), ChatSessionID: uuidString(q.binding.ChatSessionID), - Content: "**Scan:** ![result](/tmp/screenshot(1).png \"preview\")", + Content: "**Scan:** ![result [annotated]](/tmp/screenshot(1).png \"preview\")", }, }) @@ -318,7 +318,7 @@ func TestPatcherRedactsLocalImageBeforeCardRouting(t *testing.T) { if len(api.mdCardSent) != 1 { t.Fatalf("expected one SendMarkdownCard call; got %d", len(api.mdCardSent)) } - if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]" { + if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result [annotated] omitted]" { t.Errorf("local image must be redacted before card routing; got %q", got) } if len(api.textSent) != 0 { From 9864ef49e4bd13196b4e98dd6d4cd7ff9bb55237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Thu, 16 Jul 2026 23:58:00 +0800 Subject: [PATCH 04/13] fix(lark): redact reference markdown images Co-Authored-By: Claude Co-authored-by: multica-agent --- .../internal/integrations/lark/http_client.go | 198 ++++++++++++++++-- .../integrations/lark/http_client_test.go | 5 + .../integrations/lark/outbound_test.go | 12 +- 3 files changed, 186 insertions(+), 29 deletions(-) diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index eeebdfd4b16..7c12ef7f589 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -63,15 +63,19 @@ const ( ) func sanitizeMarkdownForLarkCard(markdown string) string { + definitions := parseMarkdownReferenceDefinitions(markdown) + redactedDefinitions := map[string]struct{}{} + var sanitized strings.Builder last := 0 + changed := false for start := 0; start < len(markdown)-1; { relative := strings.Index(markdown[start:], "![") if relative < 0 { break } start += relative - end, label, destination, ok := parseMarkdownImage(markdown, start) + end, label, destination, referenceKey, ok := parseMarkdownImage(markdown, start, definitions) if !ok { start += 2 continue @@ -87,49 +91,96 @@ func sanitizeMarkdownForLarkCard(markdown string) string { label = "image" } fmt.Fprintf(&sanitized, "[%s omitted]", label) + if referenceKey != "" { + redactedDefinitions[referenceKey] = struct{}{} + } last = end start = end + changed = true } - if last == 0 { + if !changed { return markdown } sanitized.WriteString(markdown[last:]) - return sanitized.String() + result := sanitized.String() + if len(redactedDefinitions) > 0 { + result = removeMarkdownReferenceDefinitions(result, redactedDefinitions) + } + return result +} + +type markdownReferenceDefinition struct { + destination string } // parseMarkdownImage returns the full image span and its destination. It uses // a small scanner instead of a regular expression because destinations may be -// angle-bracketed, followed by a title, or contain balanced/escaped -// parentheses. -func parseMarkdownImage(markdown string, start int) (end int, label, destination string, ok bool) { +// angle-bracketed, followed by a title, contain balanced/escaped parentheses, +// or be supplied through a reference-style definition. +func parseMarkdownImage(markdown string, start int, definitions map[string]markdownReferenceDefinition) (end int, label, destination, referenceKey string, ok bool) { if start < 0 || start+2 > len(markdown) || markdown[start:start+2] != "![" { - return 0, "", "", false + return 0, "", "", "", false } - labelEnd := -1 - labelDepth := 0 - for i := start + 2; i < len(markdown); i++ { + labelEnd, ok := findMarkdownLabelEnd(markdown, start+1) + if !ok { + return 0, "", "", "", false + } + label = markdown[start+2 : labelEnd] + if labelEnd+1 < len(markdown) && markdown[labelEnd+1] == '(' { + end, destination, ok := parseInlineMarkdownImageDestination(markdown, labelEnd+1) + return end, label, destination, "", ok + } + if labelEnd+1 < len(markdown) && markdown[labelEnd+1] == '[' { + referenceEnd, ok := findMarkdownLabelEnd(markdown, labelEnd+1) + if !ok { + return 0, "", "", "", false + } + referenceLabel := markdown[labelEnd+2 : referenceEnd] + if referenceLabel == "" { + referenceLabel = label + } + key := normalizeMarkdownReferenceLabel(referenceLabel) + definition, ok := definitions[key] + if !ok { + return 0, "", "", "", false + } + return referenceEnd + 1, label, definition.destination, key, true + } + + key := normalizeMarkdownReferenceLabel(label) + definition, ok := definitions[key] + if !ok { + return 0, "", "", "", false + } + return labelEnd + 1, label, definition.destination, key, true +} + +func findMarkdownLabelEnd(markdown string, open int) (int, bool) { + if open < 0 || open >= len(markdown) || markdown[open] != '[' { + return 0, false + } + depth := 0 + for i := open + 1; i < len(markdown); i++ { if markdown[i] == '\\' { i++ continue } switch markdown[i] { case '[': - labelDepth++ + depth++ case ']': - if labelDepth == 0 { - labelEnd = i - i = len(markdown) - continue + if depth == 0 { + return i, true } - labelDepth-- + depth-- } } - if labelEnd < 0 || labelEnd+1 >= len(markdown) || markdown[labelEnd+1] != '(' { - return 0, "", "", false - } + return 0, false +} - i := labelEnd + 2 +func parseInlineMarkdownImageDestination(markdown string, openParen int) (end int, destination string, ok bool) { + i := openParen + 1 for i < len(markdown) && isMarkdownSpace(markdown[i]) { i++ } @@ -198,14 +249,115 @@ func parseMarkdownImage(markdown string, start int) (end int, label, destination if destinationEnd < 0 { destinationEnd = i } - return i + 1, markdown[start+2 : labelEnd], - strings.TrimSpace(markdown[destinationStart:destinationEnd]), true + return i + 1, strings.TrimSpace(markdown[destinationStart:destinationEnd]), true } depth-- } i++ } - return 0, "", "", false + return 0, "", false +} + +func parseMarkdownReferenceDefinitions(markdown string) map[string]markdownReferenceDefinition { + definitions := map[string]markdownReferenceDefinition{} + for lineStart := 0; lineStart < len(markdown); { + lineEnd := lineStart + for lineEnd < len(markdown) && markdown[lineEnd] != '\n' { + lineEnd++ + } + label, destination, ok := parseMarkdownReferenceDefinitionLine(markdown[lineStart:lineEnd]) + if ok { + key := normalizeMarkdownReferenceLabel(label) + if _, exists := definitions[key]; !exists { + definitions[key] = markdownReferenceDefinition{destination: destination} + } + } + lineStart = lineEnd + if lineStart < len(markdown) && markdown[lineStart] == '\n' { + lineStart++ + } + } + return definitions +} + +func parseMarkdownReferenceDefinitionLine(line string) (label, destination string, ok bool) { + line = strings.TrimRight(line, "\r") + i := 0 + indent := 0 + for i < len(line) && line[i] == ' ' && indent < 4 { + i++ + indent++ + } + if indent > 3 || i >= len(line) || line[i] != '[' { + return "", "", false + } + labelEnd, ok := findMarkdownLabelEnd(line, i) + if !ok || labelEnd+1 >= len(line) || line[labelEnd+1] != ':' { + return "", "", false + } + label = line[i+1 : labelEnd] + if normalizeMarkdownReferenceLabel(label) == "" { + return "", "", false + } + i = labelEnd + 2 + for i < len(line) && isMarkdownSpace(line[i]) { + i++ + } + if i >= len(line) { + return "", "", false + } + if line[i] == '<' { + start := i + 1 + for i = start; i < len(line); i++ { + if line[i] == '\\' { + i++ + continue + } + if line[i] == '>' { + return label, strings.TrimSpace(line[start:i]), true + } + } + return "", "", false + } + + start := i + for i < len(line) && !isMarkdownSpace(line[i]) { + if line[i] == '\\' { + i += 2 + continue + } + i++ + } + return label, strings.TrimSpace(line[start:i]), true +} + +func removeMarkdownReferenceDefinitions(markdown string, redactedDefinitions map[string]struct{}) string { + var sanitized strings.Builder + for lineStart := 0; lineStart < len(markdown); { + lineEnd := lineStart + for lineEnd < len(markdown) && markdown[lineEnd] != '\n' { + lineEnd++ + } + nextLineStart := lineEnd + if nextLineStart < len(markdown) && markdown[nextLineStart] == '\n' { + nextLineStart++ + } + + line := markdown[lineStart:lineEnd] + label, destination, ok := parseMarkdownReferenceDefinitionLine(line) + _, redact := redactedDefinitions[normalizeMarkdownReferenceLabel(label)] + if ok && redact && isLocalMarkdownImageDestination(destination) { + lineStart = nextLineStart + continue + } + sanitized.WriteString(markdown[lineStart:nextLineStart]) + lineStart = nextLineStart + } + return sanitized.String() +} + +func normalizeMarkdownReferenceLabel(label string) string { + return strings.ToLower(strings.Join(strings.Fields(label), " ")) } func isMarkdownSpace(c byte) bool { diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index 7aaae9d0500..0098952ab53 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -628,6 +628,11 @@ func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { {"label_balanced_brackets", `![scan [annotated]](/tmp/x.png)`, `[scan [annotated] omitted]`}, {"label_escaped_bracket", `![scan \] annotated](/tmp/x.png)`, `[scan \] annotated omitted]`}, {"escaped_parenthesis", `![scan](/tmp/screenshot\(1\).png)`, `[scan omitted]`}, + {"reference_full", "![scan][local]\n\n[local]: /tmp/x.png", "[scan omitted]\n\n"}, + {"reference_collapsed", "![scan][]\n[scan]: /tmp/x.png", "[scan omitted]\n"}, + {"reference_shortcut", "![scan]\n[scan]: /tmp/x.png", "[scan omitted]\n"}, + {"reference_angle_windows_spaces", "![scan][local]\n[local]: ", "[scan omitted]\n"}, + {"reference_remote", "![logo][remote]\n[remote]: https://example.com/image.png", "![logo][remote]\n[remote]: https://example.com/image.png"}, {"remote_url", `![logo](https://example.com/image.png)`, `![logo](https://example.com/image.png)`}, {"remote_url_with_title", `![logo](https://example.com/image.png "preview")`, `![logo](https://example.com/image.png "preview")`}, {"mixed_images", `![logo](https://example.com/logo.png) ![scan](/tmp/scan.png)`, `![logo](https://example.com/logo.png) [scan omitted]`}, diff --git a/server/internal/integrations/lark/outbound_test.go b/server/internal/integrations/lark/outbound_test.go index 7a5d72d4a71..02caf3a9356 100644 --- a/server/internal/integrations/lark/outbound_test.go +++ b/server/internal/integrations/lark/outbound_test.go @@ -269,7 +269,7 @@ func TestPatcherRoutesMarkdownReplyToCard(t *testing.T) { } } -func TestPatcherRedactsBalancedLabelLocalImageBeforeTextRouting(t *testing.T) { +func TestPatcherRedactsReferenceLocalImageBeforeTextRouting(t *testing.T) { p, q, api := newTestPatcher(t) taskID := uuidFromString(t, "ee454545-ee45-ee45-ee45-eeeeeeeeeeee") localPath := "/var/run/app/session/card-image.png" @@ -281,7 +281,7 @@ func TestPatcherRedactsBalancedLabelLocalImageBeforeTextRouting(t *testing.T) { Payload: protocol.ChatDonePayload{ TaskID: uuidString(taskID), ChatSessionID: uuidString(q.binding.ChatSessionID), - Content: "Scan this:\n\n![scan [annotated]](" + localPath + ")", + Content: "Scan this:\n\n![scan][local]\n\n[local]: " + localPath, }, }) @@ -290,7 +290,7 @@ func TestPatcherRedactsBalancedLabelLocalImageBeforeTextRouting(t *testing.T) { if len(api.textSent) != 1 { t.Fatalf("expected one SendTextMessage call; got %d", len(api.textSent)) } - if got := api.textSent[0].Text; got != "Scan this:\n\n[scan [annotated] omitted]" { + if got := api.textSent[0].Text; got != "Scan this:\n\n[scan omitted]\n\n" { t.Errorf("local image must be redacted before routing; got %q", got) } if len(api.mdCardSent) != 0 { @@ -298,7 +298,7 @@ func TestPatcherRedactsBalancedLabelLocalImageBeforeTextRouting(t *testing.T) { } } -func TestPatcherRedactsBalancedLabelLocalImageBeforeCardRouting(t *testing.T) { +func TestPatcherRedactsReferenceLocalImageBeforeCardRouting(t *testing.T) { p, q, api := newTestPatcher(t) taskID := uuidFromString(t, "ee464646-ee46-ee46-ee46-eeeeeeeeeeee") @@ -309,7 +309,7 @@ func TestPatcherRedactsBalancedLabelLocalImageBeforeCardRouting(t *testing.T) { Payload: protocol.ChatDonePayload{ TaskID: uuidString(taskID), ChatSessionID: uuidString(q.binding.ChatSessionID), - Content: "**Scan:** ![result [annotated]](/tmp/screenshot(1).png \"preview\")", + Content: "**Scan:** ![result][local]\n\n[local]: /tmp/screenshot(1).png \"preview\"", }, }) @@ -318,7 +318,7 @@ func TestPatcherRedactsBalancedLabelLocalImageBeforeCardRouting(t *testing.T) { if len(api.mdCardSent) != 1 { t.Fatalf("expected one SendMarkdownCard call; got %d", len(api.mdCardSent)) } - if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result [annotated] omitted]" { + if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]\n\n" { t.Errorf("local image must be redacted before card routing; got %q", got) } if len(api.textSent) != 0 { From 5b994d82fffaf52ab88f525f5da7394875392e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Fri, 17 Jul 2026 00:13:04 +0800 Subject: [PATCH 05/13] fix(lark): redact multiline image references Co-Authored-By: Claude Co-authored-by: multica-agent --- .../internal/integrations/lark/http_client.go | 224 ++++++++++++++---- .../integrations/lark/http_client_test.go | 6 + .../integrations/lark/outbound_test.go | 4 +- 3 files changed, 185 insertions(+), 49 deletions(-) diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index 7c12ef7f589..7f305ebb610 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -260,102 +260,232 @@ func parseInlineMarkdownImageDestination(markdown string, openParen int) (end in func parseMarkdownReferenceDefinitions(markdown string) map[string]markdownReferenceDefinition { definitions := map[string]markdownReferenceDefinition{} - for lineStart := 0; lineStart < len(markdown); { - lineEnd := lineStart - for lineEnd < len(markdown) && markdown[lineEnd] != '\n' { - lineEnd++ - } - label, destination, ok := parseMarkdownReferenceDefinitionLine(markdown[lineStart:lineEnd]) + for start := 0; start < len(markdown); { + label, destination, end, ok := parseMarkdownReferenceDefinitionAt(markdown, start) if ok { key := normalizeMarkdownReferenceLabel(label) if _, exists := definitions[key]; !exists { definitions[key] = markdownReferenceDefinition{destination: destination} } + start = end + continue } - lineStart = lineEnd - if lineStart < len(markdown) && markdown[lineStart] == '\n' { - lineStart++ - } + start = nextMarkdownLineStart(markdown, start) } return definitions } -func parseMarkdownReferenceDefinitionLine(line string) (label, destination string, ok bool) { - line = strings.TrimRight(line, "\r") - i := 0 +func parseMarkdownReferenceDefinitionAt(markdown string, start int) (label, destination string, end int, ok bool) { + i := start indent := 0 - for i < len(line) && line[i] == ' ' && indent < 4 { + for i < len(markdown) && markdown[i] == ' ' && indent < 4 { i++ indent++ } - if indent > 3 || i >= len(line) || line[i] != '[' { - return "", "", false + if indent > 3 || i >= len(markdown) || markdown[i] != '[' { + return "", "", 0, false } - labelEnd, ok := findMarkdownLabelEnd(line, i) - if !ok || labelEnd+1 >= len(line) || line[labelEnd+1] != ':' { - return "", "", false + labelEnd, ok := findMarkdownLabelEnd(markdown, i) + if !ok || labelEnd+1 >= len(markdown) || markdown[labelEnd+1] != ':' { + return "", "", 0, false } - label = line[i+1 : labelEnd] + label = markdown[i+1 : labelEnd] if normalizeMarkdownReferenceLabel(label) == "" { - return "", "", false + return "", "", 0, false } i = labelEnd + 2 - for i < len(line) && isMarkdownSpace(line[i]) { + i, ok = skipMarkdownReferenceDefinitionSpaces(markdown, i) + if !ok || i >= len(markdown) { + return "", "", 0, false + } + + destination, i, ok = parseMarkdownReferenceDefinitionDestination(markdown, i) + if !ok || destination == "" { + return "", "", 0, false + } + end = parseMarkdownReferenceDefinitionTitleEnd(markdown, i) + return label, destination, end, true +} + +func skipMarkdownReferenceDefinitionSpaces(markdown string, i int) (int, bool) { + for i < len(markdown) && (markdown[i] == ' ' || markdown[i] == '\t') { i++ } - if i >= len(line) { - return "", "", false + if i < len(markdown) && (markdown[i] == '\n' || markdown[i] == '\r') { + i = consumeMarkdownLineEnding(markdown, i) + indent := 0 + for i < len(markdown) && markdown[i] == ' ' && indent < 4 { + i++ + indent++ + } + if indent > 3 || i >= len(markdown) || markdown[i] == '\n' || markdown[i] == '\r' { + return 0, false + } } - if line[i] == '<' { + return i, true +} + +func parseMarkdownReferenceDefinitionDestination(markdown string, i int) (string, int, bool) { + if markdown[i] == '<' { start := i + 1 - for i = start; i < len(line); i++ { - if line[i] == '\\' { + for i = start; i < len(markdown); i++ { + if markdown[i] == '\\' { i++ continue } - if line[i] == '>' { - return label, strings.TrimSpace(line[start:i]), true + if markdown[i] == '\n' || markdown[i] == '\r' { + return "", 0, false + } + if markdown[i] == '>' { + return strings.TrimSpace(markdown[start:i]), i + 1, true } } - return "", "", false + return "", 0, false } start := i - for i < len(line) && !isMarkdownSpace(line[i]) { - if line[i] == '\\' { + depth := 0 + for i < len(markdown) { + if markdown[i] == '\\' { i += 2 continue } + if markdown[i] == '\n' || markdown[i] == '\r' || markdown[i] == ' ' || markdown[i] == '\t' { + break + } + switch markdown[i] { + case '(': + depth++ + case ')': + if depth == 0 { + break + } + depth-- + } i++ } - return label, strings.TrimSpace(line[start:i]), true + return strings.TrimSpace(markdown[start:i]), i, true } -func removeMarkdownReferenceDefinitions(markdown string, redactedDefinitions map[string]struct{}) string { - var sanitized strings.Builder - for lineStart := 0; lineStart < len(markdown); { - lineEnd := lineStart - for lineEnd < len(markdown) && markdown[lineEnd] != '\n' { - lineEnd++ +func parseMarkdownReferenceDefinitionTitleEnd(markdown string, i int) int { + lineEnd := markdownLineEnd(markdown, i) + j := i + for j < lineEnd && (markdown[j] == ' ' || markdown[j] == '\t') { + j++ + } + if j >= lineEnd { + end := lineEnd + if end < len(markdown) { + next := consumeMarkdownLineEnding(markdown, end) + if titleEnd, ok := parseIndentedMarkdownReferenceDefinitionTitle(markdown, next); ok { + return titleEnd + } + end = next + } + return end + } + if end, ok := parseMarkdownReferenceDefinitionTitle(markdown, j, lineEnd); ok { + if end < len(markdown) && (markdown[end] == '\n' || markdown[end] == '\r') { + return consumeMarkdownLineEnding(markdown, end) + } + return end + } + return lineEnd +} + +func parseIndentedMarkdownReferenceDefinitionTitle(markdown string, start int) (int, bool) { + lineEnd := markdownLineEnd(markdown, start) + i := start + indent := 0 + for i < lineEnd && markdown[i] == ' ' && indent < 4 { + i++ + indent++ + } + if indent > 3 { + return 0, false + } + end, ok := parseMarkdownReferenceDefinitionTitle(markdown, i, lineEnd) + if !ok { + return 0, false + } + if end < len(markdown) && (markdown[end] == '\n' || markdown[end] == '\r') { + return consumeMarkdownLineEnding(markdown, end), true + } + return end, true +} + +func parseMarkdownReferenceDefinitionTitle(markdown string, i, lineEnd int) (int, bool) { + if i >= lineEnd { + return 0, false + } + var close byte + switch markdown[i] { + case '"': + close = '"' + case '\'': + close = '\'' + case '(': + close = ')' + default: + return 0, false + } + for i++; i < lineEnd; i++ { + if markdown[i] == '\\' { + i++ + continue } - nextLineStart := lineEnd - if nextLineStart < len(markdown) && markdown[nextLineStart] == '\n' { - nextLineStart++ + if markdown[i] == close { + i++ + for i < lineEnd && (markdown[i] == ' ' || markdown[i] == '\t') { + i++ + } + return i, i == lineEnd } + } + return 0, false +} - line := markdown[lineStart:lineEnd] - label, destination, ok := parseMarkdownReferenceDefinitionLine(line) +func removeMarkdownReferenceDefinitions(markdown string, redactedDefinitions map[string]struct{}) string { + var sanitized strings.Builder + for start := 0; start < len(markdown); { + label, destination, end, ok := parseMarkdownReferenceDefinitionAt(markdown, start) _, redact := redactedDefinitions[normalizeMarkdownReferenceLabel(label)] if ok && redact && isLocalMarkdownImageDestination(destination) { - lineStart = nextLineStart + start = end continue } - sanitized.WriteString(markdown[lineStart:nextLineStart]) - lineStart = nextLineStart + next := nextMarkdownLineStart(markdown, start) + sanitized.WriteString(markdown[start:next]) + start = next } return sanitized.String() } +func markdownLineEnd(markdown string, start int) int { + for start < len(markdown) && markdown[start] != '\n' && markdown[start] != '\r' { + start++ + } + return start +} + +func consumeMarkdownLineEnding(markdown string, i int) int { + if i < len(markdown) && markdown[i] == '\r' { + i++ + if i < len(markdown) && markdown[i] == '\n' { + i++ + } + return i + } + if i < len(markdown) && markdown[i] == '\n' { + i++ + } + return i +} + +func nextMarkdownLineStart(markdown string, start int) int { + return consumeMarkdownLineEnding(markdown, markdownLineEnd(markdown, start)) +} + func normalizeMarkdownReferenceLabel(label string) string { return strings.ToLower(strings.Join(strings.Fields(label), " ")) } diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index 0098952ab53..1876e38c622 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -632,6 +632,12 @@ func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { {"reference_collapsed", "![scan][]\n[scan]: /tmp/x.png", "[scan omitted]\n"}, {"reference_shortcut", "![scan]\n[scan]: /tmp/x.png", "[scan omitted]\n"}, {"reference_angle_windows_spaces", "![scan][local]\n[local]: ", "[scan omitted]\n"}, + {"reference_multiline_unix", "![scan][local]\n\n[local]:\n/tmp/x.png", "[scan omitted]\n\n"}, + {"reference_multiline_windows", "![scan][local]\n[local]:\n C:\\Program Files\\app\\x.png", "[scan omitted]\n"}, + {"reference_multiline_unc", "![scan][local]\n[local]:\n\\\\server\\share\\x.png", "[scan omitted]\n"}, + {"reference_multiline_file", "![scan][local]\n[local]:\nfile:///tmp/x.png", "[scan omitted]\n"}, + {"reference_multiline_remote", "![logo][remote]\n[remote]:\nhttps://example.com/image.png", "![logo][remote]\n[remote]:\nhttps://example.com/image.png"}, + {"reference_multiline_title", "![scan][local]\n[local]:\n/tmp/x.png\n \"preview\"\nnext", "[scan omitted]\nnext"}, {"reference_remote", "![logo][remote]\n[remote]: https://example.com/image.png", "![logo][remote]\n[remote]: https://example.com/image.png"}, {"remote_url", `![logo](https://example.com/image.png)`, `![logo](https://example.com/image.png)`}, {"remote_url_with_title", `![logo](https://example.com/image.png "preview")`, `![logo](https://example.com/image.png "preview")`}, diff --git a/server/internal/integrations/lark/outbound_test.go b/server/internal/integrations/lark/outbound_test.go index 02caf3a9356..916c3f4f95d 100644 --- a/server/internal/integrations/lark/outbound_test.go +++ b/server/internal/integrations/lark/outbound_test.go @@ -281,7 +281,7 @@ func TestPatcherRedactsReferenceLocalImageBeforeTextRouting(t *testing.T) { Payload: protocol.ChatDonePayload{ TaskID: uuidString(taskID), ChatSessionID: uuidString(q.binding.ChatSessionID), - Content: "Scan this:\n\n![scan][local]\n\n[local]: " + localPath, + Content: "Scan this:\n\n![scan][local]\n\n[local]:\n" + localPath, }, }) @@ -309,7 +309,7 @@ func TestPatcherRedactsReferenceLocalImageBeforeCardRouting(t *testing.T) { Payload: protocol.ChatDonePayload{ TaskID: uuidString(taskID), ChatSessionID: uuidString(q.binding.ChatSessionID), - Content: "**Scan:** ![result][local]\n\n[local]: /tmp/screenshot(1).png \"preview\"", + Content: "**Scan:** ![result][local]\n\n[local]:\n /tmp/screenshot(1).png\n \"preview\"", }, }) From be7cd0fa398444f6d7dd2c753bc12a4843eadc72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Fri, 17 Jul 2026 00:34:05 +0800 Subject: [PATCH 06/13] fix(lark): classify encoded image destinations Co-Authored-By: Claude Co-authored-by: multica-agent --- .../internal/integrations/lark/http_client.go | 124 ++++++++++++++++-- .../integrations/lark/http_client_test.go | 8 ++ .../integrations/lark/outbound_test.go | 8 +- 3 files changed, 122 insertions(+), 18 deletions(-) diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index 7f305ebb610..94350a9b0b3 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "html" "io" "log/slog" "net/http" @@ -276,13 +277,8 @@ func parseMarkdownReferenceDefinitions(markdown string) map[string]markdownRefer } func parseMarkdownReferenceDefinitionAt(markdown string, start int) (label, destination string, end int, ok bool) { - i := start - indent := 0 - for i < len(markdown) && markdown[i] == ' ' && indent < 4 { - i++ - indent++ - } - if indent > 3 || i >= len(markdown) || markdown[i] != '[' { + i, ok := skipMarkdownReferenceDefinitionLinePrefix(markdown, start) + if !ok || i >= len(markdown) || markdown[i] != '[' { return "", "", 0, false } labelEnd, ok := findMarkdownLabelEnd(markdown, i) @@ -313,18 +309,94 @@ func skipMarkdownReferenceDefinitionSpaces(markdown string, i int) (int, bool) { } if i < len(markdown) && (markdown[i] == '\n' || markdown[i] == '\r') { i = consumeMarkdownLineEnding(markdown, i) - indent := 0 - for i < len(markdown) && markdown[i] == ' ' && indent < 4 { - i++ - indent++ - } - if indent > 3 || i >= len(markdown) || markdown[i] == '\n' || markdown[i] == '\r' { + var ok bool + i, ok = skipMarkdownReferenceDefinitionLinePrefix(markdown, i) + if !ok || i >= len(markdown) || markdown[i] == '\n' || markdown[i] == '\r' { return 0, false } } return i, true } +func skipMarkdownReferenceDefinitionLinePrefix(markdown string, i int) (int, bool) { + for { + j, ok := skipUpToThreeMarkdownSpaces(markdown, i) + if !ok || j >= len(markdown) || markdown[j] != '>' { + break + } + j++ + if j < len(markdown) && (markdown[j] == ' ' || markdown[j] == '\t') { + j++ + } + i = j + } + + j, ok := skipUpToThreeMarkdownSpaces(markdown, i) + if !ok { + return 0, false + } + if after, ok := skipMarkdownListMarker(markdown, j); ok { + i = after + for { + j, ok := skipUpToThreeMarkdownSpaces(markdown, i) + if !ok || j >= len(markdown) || markdown[j] != '>' { + break + } + j++ + if j < len(markdown) && (markdown[j] == ' ' || markdown[j] == '\t') { + j++ + } + i = j + } + } + return skipUpToThreeMarkdownSpaces(markdown, i) +} + +func skipUpToThreeMarkdownSpaces(markdown string, i int) (int, bool) { + spaces := 0 + for i+spaces < len(markdown) && markdown[i+spaces] == ' ' { + spaces++ + } + if spaces > 3 { + return 0, false + } + return i + spaces, true +} + +func skipMarkdownListMarker(markdown string, i int) (int, bool) { + if i >= len(markdown) { + return 0, false + } + if markdown[i] == '-' || markdown[i] == '+' || markdown[i] == '*' { + if i+1 < len(markdown) && (markdown[i+1] == ' ' || markdown[i+1] == '\t') { + return skipMarkdownListMarkerPadding(markdown, i+2), true + } + return 0, false + } + if markdown[i] < '0' || markdown[i] > '9' { + return 0, false + } + j := i + for j < len(markdown) && markdown[j] >= '0' && markdown[j] <= '9' && j-i < 9 { + j++ + } + if j == i || j >= len(markdown) || (markdown[j] != '.' && markdown[j] != ')') { + return 0, false + } + j++ + if j < len(markdown) && (markdown[j] == ' ' || markdown[j] == '\t') { + return skipMarkdownListMarkerPadding(markdown, j+1), true + } + return 0, false +} + +func skipMarkdownListMarkerPadding(markdown string, i int) int { + for i < len(markdown) && (markdown[i] == ' ' || markdown[i] == '\t') { + i++ + } + return i +} + func parseMarkdownReferenceDefinitionDestination(markdown string, i int) (string, int, bool) { if markdown[i] == '<' { start := i + 1 @@ -347,6 +419,9 @@ func parseMarkdownReferenceDefinitionDestination(markdown string, i int) (string depth := 0 for i < len(markdown) { if markdown[i] == '\\' { + if i+1 >= len(markdown) { + return "", 0, false + } i += 2 continue } @@ -495,6 +570,11 @@ func isMarkdownSpace(c byte) bool { } func isLocalMarkdownImageDestination(destination string) bool { + return isLocalMarkdownImageDestinationRaw(destination) || + isLocalMarkdownImageDestinationRaw(decodeMarkdownDestinationForClassification(destination)) +} + +func isLocalMarkdownImageDestinationRaw(destination string) bool { lower := strings.ToLower(destination) if strings.HasPrefix(lower, "file:") || strings.HasPrefix(destination, "/") || strings.HasPrefix(destination, `\\`) { @@ -506,6 +586,24 @@ func isLocalMarkdownImageDestination(destination string) bool { destination[1] == ':' && (destination[2] == '/' || destination[2] == '\\') } +func decodeMarkdownDestinationForClassification(destination string) string { + var decoded strings.Builder + for i := 0; i < len(destination); i++ { + if destination[i] == '\\' && i+1 < len(destination) && isASCIIpunct(destination[i+1]) { + decoded.WriteByte(destination[i+1]) + i++ + continue + } + decoded.WriteByte(destination[i]) + } + return html.UnescapeString(decoded.String()) +} + +func isASCIIpunct(c byte) bool { + return (c >= '!' && c <= '/') || (c >= ':' && c <= '@') || + (c >= '[' && c <= '`') || (c >= '{' && c <= '~') +} + // HTTPClientConfig configures the production Lark HTTP APIClient. type HTTPClientConfig struct { // BaseURL is an optional deployment-wide override for the Lark diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index 1876e38c622..44e2bfb4701 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -638,6 +638,14 @@ func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { {"reference_multiline_file", "![scan][local]\n[local]:\nfile:///tmp/x.png", "[scan omitted]\n"}, {"reference_multiline_remote", "![logo][remote]\n[remote]:\nhttps://example.com/image.png", "![logo][remote]\n[remote]:\nhttps://example.com/image.png"}, {"reference_multiline_title", "![scan][local]\n[local]:\n/tmp/x.png\n \"preview\"\nnext", "[scan omitted]\nnext"}, + {"destination_escaped_unix_separator", `![scan](\/tmp/x.png)`, `[scan omitted]`}, + {"destination_decimal_entity_unix_separator", `![scan](/tmp/x.png)`, `[scan omitted]`}, + {"destination_hex_entity_windows_separator", `![scan](C:\Users\agent\x.png)`, `[scan omitted]`}, + {"reference_escaped_separator", "![scan][local]\n[local]: \\/tmp/x.png", "[scan omitted]\n"}, + {"reference_blockquote_definition", "![scan][local]\n\n> [local]: file:private.png", "[scan omitted]\n\n"}, + {"reference_list_definition", "![scan][local]\n\n- [local]: /tmp/x.png", "[scan omitted]\n\n"}, + {"malformed_trailing_backslash_definition", "![scan][x]\n[x]: \\", "![scan][x]\n[x]: \\"}, + {"malformed_truncated_angle_definition", "![scan][x]\n[x]: [local]: file:private.png", }, }) From 2bb58f1f7044173010e5d829cbd7cf2577459345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Fri, 17 Jul 2026 07:50:40 +0800 Subject: [PATCH 07/13] fix(lark): ignore code block image references Co-Authored-By: Claude Co-authored-by: multica-agent --- server/go.mod | 2 +- .../internal/integrations/lark/http_client.go | 75 ++++++++++++++++++- .../integrations/lark/http_client_test.go | 4 + .../integrations/lark/outbound_test.go | 8 +- 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/server/go.mod b/server/go.mod index 9f2e8c9192d..8a02bd77852 100644 --- a/server/go.mod +++ b/server/go.mod @@ -31,6 +31,7 @@ require ( github.com/spf13/pflag v1.0.9 golang.org/x/sync v0.20.0 golang.org/x/sys v0.35.0 + golang.org/x/text v0.35.0 google.golang.org/protobuf v1.36.8 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 @@ -71,5 +72,4 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/net v0.43.0 // indirect - golang.org/x/text v0.35.0 // indirect ) diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index 94350a9b0b3..1afb6b43f64 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -15,6 +15,8 @@ import ( "strings" "sync" "time" + + "golang.org/x/text/cases" ) // Real Lark/飞书 Open Platform HTTP APIClient. @@ -261,7 +263,12 @@ func parseInlineMarkdownImageDestination(markdown string, openParen int) (end in func parseMarkdownReferenceDefinitions(markdown string) map[string]markdownReferenceDefinition { definitions := map[string]markdownReferenceDefinition{} + var state markdownReferenceDefinitionScanState for start := 0; start < len(markdown); { + if state.skipLine(markdown, start) { + start = nextMarkdownLineStart(markdown, start) + continue + } label, destination, end, ok := parseMarkdownReferenceDefinitionAt(markdown, start) if ok { key := normalizeMarkdownReferenceLabel(label) @@ -276,6 +283,64 @@ func parseMarkdownReferenceDefinitions(markdown string) map[string]markdownRefer return definitions } +type markdownReferenceDefinitionScanState struct { + inFence bool + fence byte + fenceLen int +} + +func (s *markdownReferenceDefinitionScanState) skipLine(markdown string, start int) bool { + if s.inFence { + if fence, fenceLen, ok := parseMarkdownFenceLine(markdown, start); ok && fence == s.fence && fenceLen >= s.fenceLen { + s.inFence = false + } + return true + } + if fence, fenceLen, ok := parseMarkdownFenceLine(markdown, start); ok { + s.inFence = true + s.fence = fence + s.fenceLen = fenceLen + return true + } + return isIndentedMarkdownCodeLine(markdown, start) +} + +func parseMarkdownFenceLine(markdown string, start int) (byte, int, bool) { + lineEnd := markdownLineEnd(markdown, start) + i, ok := skipUpToThreeMarkdownSpaces(markdown, start) + if !ok || i >= lineEnd || (markdown[i] != '`' && markdown[i] != '~') { + return 0, 0, false + } + fence := markdown[i] + j := i + for j < lineEnd && markdown[j] == fence { + j++ + } + if j-i < 3 { + return 0, 0, false + } + if fence == '`' { + for k := j; k < lineEnd; k++ { + if markdown[k] == '`' { + return 0, 0, false + } + } + } + return fence, j - i, true +} + +func isIndentedMarkdownCodeLine(markdown string, start int) bool { + lineEnd := markdownLineEnd(markdown, start) + if start >= lineEnd { + return false + } + spaces := 0 + for start+spaces < lineEnd && markdown[start+spaces] == ' ' { + spaces++ + } + return spaces >= 4 +} + func parseMarkdownReferenceDefinitionAt(markdown string, start int) (label, destination string, end int, ok bool) { i, ok := skipMarkdownReferenceDefinitionLinePrefix(markdown, start) if !ok || i >= len(markdown) || markdown[i] != '[' { @@ -522,14 +587,20 @@ func parseMarkdownReferenceDefinitionTitle(markdown string, i, lineEnd int) (int func removeMarkdownReferenceDefinitions(markdown string, redactedDefinitions map[string]struct{}) string { var sanitized strings.Builder + var state markdownReferenceDefinitionScanState for start := 0; start < len(markdown); { + next := nextMarkdownLineStart(markdown, start) + if state.skipLine(markdown, start) { + sanitized.WriteString(markdown[start:next]) + start = next + continue + } label, destination, end, ok := parseMarkdownReferenceDefinitionAt(markdown, start) _, redact := redactedDefinitions[normalizeMarkdownReferenceLabel(label)] if ok && redact && isLocalMarkdownImageDestination(destination) { start = end continue } - next := nextMarkdownLineStart(markdown, start) sanitized.WriteString(markdown[start:next]) start = next } @@ -562,7 +633,7 @@ func nextMarkdownLineStart(markdown string, start int) int { } func normalizeMarkdownReferenceLabel(label string) string { - return strings.ToLower(strings.Join(strings.Fields(label), " ")) + return cases.Fold().String(strings.Join(strings.Fields(label), " ")) } func isMarkdownSpace(c byte) bool { diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index 44e2bfb4701..70d7feed924 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -644,6 +644,10 @@ func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { {"reference_escaped_separator", "![scan][local]\n[local]: \\/tmp/x.png", "[scan omitted]\n"}, {"reference_blockquote_definition", "![scan][local]\n\n> [local]: file:private.png", "[scan omitted]\n\n"}, {"reference_list_definition", "![scan][local]\n\n- [local]: /tmp/x.png", "[scan omitted]\n\n"}, + {"reference_unicode_case_fold", "![scan][K]\n\n[K]: /tmp/x.png", "[scan omitted]\n\n"}, + {"reference_fenced_code_remote_then_local", "![scan][local]\n\n```\n[local]: https://example.com/image.png\n```\n\n[local]: /tmp/x.png", "[scan omitted]\n\n```\n[local]: https://example.com/image.png\n```\n\n"}, + {"reference_indented_code_remote_then_local", "![scan][local]\n\n [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n [local]: https://example.com/image.png\n\n"}, + {"reference_fenced_code_local_only", "![logo][local]\n\n```\n[local]: /tmp/x.png\n```", "![logo][local]\n\n```\n[local]: /tmp/x.png\n```"}, {"malformed_trailing_backslash_definition", "![scan][x]\n[x]: \\", "![scan][x]\n[x]: \\"}, {"malformed_truncated_angle_definition", "![scan][x]\n[x]: [local]: file:private.png", + Content: "**Scan:** ![result][local]\n\n```\n[local]: https://example.com/image.png\n```\n\n[local]: /tmp/screenshot.png", }, }) @@ -316,7 +316,7 @@ func TestPatcherRedactsReferenceLocalImageBeforeCardRouting(t *testing.T) { if len(api.mdCardSent) != 1 { t.Fatalf("expected one SendMarkdownCard call; got %d", len(api.mdCardSent)) } - if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]\n\n" { + if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]\n\n```\n[local]: https://example.com/image.png\n```\n\n" { t.Errorf("local image must be redacted before card routing; got %q", got) } if len(api.textSent) != 0 { From 080a65aafd9387cd7cee41cf715ff84a5f8e902d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Fri, 17 Jul 2026 08:11:07 +0800 Subject: [PATCH 08/13] fix(lark): validate markdown fence closers Co-Authored-By: Claude Co-authored-by: multica-agent --- .../internal/integrations/lark/http_client.go | 30 ++++++++++++++++--- .../integrations/lark/http_client_test.go | 3 ++ .../integrations/lark/outbound_test.go | 4 +-- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index 1afb6b43f64..7b7568b558a 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -291,12 +291,12 @@ type markdownReferenceDefinitionScanState struct { func (s *markdownReferenceDefinitionScanState) skipLine(markdown string, start int) bool { if s.inFence { - if fence, fenceLen, ok := parseMarkdownFenceLine(markdown, start); ok && fence == s.fence && fenceLen >= s.fenceLen { + if parseMarkdownFenceCloseLine(markdown, start, s.fence, s.fenceLen) { s.inFence = false } return true } - if fence, fenceLen, ok := parseMarkdownFenceLine(markdown, start); ok { + if fence, fenceLen, ok := parseMarkdownFenceOpenLine(markdown, start); ok { s.inFence = true s.fence = fence s.fenceLen = fenceLen @@ -305,9 +305,9 @@ func (s *markdownReferenceDefinitionScanState) skipLine(markdown string, start i return isIndentedMarkdownCodeLine(markdown, start) } -func parseMarkdownFenceLine(markdown string, start int) (byte, int, bool) { +func parseMarkdownFenceOpenLine(markdown string, start int) (byte, int, bool) { lineEnd := markdownLineEnd(markdown, start) - i, ok := skipUpToThreeMarkdownSpaces(markdown, start) + i, ok := skipMarkdownReferenceDefinitionLinePrefix(markdown, start) if !ok || i >= lineEnd || (markdown[i] != '`' && markdown[i] != '~') { return 0, 0, false } @@ -329,6 +329,28 @@ func parseMarkdownFenceLine(markdown string, start int) (byte, int, bool) { return fence, j - i, true } +func parseMarkdownFenceCloseLine(markdown string, start int, fence byte, fenceLen int) bool { + lineEnd := markdownLineEnd(markdown, start) + i, ok := skipMarkdownReferenceDefinitionLinePrefix(markdown, start) + if !ok || i >= lineEnd || markdown[i] != fence { + return false + } + j := i + for j < lineEnd && markdown[j] == fence { + j++ + } + if j-i < fenceLen { + return false + } + for j < lineEnd { + if markdown[j] != ' ' && markdown[j] != ' ' { + return false + } + j++ + } + return true +} + func isIndentedMarkdownCodeLine(markdown string, start int) bool { lineEnd := markdownLineEnd(markdown, start) if start >= lineEnd { diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index 70d7feed924..ab00441ba96 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -646,6 +646,9 @@ func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { {"reference_list_definition", "![scan][local]\n\n- [local]: /tmp/x.png", "[scan omitted]\n\n"}, {"reference_unicode_case_fold", "![scan][K]\n\n[K]: /tmp/x.png", "[scan omitted]\n\n"}, {"reference_fenced_code_remote_then_local", "![scan][local]\n\n```\n[local]: https://example.com/image.png\n```\n\n[local]: /tmp/x.png", "[scan omitted]\n\n```\n[local]: https://example.com/image.png\n```\n\n"}, + {"reference_fenced_backtick_suffix_not_close", "![scan][local]\n\n```\n[local]: https://example.com/image.png\n``` still code\n[local]: https://example.com/also-code.png\n```\n\n[local]: /tmp/x.png", "[scan omitted]\n\n```\n[local]: https://example.com/image.png\n``` still code\n[local]: https://example.com/also-code.png\n```\n\n"}, + {"reference_fenced_tilde_suffix_not_close", "![scan][local]\n\n~~~\n[local]: https://example.com/image.png\n~~~ still code\n[local]: https://example.com/also-code.png\n~~~\n\n[local]: /tmp/x.png", "[scan omitted]\n\n~~~\n[local]: https://example.com/image.png\n~~~ still code\n[local]: https://example.com/also-code.png\n~~~\n\n"}, + {"reference_blockquote_fence_suffix_not_close", "![scan][local]\n\n> ```\n> [local]: https://example.com/image.png\n> ``` still code\n> [local]: https://example.com/also-code.png\n> ```\n\n[local]: /tmp/x.png", "[scan omitted]\n\n> ```\n> [local]: https://example.com/image.png\n> ``` still code\n> [local]: https://example.com/also-code.png\n> ```\n\n"}, {"reference_indented_code_remote_then_local", "![scan][local]\n\n [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n [local]: https://example.com/image.png\n\n"}, {"reference_fenced_code_local_only", "![logo][local]\n\n```\n[local]: /tmp/x.png\n```", "![logo][local]\n\n```\n[local]: /tmp/x.png\n```"}, {"malformed_trailing_backslash_definition", "![scan][x]\n[x]: \\", "![scan][x]\n[x]: \\"}, diff --git a/server/internal/integrations/lark/outbound_test.go b/server/internal/integrations/lark/outbound_test.go index 3dbaa52dac1..2391408ee43 100644 --- a/server/internal/integrations/lark/outbound_test.go +++ b/server/internal/integrations/lark/outbound_test.go @@ -307,7 +307,7 @@ func TestPatcherRedactsReferenceLocalImageBeforeCardRouting(t *testing.T) { Payload: protocol.ChatDonePayload{ TaskID: uuidString(taskID), ChatSessionID: uuidString(q.binding.ChatSessionID), - Content: "**Scan:** ![result][local]\n\n```\n[local]: https://example.com/image.png\n```\n\n[local]: /tmp/screenshot.png", + Content: "**Scan:** ![result][local]\n\n```\n[local]: https://example.com/image.png\n``` still code\n[local]: https://example.com/also-code.png\n```\n\n[local]: /tmp/screenshot.png", }, }) @@ -316,7 +316,7 @@ func TestPatcherRedactsReferenceLocalImageBeforeCardRouting(t *testing.T) { if len(api.mdCardSent) != 1 { t.Fatalf("expected one SendMarkdownCard call; got %d", len(api.mdCardSent)) } - if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]\n\n```\n[local]: https://example.com/image.png\n```\n\n" { + if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]\n\n```\n[local]: https://example.com/image.png\n``` still code\n[local]: https://example.com/also-code.png\n```\n\n" { t.Errorf("local image must be redacted before card routing; got %q", got) } if len(api.textSent) != 0 { From ff7de39d456053a6c133a75608645121a134c974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Fri, 17 Jul 2026 08:23:54 +0800 Subject: [PATCH 09/13] fix(lark): close fences with containers Co-Authored-By: Claude Co-authored-by: multica-agent --- .../internal/integrations/lark/http_client.go | 72 ++++++++++++++++--- .../integrations/lark/http_client_test.go | 4 ++ .../integrations/lark/outbound_test.go | 4 +- 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index 7b7568b558a..ee2e60495c6 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -284,32 +284,43 @@ func parseMarkdownReferenceDefinitions(markdown string) map[string]markdownRefer } type markdownReferenceDefinitionScanState struct { - inFence bool - fence byte - fenceLen int + inFence bool + fence byte + fenceLen int + container markdownLineContainer +} + +type markdownLineContainer struct { + blockquote int + list bool } func (s *markdownReferenceDefinitionScanState) skipLine(markdown string, start int) bool { if s.inFence { - if parseMarkdownFenceCloseLine(markdown, start, s.fence, s.fenceLen) { + if !lineBelongsToMarkdownContainer(markdown, start, s.container) { s.inFence = false + } else { + if parseMarkdownFenceCloseLine(markdown, start, s.fence, s.fenceLen) { + s.inFence = false + } + return true } - return true } - if fence, fenceLen, ok := parseMarkdownFenceOpenLine(markdown, start); ok { + if fence, fenceLen, container, ok := parseMarkdownFenceOpenLine(markdown, start); ok { s.inFence = true s.fence = fence s.fenceLen = fenceLen + s.container = container return true } return isIndentedMarkdownCodeLine(markdown, start) } -func parseMarkdownFenceOpenLine(markdown string, start int) (byte, int, bool) { +func parseMarkdownFenceOpenLine(markdown string, start int) (byte, int, markdownLineContainer, bool) { lineEnd := markdownLineEnd(markdown, start) i, ok := skipMarkdownReferenceDefinitionLinePrefix(markdown, start) if !ok || i >= lineEnd || (markdown[i] != '`' && markdown[i] != '~') { - return 0, 0, false + return 0, 0, markdownLineContainer{}, false } fence := markdown[i] j := i @@ -317,16 +328,55 @@ func parseMarkdownFenceOpenLine(markdown string, start int) (byte, int, bool) { j++ } if j-i < 3 { - return 0, 0, false + return 0, 0, markdownLineContainer{}, false } if fence == '`' { for k := j; k < lineEnd; k++ { if markdown[k] == '`' { - return 0, 0, false + return 0, 0, markdownLineContainer{}, false } } } - return fence, j - i, true + return fence, j - i, markdownLineContainerForLine(markdown, start), true +} + +func markdownLineContainerForLine(markdown string, start int) markdownLineContainer { + var container markdownLineContainer + i := start + for { + j, ok := skipUpToThreeMarkdownSpaces(markdown, i) + if !ok || j >= len(markdown) || markdown[j] != '>' { + break + } + container.blockquote++ + j++ + if j < len(markdown) && (markdown[j] == ' ' || markdown[j] == ' ') { + j++ + } + i = j + } + j, ok := skipUpToThreeMarkdownSpaces(markdown, i) + if ok { + _, container.list = skipMarkdownListMarker(markdown, j) + } + return container +} + +func lineBelongsToMarkdownContainer(markdown string, start int, container markdownLineContainer) bool { + current := markdownLineContainerForLine(markdown, start) + if current.blockquote < container.blockquote { + return false + } + return !container.list || current.list || markdownLineIndent(markdown, start) >= 2 +} + +func markdownLineIndent(markdown string, start int) int { + lineEnd := markdownLineEnd(markdown, start) + indent := 0 + for start+indent < lineEnd && markdown[start+indent] == ' ' { + indent++ + } + return indent } func parseMarkdownFenceCloseLine(markdown string, start int, fence byte, fenceLen int) bool { diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index ab00441ba96..9910cc5a131 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -649,6 +649,10 @@ func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { {"reference_fenced_backtick_suffix_not_close", "![scan][local]\n\n```\n[local]: https://example.com/image.png\n``` still code\n[local]: https://example.com/also-code.png\n```\n\n[local]: /tmp/x.png", "[scan omitted]\n\n```\n[local]: https://example.com/image.png\n``` still code\n[local]: https://example.com/also-code.png\n```\n\n"}, {"reference_fenced_tilde_suffix_not_close", "![scan][local]\n\n~~~\n[local]: https://example.com/image.png\n~~~ still code\n[local]: https://example.com/also-code.png\n~~~\n\n[local]: /tmp/x.png", "[scan omitted]\n\n~~~\n[local]: https://example.com/image.png\n~~~ still code\n[local]: https://example.com/also-code.png\n~~~\n\n"}, {"reference_blockquote_fence_suffix_not_close", "![scan][local]\n\n> ```\n> [local]: https://example.com/image.png\n> ``` still code\n> [local]: https://example.com/also-code.png\n> ```\n\n[local]: /tmp/x.png", "[scan omitted]\n\n> ```\n> [local]: https://example.com/image.png\n> ``` still code\n> [local]: https://example.com/also-code.png\n> ```\n\n"}, + {"reference_blockquote_unclosed_fence_then_local", "![scan][local]\n\n> ```\n> [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n> ```\n> [local]: https://example.com/image.png\n\n"}, + {"reference_blockquote_unclosed_fence_then_remote", "![logo][remote]\n\n> ```\n> [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png", "![logo][remote]\n\n> ```\n> [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png"}, + {"reference_list_unclosed_fence_then_local", "![scan][local]\n\n- ```\n [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n- ```\n [local]: https://example.com/image.png\n\n"}, + {"reference_list_unclosed_fence_then_remote", "![logo][remote]\n\n- ```\n [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png", "![logo][remote]\n\n- ```\n [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png"}, {"reference_indented_code_remote_then_local", "![scan][local]\n\n [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n [local]: https://example.com/image.png\n\n"}, {"reference_fenced_code_local_only", "![logo][local]\n\n```\n[local]: /tmp/x.png\n```", "![logo][local]\n\n```\n[local]: /tmp/x.png\n```"}, {"malformed_trailing_backslash_definition", "![scan][x]\n[x]: \\", "![scan][x]\n[x]: \\"}, diff --git a/server/internal/integrations/lark/outbound_test.go b/server/internal/integrations/lark/outbound_test.go index 2391408ee43..282d6a01a37 100644 --- a/server/internal/integrations/lark/outbound_test.go +++ b/server/internal/integrations/lark/outbound_test.go @@ -307,7 +307,7 @@ func TestPatcherRedactsReferenceLocalImageBeforeCardRouting(t *testing.T) { Payload: protocol.ChatDonePayload{ TaskID: uuidString(taskID), ChatSessionID: uuidString(q.binding.ChatSessionID), - Content: "**Scan:** ![result][local]\n\n```\n[local]: https://example.com/image.png\n``` still code\n[local]: https://example.com/also-code.png\n```\n\n[local]: /tmp/screenshot.png", + Content: "**Scan:** ![result][local]\n\n- ~~~\n [local]: https://example.com/image.png\n\n[local]: /tmp/screenshot.png", }, }) @@ -316,7 +316,7 @@ func TestPatcherRedactsReferenceLocalImageBeforeCardRouting(t *testing.T) { if len(api.mdCardSent) != 1 { t.Fatalf("expected one SendMarkdownCard call; got %d", len(api.mdCardSent)) } - if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]\n\n```\n[local]: https://example.com/image.png\n``` still code\n[local]: https://example.com/also-code.png\n```\n\n" { + if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]\n\n- ~~~\n [local]: https://example.com/image.png\n\n" { t.Errorf("local image must be redacted before card routing; got %q", got) } if len(api.textSent) != 0 { From 4b503fff08f7c3dccce9aa3913af8f8638ccaf94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Fri, 17 Jul 2026 08:37:59 +0800 Subject: [PATCH 10/13] fix(lark): keep list fences across blank lines Co-Authored-By: Claude Co-authored-by: multica-agent --- server/internal/integrations/lark/http_client.go | 13 ++++++++++++- .../internal/integrations/lark/http_client_test.go | 2 ++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index ee2e60495c6..f29a128d30d 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -367,7 +367,18 @@ func lineBelongsToMarkdownContainer(markdown string, start int, container markdo if current.blockquote < container.blockquote { return false } - return !container.list || current.list || markdownLineIndent(markdown, start) >= 2 + return !container.list || current.list || isBlankMarkdownLine(markdown, start) || markdownLineIndent(markdown, start) >= 2 +} + +func isBlankMarkdownLine(markdown string, start int) bool { + lineEnd := markdownLineEnd(markdown, start) + for start < lineEnd { + if markdown[start] != ' ' && markdown[start] != '\t' { + return false + } + start++ + } + return true } func markdownLineIndent(markdown string, start int) int { diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index 9910cc5a131..00dbc5c1559 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -652,6 +652,8 @@ func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { {"reference_blockquote_unclosed_fence_then_local", "![scan][local]\n\n> ```\n> [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n> ```\n> [local]: https://example.com/image.png\n\n"}, {"reference_blockquote_unclosed_fence_then_remote", "![logo][remote]\n\n> ```\n> [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png", "![logo][remote]\n\n> ```\n> [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png"}, {"reference_list_unclosed_fence_then_local", "![scan][local]\n\n- ```\n [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n- ```\n [local]: https://example.com/image.png\n\n"}, + {"reference_list_unclosed_fence_blank_then_code_definition", "![scan][local]\n\n- ```\n [local]: https://example.com/image.png\n\n [local]: https://example.com/also-code.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n- ```\n [local]: https://example.com/image.png\n\n [local]: https://example.com/also-code.png\n\n"}, + {"reference_list_unclosed_fence_blank_then_code_definition", "![scan][local]\n\n- ```\n [local]: https://example.com/image.png\n\n [local]: https://example.com/also-code.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n- ```\n [local]: https://example.com/image.png\n\n [local]: https://example.com/also-code.png\n\n"}, {"reference_list_unclosed_fence_then_remote", "![logo][remote]\n\n- ```\n [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png", "![logo][remote]\n\n- ```\n [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png"}, {"reference_indented_code_remote_then_local", "![scan][local]\n\n [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n [local]: https://example.com/image.png\n\n"}, {"reference_fenced_code_local_only", "![logo][local]\n\n```\n[local]: /tmp/x.png\n```", "![logo][local]\n\n```\n[local]: /tmp/x.png\n```"}, From bcdfdea99795c12fcb717e29eacf213d54bc2f33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Fri, 17 Jul 2026 08:38:46 +0800 Subject: [PATCH 11/13] test(lark): remove duplicate list fence case Co-Authored-By: Claude Co-authored-by: multica-agent --- server/internal/integrations/lark/http_client_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index 00dbc5c1559..88b36ac5ff9 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -653,7 +653,6 @@ func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { {"reference_blockquote_unclosed_fence_then_remote", "![logo][remote]\n\n> ```\n> [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png", "![logo][remote]\n\n> ```\n> [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png"}, {"reference_list_unclosed_fence_then_local", "![scan][local]\n\n- ```\n [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n- ```\n [local]: https://example.com/image.png\n\n"}, {"reference_list_unclosed_fence_blank_then_code_definition", "![scan][local]\n\n- ```\n [local]: https://example.com/image.png\n\n [local]: https://example.com/also-code.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n- ```\n [local]: https://example.com/image.png\n\n [local]: https://example.com/also-code.png\n\n"}, - {"reference_list_unclosed_fence_blank_then_code_definition", "![scan][local]\n\n- ```\n [local]: https://example.com/image.png\n\n [local]: https://example.com/also-code.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n- ```\n [local]: https://example.com/image.png\n\n [local]: https://example.com/also-code.png\n\n"}, {"reference_list_unclosed_fence_then_remote", "![logo][remote]\n\n- ```\n [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png", "![logo][remote]\n\n- ```\n [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png"}, {"reference_indented_code_remote_then_local", "![scan][local]\n\n [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n [local]: https://example.com/image.png\n\n"}, {"reference_fenced_code_local_only", "![logo][local]\n\n```\n[local]: /tmp/x.png\n```", "![logo][local]\n\n```\n[local]: /tmp/x.png\n```"}, From df7b3f967def748bd9c0b0879be22baf1d094f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Fri, 17 Jul 2026 08:56:01 +0800 Subject: [PATCH 12/13] fix(lark): distinguish sibling list fences Co-Authored-By: Claude Co-authored-by: multica-agent --- server/internal/integrations/lark/http_client.go | 13 +++++++++---- .../internal/integrations/lark/http_client_test.go | 2 ++ server/internal/integrations/lark/outbound_test.go | 4 ++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index f29a128d30d..249828284d5 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -291,8 +291,8 @@ type markdownReferenceDefinitionScanState struct { } type markdownLineContainer struct { - blockquote int - list bool + blockquote int + listContentIndent int } func (s *markdownReferenceDefinitionScanState) skipLine(markdown string, start int) bool { @@ -357,7 +357,9 @@ func markdownLineContainerForLine(markdown string, start int) markdownLineContai } j, ok := skipUpToThreeMarkdownSpaces(markdown, i) if ok { - _, container.list = skipMarkdownListMarker(markdown, j) + if after, ok := skipMarkdownListMarker(markdown, j); ok { + container.listContentIndent = after - start + } } return container } @@ -367,7 +369,10 @@ func lineBelongsToMarkdownContainer(markdown string, start int, container markdo if current.blockquote < container.blockquote { return false } - return !container.list || current.list || isBlankMarkdownLine(markdown, start) || markdownLineIndent(markdown, start) >= 2 + if container.listContentIndent == 0 { + return true + } + return isBlankMarkdownLine(markdown, start) || markdownLineIndent(markdown, start) >= container.listContentIndent } func isBlankMarkdownLine(markdown string, start int) bool { diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index 88b36ac5ff9..b1cf2bffdf2 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -653,6 +653,8 @@ func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { {"reference_blockquote_unclosed_fence_then_remote", "![logo][remote]\n\n> ```\n> [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png", "![logo][remote]\n\n> ```\n> [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png"}, {"reference_list_unclosed_fence_then_local", "![scan][local]\n\n- ```\n [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n- ```\n [local]: https://example.com/image.png\n\n"}, {"reference_list_unclosed_fence_blank_then_code_definition", "![scan][local]\n\n- ```\n [local]: https://example.com/image.png\n\n [local]: https://example.com/also-code.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n- ```\n [local]: https://example.com/image.png\n\n [local]: https://example.com/also-code.png\n\n"}, + {"reference_list_sibling_definition_after_unclosed_fence", "![scan][local]\n\n- ```\n [local]: https://example.com/image.png\n- [local]: /tmp/x.png", "[scan omitted]\n\n- ```\n [local]: https://example.com/image.png\n"}, + {"reference_list_sibling_remote_after_unclosed_fence", "![logo][remote]\n\n- ```\n [remote]: /tmp/x.png\n- [remote]: https://example.com/image.png", "![logo][remote]\n\n- ```\n [remote]: /tmp/x.png\n- [remote]: https://example.com/image.png"}, {"reference_list_unclosed_fence_then_remote", "![logo][remote]\n\n- ```\n [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png", "![logo][remote]\n\n- ```\n [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png"}, {"reference_indented_code_remote_then_local", "![scan][local]\n\n [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n [local]: https://example.com/image.png\n\n"}, {"reference_fenced_code_local_only", "![logo][local]\n\n```\n[local]: /tmp/x.png\n```", "![logo][local]\n\n```\n[local]: /tmp/x.png\n```"}, diff --git a/server/internal/integrations/lark/outbound_test.go b/server/internal/integrations/lark/outbound_test.go index 282d6a01a37..8997f5c161c 100644 --- a/server/internal/integrations/lark/outbound_test.go +++ b/server/internal/integrations/lark/outbound_test.go @@ -307,7 +307,7 @@ func TestPatcherRedactsReferenceLocalImageBeforeCardRouting(t *testing.T) { Payload: protocol.ChatDonePayload{ TaskID: uuidString(taskID), ChatSessionID: uuidString(q.binding.ChatSessionID), - Content: "**Scan:** ![result][local]\n\n- ~~~\n [local]: https://example.com/image.png\n\n[local]: /tmp/screenshot.png", + Content: "**Scan:** ![result][local]\n\n- ~~~\n [local]: https://example.com/image.png\n- [local]: /tmp/screenshot.png", }, }) @@ -316,7 +316,7 @@ func TestPatcherRedactsReferenceLocalImageBeforeCardRouting(t *testing.T) { if len(api.mdCardSent) != 1 { t.Fatalf("expected one SendMarkdownCard call; got %d", len(api.mdCardSent)) } - if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]\n\n- ~~~\n [local]: https://example.com/image.png\n\n" { + if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]\n\n- ~~~\n [local]: https://example.com/image.png\n" { t.Errorf("local image must be redacted before card routing; got %q", got) } if len(api.textSent) != 0 { From 4938cee4816343eb35b99fa95733a7e3206dba3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E4=BC=9F?= Date: Fri, 17 Jul 2026 09:07:46 +0800 Subject: [PATCH 13/13] fix(lark): normalize blockquote list fence indents Co-Authored-By: Claude Co-authored-by: multica-agent --- .../internal/integrations/lark/http_client.go | 25 +++++++++++++++++-- .../integrations/lark/http_client_test.go | 3 +++ .../integrations/lark/outbound_test.go | 4 +-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index 249828284d5..005fe9c1c12 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -358,7 +358,7 @@ func markdownLineContainerForLine(markdown string, start int) markdownLineContai j, ok := skipUpToThreeMarkdownSpaces(markdown, i) if ok { if after, ok := skipMarkdownListMarker(markdown, j); ok { - container.listContentIndent = after - start + container.listContentIndent = after - i } } return container @@ -372,7 +372,28 @@ func lineBelongsToMarkdownContainer(markdown string, start int, container markdo if container.listContentIndent == 0 { return true } - return isBlankMarkdownLine(markdown, start) || markdownLineIndent(markdown, start) >= container.listContentIndent + return isBlankMarkdownLine(markdown, start) || markdownLineIndentAfterBlockquotes(markdown, start, container.blockquote) >= container.listContentIndent +} + +func markdownLineIndentAfterBlockquotes(markdown string, start int, blockquotes int) int { + lineEnd := markdownLineEnd(markdown, start) + i := start + for n := 0; n < blockquotes; n++ { + j, ok := skipUpToThreeMarkdownSpaces(markdown, i) + if !ok || j >= lineEnd || markdown[j] != '>' { + return -1 + } + j++ + if j < lineEnd && (markdown[j] == ' ' || markdown[j] == ' ') { + j++ + } + i = j + } + indent := 0 + for i+indent < lineEnd && markdown[i+indent] == ' ' { + indent++ + } + return indent } func isBlankMarkdownLine(markdown string, start int) bool { diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go index b1cf2bffdf2..9e6bf5d3ef8 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -649,6 +649,9 @@ func TestSanitizeMarkdownForLarkCard_LocalPathFormats(t *testing.T) { {"reference_fenced_backtick_suffix_not_close", "![scan][local]\n\n```\n[local]: https://example.com/image.png\n``` still code\n[local]: https://example.com/also-code.png\n```\n\n[local]: /tmp/x.png", "[scan omitted]\n\n```\n[local]: https://example.com/image.png\n``` still code\n[local]: https://example.com/also-code.png\n```\n\n"}, {"reference_fenced_tilde_suffix_not_close", "![scan][local]\n\n~~~\n[local]: https://example.com/image.png\n~~~ still code\n[local]: https://example.com/also-code.png\n~~~\n\n[local]: /tmp/x.png", "[scan omitted]\n\n~~~\n[local]: https://example.com/image.png\n~~~ still code\n[local]: https://example.com/also-code.png\n~~~\n\n"}, {"reference_blockquote_fence_suffix_not_close", "![scan][local]\n\n> ```\n> [local]: https://example.com/image.png\n> ``` still code\n> [local]: https://example.com/also-code.png\n> ```\n\n[local]: /tmp/x.png", "[scan omitted]\n\n> ```\n> [local]: https://example.com/image.png\n> ``` still code\n> [local]: https://example.com/also-code.png\n> ```\n\n"}, + {"reference_blockquote_list_continuation_then_local", "![scan][local]\n\n> - ```\n> [local]: https://example.com/image.png\n> [local]: https://example.com/also-code.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n> - ```\n> [local]: https://example.com/image.png\n> [local]: https://example.com/also-code.png\n\n"}, + {"reference_blockquote_list_sibling_then_local", "![scan][local]\n\n> - ```\n> [local]: https://example.com/image.png\n> - [local]: /tmp/x.png", "[scan omitted]\n\n> - ```\n> [local]: https://example.com/image.png\n"}, + {"reference_blockquote_list_sibling_then_remote", "![logo][remote]\n\n> - ```\n> [remote]: /tmp/x.png\n> - [remote]: https://example.com/image.png", "![logo][remote]\n\n> - ```\n> [remote]: /tmp/x.png\n> - [remote]: https://example.com/image.png"}, {"reference_blockquote_unclosed_fence_then_local", "![scan][local]\n\n> ```\n> [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n> ```\n> [local]: https://example.com/image.png\n\n"}, {"reference_blockquote_unclosed_fence_then_remote", "![logo][remote]\n\n> ```\n> [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png", "![logo][remote]\n\n> ```\n> [remote]: /tmp/x.png\n\n[remote]: https://example.com/image.png"}, {"reference_list_unclosed_fence_then_local", "![scan][local]\n\n- ```\n [local]: https://example.com/image.png\n\n[local]: /tmp/x.png", "[scan omitted]\n\n- ```\n [local]: https://example.com/image.png\n\n"}, diff --git a/server/internal/integrations/lark/outbound_test.go b/server/internal/integrations/lark/outbound_test.go index 8997f5c161c..ea2edf8783e 100644 --- a/server/internal/integrations/lark/outbound_test.go +++ b/server/internal/integrations/lark/outbound_test.go @@ -307,7 +307,7 @@ func TestPatcherRedactsReferenceLocalImageBeforeCardRouting(t *testing.T) { Payload: protocol.ChatDonePayload{ TaskID: uuidString(taskID), ChatSessionID: uuidString(q.binding.ChatSessionID), - Content: "**Scan:** ![result][local]\n\n- ~~~\n [local]: https://example.com/image.png\n- [local]: /tmp/screenshot.png", + Content: "**Scan:** ![result][local]\n\n> - ~~~\n> [local]: https://example.com/image.png\n> - [local]: /tmp/screenshot.png", }, }) @@ -316,7 +316,7 @@ func TestPatcherRedactsReferenceLocalImageBeforeCardRouting(t *testing.T) { if len(api.mdCardSent) != 1 { t.Fatalf("expected one SendMarkdownCard call; got %d", len(api.mdCardSent)) } - if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]\n\n- ~~~\n [local]: https://example.com/image.png\n" { + if got := api.mdCardSent[0].Markdown; got != "**Scan:** [result omitted]\n\n> - ~~~\n> [local]: https://example.com/image.png\n" { t.Errorf("local image must be redacted before card routing; got %q", got) } if len(api.textSent) != 0 {