Skip to content
41 changes: 40 additions & 1 deletion server/internal/integrations/lark/content_flatten.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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]"
}
}
40 changes: 39 additions & 1 deletion server/internal/integrations/lark/content_flatten_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", `{}`, ""},
Expand All @@ -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)
}
})
}
}
20 changes: 19 additions & 1 deletion server/internal/integrations/lark/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"log/slog"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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},
},
},
}
Expand Down
68 changes: 66 additions & 2 deletions server/internal/integrations/lark/http_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
},
})
Expand Down
1 change: 1 addition & 0 deletions server/internal/integrations/lark/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions server/internal/integrations/lark/outbound_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 10 additions & 6 deletions server/internal/integrations/lark/ws_frame_decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
32 changes: 32 additions & 0 deletions server/internal/integrations/lark/ws_frame_decoder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading