Skip to content

Commit 061268c

Browse files
authored
feat(channel): inbound rich Parts across IMs (#644)
* feat(pipeline): consume inbound MessagePart in adapt Adapters that populate Message.Parts (currently only Telegram, for mentions) had their structured data silently dropped: adaptMessage and adaptEdit only read msg.Message.Text, so the canonical ContentNode tree the renderer is designed to consume was never built. Add adaptBody/adaptParts/partToNode that translate channel.MessagePart into the ContentNode shape the renderer already supports (mention/link/pre/code plus nested bold/italic/strikethrough wrappers). Fall back to the existing plain-text path when Parts is empty. Telegram inbound mentions now reach the LLM as <mention uid="..."> instead of being flattened to plain text. * feat(telegram): extract bold/italic/code/link entities as inbound parts The previous extractor only recognised mention/text_mention, so Telegram formatting (bold, italic, code, code blocks, text_link, bare URLs) was flattened to plain text before reaching the pipeline. With the renderer now consuming MessagePart via adaptBody, populating the full entity set lets the LLM see the user's formatting intent. The new extractor walks rune offsets, sorts entities by (offset asc, length desc) so an outer span wins on overlap, emits plain-text Parts for the gaps between entities, and returns nil when the result carries no rich content so callers fall back to the Text field unchanged. * feat(feishu): emit inbound post structure as MessagePart Feishu post messages already carry a structured tree (lines of typed elements: text with style, link, at-mention, code block). The inbound path flattened it to a space-joined string, so style, link URLs, and @user IDs never reached the pipeline. extractFeishuPostParts walks the same lines/parts structure as the existing text and attachment extractors, translates each tag (text + style array, a, at, code_block) into channel.MessagePart, and inserts a newline text part between lines so the LLM sees the paragraph break. Returns nil for single-line all-unstyled posts so callers fall back to the plain Text field unchanged. * fix(telegram): slice entities by UTF-16 code units Telegram entity offset and length are documented as UTF-16 code units, but the parser indexed into []rune, which under-counts every supplementary-plane character (most emoji) by one position. A bold entity following 🎉 in the message would land one character left of its real target, so the LLM would see "old" wrapped in <b> instead of "bold", and any further entity in the same message would compound the drift. Switch the parser to walk utf16.Encode/Decode of the text, matching the Telegram spec. The BMP-only test is kept (CJK still indexes the same under either model) and the surrogate-pair case is now covered by TestExtractTelegramMessageParts_HandlesSupplementaryPlaneEmoji. * fix(telegram): bump inbound Format to rich when Parts populate toInboundTelegramMessage hard-coded MessageFormatPlain regardless of whether entity parsing produced rich Parts, so an inbound message with bold/italic/code spans reached downstream consumers tagged as plain even though Parts already carried the structure. Discord/Slack/Feishu inbound flip Format to Rich in the same condition; align Telegram. Also rename the local variable from mentionParts to richParts now that the extractor covers all supported entity types, not just mentions. * fix(pipeline): preserve whitespace-only text parts in adapt adaptParts rejected any text MessagePart whose body trimmed to empty, so structured-post adapters that interleave content with newline separators (Feishu emits a "\n" text Part between lines) lost the separators and the agent saw `line1line2` instead of `line1\nline2`. Only drop literally empty parts now; whitespace-only spans flow through unchanged. The existing single-line "all-plain-no-styles → nil" guards on the adapter side still elide whole-message whitespace runs, so this loosening only matters once at least one rich span makes the message non-trivial. * fix(feishu): keep text from unknown post tags extractFeishuPostText's default branch reads part["text"] for any unrecognised tag, so the legacy text path always surfaced user-visible copy from forward-compat tags. The new Parts builder's default branch silently dropped them, so a post mixing a styled element (which flips adaptBody to the Parts path) with an unknown text-bearing tag lost that content from the LLM context entirely. Forward text-bearing unknown tags as plain text Parts. rich=false stays so this alone never promotes an all-plain post into the rich path. * fix(telegram): preserve sender text on text_mention parts The text_mention branch overwrote the entity slice with "@" plus the linked profile's first name. When a sender anchored a tg://user link to a custom label such as "the reviewer", the LLM saw "@alice" instead of what was actually written; the link target also lost the visible context the sender chose. Use the entity slice as the mention display, surface the linked user's id and profile via Metadata, and set ChannelIdentityID to the platform user id so downstream identity rendering still works. * fix(telegram): split outer entity around nested link/mention Telegram delivers overlapping entities natively (a bold span covers the whole rendered text, a text_link/text_mention pin a sub-range). The cursor guard dropped any nested entity, so a bold-with-link message reached the LLM as bold text only — the URL/identity signal was lost, even though the wire payload carried it. Pre-pass to identify each structural entity's smallest container. In the main loop, when an outer entity has nested structural children, emit the outer in segments around the children: lead styled run, then the child (link or mention with full URL/ChannelIdentityID), then the tail styled run. The flat MessagePart schema still can't carry both the outer style and the link at the same position, so the link span itself appears without the outer's style — but the URL is preserved, and the surrounding text keeps the user's emphasis. * fix(telegram): treat coextensive style+structural as nested The split pre-pass skipped any candidate whose range exactly matched the structural entity's. A fully bold link (bold and text_link covering the identical span) therefore had no recognised parent, the main loop emitted only the styled span, and the cursor guard then dropped the text_link — the URL never reached the LLM even though Telegram delivered it. Drop the equality exclusion so a style entity on the same range counts as the structural's parent, and explicitly skip structural candidates when picking a parent so two coextensive links don't mutually adopt each other and disappear. The split path then emits just the link Part (the flat schema still can't carry both style and link at the same span; the URL wins).
1 parent 54609a3 commit 061268c

9 files changed

Lines changed: 1396 additions & 54 deletions

File tree

internal/channel/adapters/feishu/inbound.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ func extractFeishuInbound(event *larkim.P2MessageReceiveV1, botOpenID string, lo
5151
if postText != "" {
5252
msg.Text = postText
5353
}
54+
if postParts := extractFeishuPostParts(contentMap); len(postParts) > 0 {
55+
msg.Parts = postParts
56+
msg.Format = channel.MessageFormatRich
57+
}
5458
postAtts := extractFeishuPostAttachments(contentMap, msg.ID)
5559
msg.Attachments = append(msg.Attachments, postAtts...)
5660
if len(postAtts) > 0 || postText != "" {

internal/channel/adapters/feishu/inbound_post.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,133 @@ func stringValue(raw any) string {
128128
}
129129
return fmt.Sprint(raw)
130130
}
131+
132+
func extractFeishuPostParts(contentMap map[string]any) []channel.MessagePart {
133+
linesRaw := getFeishuPostContentLines(contentMap)
134+
if linesRaw == nil {
135+
return nil
136+
}
137+
var parts []channel.MessagePart
138+
hasRich := false
139+
for li, rawLine := range linesRaw {
140+
line, ok := rawLine.([]any)
141+
if !ok {
142+
continue
143+
}
144+
if li > 0 && len(parts) > 0 {
145+
parts = append(parts, channel.MessagePart{Type: channel.MessagePartText, Text: "\n"})
146+
hasRich = true
147+
}
148+
for _, rawPart := range line {
149+
part, ok := rawPart.(map[string]any)
150+
if !ok {
151+
continue
152+
}
153+
mp, rich, ok := feishuPostPartToMessagePart(part)
154+
if !ok {
155+
continue
156+
}
157+
if rich {
158+
hasRich = true
159+
}
160+
parts = append(parts, mp)
161+
}
162+
}
163+
if !hasRich {
164+
return nil
165+
}
166+
return parts
167+
}
168+
169+
func feishuPostPartToMessagePart(part map[string]any) (channel.MessagePart, bool, bool) {
170+
tag := strings.ToLower(strings.TrimSpace(stringValue(part["tag"])))
171+
switch tag {
172+
case "text":
173+
text := stringValue(part["text"])
174+
if text == "" {
175+
return channel.MessagePart{}, false, false
176+
}
177+
styles := feishuPostStyles(part["style"])
178+
return channel.MessagePart{
179+
Type: channel.MessagePartText,
180+
Text: text,
181+
Styles: styles,
182+
}, len(styles) > 0, true
183+
case "a":
184+
text := stringValue(part["text"])
185+
href := strings.TrimSpace(stringValue(part["href"]))
186+
if text == "" && href == "" {
187+
return channel.MessagePart{}, false, false
188+
}
189+
if text == "" {
190+
text = href
191+
}
192+
return channel.MessagePart{
193+
Type: channel.MessagePartLink,
194+
Text: text,
195+
URL: href,
196+
}, true, true
197+
case "at":
198+
uid := strings.TrimSpace(stringValue(part["user_id"]))
199+
display := strings.TrimSpace(stringValue(part["text"]))
200+
if display == "" {
201+
display = strings.TrimSpace(stringValue(part["name"]))
202+
}
203+
if display == "" {
204+
display = strings.TrimSpace(stringValue(part["user_name"]))
205+
}
206+
if display == "" {
207+
if uid == "" {
208+
return channel.MessagePart{}, false, false
209+
}
210+
display = "@" + uid
211+
} else if !strings.HasPrefix(display, "@") {
212+
display = "@" + display
213+
}
214+
return channel.MessagePart{
215+
Type: channel.MessagePartMention,
216+
Text: display,
217+
ChannelIdentityID: uid,
218+
}, true, true
219+
case "code_block":
220+
text := stringValue(part["text"])
221+
if text == "" {
222+
return channel.MessagePart{}, false, false
223+
}
224+
return channel.MessagePart{
225+
Type: channel.MessagePartCodeBlock,
226+
Text: text,
227+
Language: strings.TrimSpace(stringValue(part["language"])),
228+
}, true, true
229+
default:
230+
// Forward-compatible: any future tag carrying a `text` field should
231+
// still surface its body so users don't lose content when a styled
232+
// element promotes the post to rich (and adaptBody picks Parts over
233+
// Text). rich=false keeps this from falsely promoting a plain-only
234+
// post on its own.
235+
text := stringValue(part["text"])
236+
if text == "" {
237+
return channel.MessagePart{}, false, false
238+
}
239+
return channel.MessagePart{Type: channel.MessagePartText, Text: text}, false, true
240+
}
241+
}
242+
243+
func feishuPostStyles(raw any) []channel.MessageTextStyle {
244+
arr, ok := raw.([]any)
245+
if !ok || len(arr) == 0 {
246+
return nil
247+
}
248+
var styles []channel.MessageTextStyle
249+
for _, item := range arr {
250+
switch strings.ToLower(strings.TrimSpace(stringValue(item))) {
251+
case "bold":
252+
styles = append(styles, channel.MessageStyleBold)
253+
case "italic":
254+
styles = append(styles, channel.MessageStyleItalic)
255+
case "linethrough", "strike", "strikethrough":
256+
styles = append(styles, channel.MessageStyleStrikethrough)
257+
}
258+
}
259+
return styles
260+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package feishu
2+
3+
import (
4+
"testing"
5+
6+
"github.com/memohai/memoh/internal/channel"
7+
)
8+
9+
func TestExtractFeishuPostParts_StyleArrayIsRespected(t *testing.T) {
10+
t.Parallel()
11+
content := map[string]any{
12+
"content": []any{
13+
[]any{
14+
map[string]any{"tag": "text", "text": "shout", "style": []any{"bold", "italic"}},
15+
},
16+
},
17+
}
18+
parts := extractFeishuPostParts(content)
19+
if len(parts) != 1 {
20+
t.Fatalf("got %+v", parts)
21+
}
22+
styles := parts[0].Styles
23+
if len(styles) != 2 || styles[0] != channel.MessageStyleBold || styles[1] != channel.MessageStyleItalic {
24+
t.Fatalf("expected bold+italic styles, got %+v", styles)
25+
}
26+
}
27+
28+
func TestExtractFeishuPostParts_LineThroughMapsToStrike(t *testing.T) {
29+
t.Parallel()
30+
content := map[string]any{
31+
"content": []any{
32+
[]any{
33+
map[string]any{"tag": "text", "text": "gone", "style": []any{"lineThrough"}},
34+
},
35+
},
36+
}
37+
parts := extractFeishuPostParts(content)
38+
if len(parts) != 1 || len(parts[0].Styles) != 1 || parts[0].Styles[0] != channel.MessageStyleStrikethrough {
39+
t.Fatalf("expected strikethrough, got %+v", parts)
40+
}
41+
}
42+
43+
func TestExtractFeishuPostParts_LinkTag(t *testing.T) {
44+
t.Parallel()
45+
content := map[string]any{
46+
"content": []any{
47+
[]any{
48+
map[string]any{"tag": "a", "text": "Memoh", "href": "https://example.com"},
49+
},
50+
},
51+
}
52+
parts := extractFeishuPostParts(content)
53+
if len(parts) != 1 || parts[0].Type != channel.MessagePartLink {
54+
t.Fatalf("expected link part, got %+v", parts)
55+
}
56+
if parts[0].URL != "https://example.com" || parts[0].Text != "Memoh" {
57+
t.Fatalf("link content wrong: %+v", parts[0])
58+
}
59+
}
60+
61+
func TestExtractFeishuPostParts_AtTag(t *testing.T) {
62+
t.Parallel()
63+
content := map[string]any{
64+
"content": []any{
65+
[]any{
66+
map[string]any{"tag": "at", "user_id": "ou_xyz", "text": "@Alice"},
67+
},
68+
},
69+
}
70+
parts := extractFeishuPostParts(content)
71+
if len(parts) != 1 || parts[0].Type != channel.MessagePartMention {
72+
t.Fatalf("expected mention, got %+v", parts)
73+
}
74+
if parts[0].ChannelIdentityID != "ou_xyz" || parts[0].Text != "@Alice" {
75+
t.Fatalf("mention content wrong: %+v", parts[0])
76+
}
77+
}
78+
79+
func TestExtractFeishuPostParts_CodeBlock(t *testing.T) {
80+
t.Parallel()
81+
content := map[string]any{
82+
"content": []any{
83+
[]any{
84+
map[string]any{"tag": "code_block", "text": "fn main(){}", "language": "rust"},
85+
},
86+
},
87+
}
88+
parts := extractFeishuPostParts(content)
89+
if len(parts) != 1 || parts[0].Type != channel.MessagePartCodeBlock {
90+
t.Fatalf("expected code_block, got %+v", parts)
91+
}
92+
if parts[0].Language != "rust" || parts[0].Text != "fn main(){}" {
93+
t.Fatalf("code_block content wrong: %+v", parts[0])
94+
}
95+
}
96+
97+
func TestExtractFeishuPostParts_MixedLineBreaksWithNewline(t *testing.T) {
98+
t.Parallel()
99+
content := map[string]any{
100+
"content": []any{
101+
[]any{
102+
map[string]any{"tag": "text", "text": "line1"},
103+
},
104+
[]any{
105+
map[string]any{"tag": "text", "text": "line2"},
106+
},
107+
},
108+
}
109+
parts := extractFeishuPostParts(content)
110+
if len(parts) != 3 {
111+
t.Fatalf("expected 3 parts (text, newline, text), got %+v", parts)
112+
}
113+
if parts[0].Text != "line1" {
114+
t.Fatalf("part 0 wrong: %+v", parts[0])
115+
}
116+
if parts[1].Type != channel.MessagePartText || parts[1].Text != "\n" {
117+
t.Fatalf("expected newline separator, got %+v", parts[1])
118+
}
119+
if parts[2].Text != "line2" {
120+
t.Fatalf("part 2 wrong: %+v", parts[2])
121+
}
122+
}
123+
124+
func TestExtractFeishuPostParts_KeepsTextFromUnknownTag(t *testing.T) {
125+
t.Parallel()
126+
// When a styled element promotes the post to "rich", adaptBody picks Parts
127+
// over Text. Any text-bearing unknown tag must still surface its body or
128+
// the LLM loses that content entirely.
129+
content := map[string]any{
130+
"content": []any{
131+
[]any{
132+
map[string]any{"tag": "text", "text": "intro", "style": []any{"bold"}},
133+
map[string]any{"tag": "unknown_future_tag", "text": "important detail"},
134+
},
135+
},
136+
}
137+
parts := extractFeishuPostParts(content)
138+
if len(parts) != 2 {
139+
t.Fatalf("expected 2 parts (bold + unknown's text), got %+v", parts)
140+
}
141+
if parts[1].Type != channel.MessagePartText || parts[1].Text != "important detail" {
142+
t.Fatalf("expected unknown tag's text preserved, got %+v", parts[1])
143+
}
144+
}
145+
146+
func TestExtractFeishuPostParts_ImagesAndFilesSkipped(t *testing.T) {
147+
t.Parallel()
148+
content := map[string]any{
149+
"content": []any{
150+
[]any{
151+
map[string]any{"tag": "text", "text": "see", "style": []any{"bold"}},
152+
map[string]any{"tag": "img", "image_key": "img_x"},
153+
map[string]any{"tag": "file", "file_key": "f_y"},
154+
},
155+
},
156+
}
157+
parts := extractFeishuPostParts(content)
158+
if len(parts) != 1 || parts[0].Text != "see" || parts[0].Type != channel.MessagePartText {
159+
t.Fatalf("img/file tags should be skipped (extracted as attachments elsewhere), got %+v", parts)
160+
}
161+
}
162+
163+
func TestExtractFeishuPostParts_NoContentReturnsNil(t *testing.T) {
164+
t.Parallel()
165+
if got := extractFeishuPostParts(nil); got != nil {
166+
t.Fatalf("expected nil for nil content, got %+v", got)
167+
}
168+
if got := extractFeishuPostParts(map[string]any{}); got != nil {
169+
t.Fatalf("expected nil for empty content, got %+v", got)
170+
}
171+
}
172+
173+
func TestExtractFeishuPostParts_PlainOnlyReturnsNil(t *testing.T) {
174+
t.Parallel()
175+
content := map[string]any{
176+
"content": []any{
177+
[]any{
178+
map[string]any{"tag": "text", "text": "just text"},
179+
},
180+
},
181+
}
182+
parts := extractFeishuPostParts(content)
183+
if parts != nil {
184+
t.Fatalf("expected nil when only unstyled plain text (caller falls back to Text), got %+v", parts)
185+
}
186+
}

0 commit comments

Comments
 (0)