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/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..005fe9c1c12 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" @@ -14,6 +15,8 @@ import ( "strings" "sync" "time" + + "golang.org/x/text/cases" ) // Real Lark/飞书 Open Platform HTTP APIClient. @@ -62,6 +65,725 @@ const ( codeTokenInvalid = 99991664 ) +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, referenceKey, ok := parseMarkdownImage(markdown, start, definitions) + if !ok { + start += 2 + continue + } + if !isLocalMarkdownImageDestination(destination) { + start = end + continue + } + + sanitized.WriteString(markdown[last:start]) + label = strings.TrimSpace(label) + if label == "" { + label = "image" + } + fmt.Fprintf(&sanitized, "[%s omitted]", label) + if referenceKey != "" { + redactedDefinitions[referenceKey] = struct{}{} + } + last = end + start = end + changed = true + } + if !changed { + return markdown + } + sanitized.WriteString(markdown[last:]) + 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, 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 + } + + 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 '[': + depth++ + case ']': + if depth == 0 { + return i, true + } + depth-- + } + } + return 0, false +} + +func parseInlineMarkdownImageDestination(markdown string, openParen int) (end int, destination string, ok bool) { + i := openParen + 1 + 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, strings.TrimSpace(markdown[destinationStart:destinationEnd]), true + } + depth-- + } + i++ + } + return 0, "", false +} + +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) + if _, exists := definitions[key]; !exists { + definitions[key] = markdownReferenceDefinition{destination: destination} + } + start = end + continue + } + start = nextMarkdownLineStart(markdown, start) + } + return definitions +} + +type markdownReferenceDefinitionScanState struct { + inFence bool + fence byte + fenceLen int + container markdownLineContainer +} + +type markdownLineContainer struct { + blockquote int + listContentIndent int +} + +func (s *markdownReferenceDefinitionScanState) skipLine(markdown string, start int) bool { + if s.inFence { + if !lineBelongsToMarkdownContainer(markdown, start, s.container) { + s.inFence = false + } else { + if parseMarkdownFenceCloseLine(markdown, start, s.fence, s.fenceLen) { + s.inFence = false + } + return true + } + } + 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, markdownLineContainer, bool) { + lineEnd := markdownLineEnd(markdown, start) + i, ok := skipMarkdownReferenceDefinitionLinePrefix(markdown, start) + if !ok || i >= lineEnd || (markdown[i] != '`' && markdown[i] != '~') { + return 0, 0, markdownLineContainer{}, false + } + fence := markdown[i] + j := i + for j < lineEnd && markdown[j] == fence { + j++ + } + if j-i < 3 { + return 0, 0, markdownLineContainer{}, false + } + if fence == '`' { + for k := j; k < lineEnd; k++ { + if markdown[k] == '`' { + return 0, 0, markdownLineContainer{}, false + } + } + } + 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 { + if after, ok := skipMarkdownListMarker(markdown, j); ok { + container.listContentIndent = after - i + } + } + return container +} + +func lineBelongsToMarkdownContainer(markdown string, start int, container markdownLineContainer) bool { + current := markdownLineContainerForLine(markdown, start) + if current.blockquote < container.blockquote { + return false + } + if container.listContentIndent == 0 { + return true + } + 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 { + 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 { + 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 { + 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 { + 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] != '[' { + return "", "", 0, false + } + labelEnd, ok := findMarkdownLabelEnd(markdown, i) + if !ok || labelEnd+1 >= len(markdown) || markdown[labelEnd+1] != ':' { + return "", "", 0, false + } + label = markdown[i+1 : labelEnd] + if normalizeMarkdownReferenceLabel(label) == "" { + return "", "", 0, false + } + i = labelEnd + 2 + 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(markdown) && (markdown[i] == '\n' || markdown[i] == '\r') { + i = consumeMarkdownLineEnding(markdown, i) + 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 + for i = start; i < len(markdown); i++ { + if markdown[i] == '\\' { + i++ + continue + } + if markdown[i] == '\n' || markdown[i] == '\r' { + return "", 0, false + } + if markdown[i] == '>' { + return strings.TrimSpace(markdown[start:i]), i + 1, true + } + } + return "", 0, false + } + + start := i + depth := 0 + for i < len(markdown) { + if markdown[i] == '\\' { + if i+1 >= len(markdown) { + return "", 0, false + } + 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 strings.TrimSpace(markdown[start:i]), i, true +} + +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 + } + if markdown[i] == close { + i++ + for i < lineEnd && (markdown[i] == ' ' || markdown[i] == '\t') { + i++ + } + return i, i == lineEnd + } + } + return 0, false +} + +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 + } + 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 cases.Fold().String(strings.Join(strings.Fields(label), " ")) +} + +func isMarkdownSpace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' +} + +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, `\\`) { + return true + } + return len(destination) >= 3 && + ((destination[0] >= 'a' && destination[0] <= 'z') || + (destination[0] >= 'A' && destination[0] <= 'Z')) && + 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 @@ -358,11 +1080,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..9e6bf5d3ef8 100644 --- a/server/internal/integrations/lark/http_client_test.go +++ b/server/internal/integrations/lark/http_client_test.go @@ -567,6 +567,117 @@ 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]`}, + {"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]`}, + {"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_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"}, + {"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"}, + {"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_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"}, + {"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```"}, + {"malformed_trailing_backslash_definition", "![scan][x]\n[x]: \\", "![scan][x]\n[x]: \\"}, + {"malformed_truncated_angle_definition", "![scan][x]\n[x]: - ~~~\n> [local]: https://example.com/image.png\n> - [local]: /tmp/screenshot.png", + }, + }) + + 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]\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 { + 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. 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