-
Notifications
You must be signed in to change notification settings - Fork 3.6k
feat(feishu): enhance channel with markdown cards, media, mentions, and editing #1000
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c9fb681
0bee9d7
42eb6ea
595de78
fa1cb9c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,86 @@ | ||
| package feishu | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "regexp" | ||
| "strings" | ||
|
|
||
| larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" | ||
| ) | ||
|
|
||
| // mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions. | ||
| var mentionPlaceholderRegex = regexp.MustCompile(`@_user_\d+`) | ||
|
|
||
| // stringValue safely dereferences a *string pointer. | ||
| func stringValue(v *string) string { | ||
| if v == nil { | ||
| return "" | ||
| } | ||
| return *v | ||
| } | ||
|
|
||
| // buildMarkdownCard builds a Feishu Interactive Card JSON 2.0 string with markdown content. | ||
| // JSON 2.0 cards support full CommonMark standard markdown syntax. | ||
| func buildMarkdownCard(content string) (string, error) { | ||
| card := map[string]any{ | ||
| "schema": "2.0", | ||
| "body": map[string]any{ | ||
| "elements": []map[string]any{ | ||
| { | ||
| "tag": "markdown", | ||
| "content": content, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| data, err := json.Marshal(card) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| return string(data), nil | ||
| } | ||
|
|
||
| // extractJSONStringField unmarshals content as JSON and returns the value of the given string field. | ||
| // Returns "" if the content is invalid JSON or the field is missing/empty. | ||
| func extractJSONStringField(content, field string) string { | ||
| var m map[string]json.RawMessage | ||
| if err := json.Unmarshal([]byte(content), &m); err != nil { | ||
| return "" | ||
| } | ||
| raw, ok := m[field] | ||
| if !ok { | ||
| return "" | ||
| } | ||
| var s string | ||
| if err := json.Unmarshal(raw, &s); err != nil { | ||
| return "" | ||
| } | ||
| return s | ||
| } | ||
|
|
||
| // extractImageKey extracts the image_key from a Feishu image message content JSON. | ||
| // Format: {"image_key": "img_xxx"} | ||
| func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") } | ||
|
|
||
| // extractFileKey extracts the file_key from a Feishu file/audio message content JSON. | ||
| // Format: {"file_key": "file_xxx", "file_name": "...", ...} | ||
| func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") } | ||
|
|
||
| // extractFileName extracts the file_name from a Feishu file message content JSON. | ||
| func extractFileName(content string) string { return extractJSONStringField(content, "file_name") } | ||
|
|
||
| // stripMentionPlaceholders removes @_user_N placeholders from the text content. | ||
| // These are inserted by Feishu when users @mention someone in a message. | ||
| func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string { | ||
| if len(mentions) == 0 { | ||
| return content | ||
| } | ||
| for _, m := range mentions { | ||
| if m.Key != nil && *m.Key != "" { | ||
| content = strings.ReplaceAll(content, *m.Key, "") | ||
| } | ||
| } | ||
| // Also clean up any remaining @_user_N patterns | ||
| content = mentionPlaceholderRegex.ReplaceAllString(content, "") | ||
| return strings.TrimSpace(content) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,292 @@ | ||
| package feishu | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "testing" | ||
|
|
||
| larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" | ||
| ) | ||
|
Comment on lines
+1
to
+8
|
||
|
|
||
| func TestExtractJSONStringField(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| content string | ||
| field string | ||
| want string | ||
| }{ | ||
| { | ||
| name: "valid field", | ||
| content: `{"image_key": "img_v2_xxx"}`, | ||
| field: "image_key", | ||
| want: "img_v2_xxx", | ||
| }, | ||
| { | ||
| name: "missing field", | ||
| content: `{"image_key": "img_v2_xxx"}`, | ||
| field: "file_key", | ||
| want: "", | ||
| }, | ||
| { | ||
| name: "invalid JSON", | ||
| content: `not json at all`, | ||
| field: "image_key", | ||
| want: "", | ||
| }, | ||
| { | ||
| name: "empty content", | ||
| content: "", | ||
| field: "image_key", | ||
| want: "", | ||
| }, | ||
| { | ||
| name: "non-string field value", | ||
| content: `{"count": 42}`, | ||
| field: "count", | ||
| want: "", | ||
| }, | ||
| { | ||
| name: "empty string value", | ||
| content: `{"image_key": ""}`, | ||
| field: "image_key", | ||
| want: "", | ||
| }, | ||
| { | ||
| name: "multiple fields", | ||
| content: `{"file_key": "file_xxx", "file_name": "test.pdf"}`, | ||
| field: "file_name", | ||
| want: "test.pdf", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| got := extractJSONStringField(tt.content, tt.field) | ||
| if got != tt.want { | ||
| t.Errorf("extractJSONStringField(%q, %q) = %q, want %q", tt.content, tt.field, got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestExtractImageKey(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| content string | ||
| want string | ||
| }{ | ||
| { | ||
| name: "normal", | ||
| content: `{"image_key": "img_v2_abc123"}`, | ||
| want: "img_v2_abc123", | ||
| }, | ||
| { | ||
| name: "missing key", | ||
| content: `{"file_key": "file_xxx"}`, | ||
| want: "", | ||
| }, | ||
| { | ||
| name: "malformed JSON", | ||
| content: `{broken`, | ||
| want: "", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| got := extractImageKey(tt.content) | ||
| if got != tt.want { | ||
| t.Errorf("extractImageKey(%q) = %q, want %q", tt.content, got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestExtractFileKey(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| content string | ||
| want string | ||
| }{ | ||
| { | ||
| name: "normal", | ||
| content: `{"file_key": "file_v2_abc123", "file_name": "test.doc"}`, | ||
| want: "file_v2_abc123", | ||
| }, | ||
| { | ||
| name: "missing key", | ||
| content: `{"image_key": "img_xxx"}`, | ||
| want: "", | ||
| }, | ||
| { | ||
| name: "malformed JSON", | ||
| content: `not json`, | ||
| want: "", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| got := extractFileKey(tt.content) | ||
| if got != tt.want { | ||
| t.Errorf("extractFileKey(%q) = %q, want %q", tt.content, got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestExtractFileName(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| content string | ||
| want string | ||
| }{ | ||
| { | ||
| name: "normal", | ||
| content: `{"file_key": "file_xxx", "file_name": "report.pdf"}`, | ||
| want: "report.pdf", | ||
| }, | ||
| { | ||
| name: "missing name", | ||
| content: `{"file_key": "file_xxx"}`, | ||
| want: "", | ||
| }, | ||
| { | ||
| name: "malformed JSON", | ||
| content: `{bad`, | ||
| want: "", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| got := extractFileName(tt.content) | ||
| if got != tt.want { | ||
| t.Errorf("extractFileName(%q) = %q, want %q", tt.content, got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestBuildMarkdownCard(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| content string | ||
| }{ | ||
| { | ||
| name: "normal content", | ||
| content: "Hello **world**", | ||
| }, | ||
| { | ||
| name: "empty content", | ||
| content: "", | ||
| }, | ||
| { | ||
| name: "special characters", | ||
| content: `Code: "foo" & <bar> 'baz'`, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| result, err := buildMarkdownCard(tt.content) | ||
| if err != nil { | ||
| t.Fatalf("buildMarkdownCard(%q) unexpected error: %v", tt.content, err) | ||
| } | ||
|
|
||
| // Verify valid JSON | ||
| var parsed map[string]any | ||
| if err := json.Unmarshal([]byte(result), &parsed); err != nil { | ||
| t.Fatalf("buildMarkdownCard(%q) produced invalid JSON: %v", tt.content, err) | ||
| } | ||
|
|
||
| // Verify schema | ||
| if parsed["schema"] != "2.0" { | ||
| t.Errorf("schema = %v, want %q", parsed["schema"], "2.0") | ||
| } | ||
|
|
||
| // Verify body.elements[0].content == input | ||
| body, ok := parsed["body"].(map[string]any) | ||
| if !ok { | ||
| t.Fatal("missing body in card JSON") | ||
| } | ||
| elements, ok := body["elements"].([]any) | ||
| if !ok || len(elements) == 0 { | ||
| t.Fatal("missing or empty elements in card JSON") | ||
| } | ||
| elem, ok := elements[0].(map[string]any) | ||
| if !ok { | ||
| t.Fatal("first element is not an object") | ||
| } | ||
| if elem["tag"] != "markdown" { | ||
| t.Errorf("tag = %v, want %q", elem["tag"], "markdown") | ||
| } | ||
| if elem["content"] != tt.content { | ||
| t.Errorf("content = %v, want %q", elem["content"], tt.content) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestStripMentionPlaceholders(t *testing.T) { | ||
| strPtr := func(s string) *string { return &s } | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| content string | ||
| mentions []*larkim.MentionEvent | ||
| want string | ||
| }{ | ||
| { | ||
| name: "no mentions", | ||
| content: "Hello world", | ||
| mentions: nil, | ||
| want: "Hello world", | ||
| }, | ||
| { | ||
| name: "single mention", | ||
| content: "@_user_1 hello", | ||
| mentions: []*larkim.MentionEvent{ | ||
| {Key: strPtr("@_user_1")}, | ||
| }, | ||
| want: "hello", | ||
| }, | ||
| { | ||
| name: "multiple mentions", | ||
| content: "@_user_1 @_user_2 hey", | ||
| mentions: []*larkim.MentionEvent{ | ||
| {Key: strPtr("@_user_1")}, | ||
| {Key: strPtr("@_user_2")}, | ||
| }, | ||
| want: "hey", | ||
| }, | ||
| { | ||
| name: "empty content", | ||
| content: "", | ||
| mentions: []*larkim.MentionEvent{{Key: strPtr("@_user_1")}}, | ||
| want: "", | ||
| }, | ||
| { | ||
| name: "empty mentions slice", | ||
| content: "@_user_1 test", | ||
| mentions: []*larkim.MentionEvent{}, | ||
| want: "@_user_1 test", | ||
| }, | ||
| { | ||
| name: "mention with nil key", | ||
| content: "@_user_1 test", | ||
| mentions: []*larkim.MentionEvent{ | ||
| {Key: nil}, | ||
| }, | ||
| want: "test", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| got := stripMentionPlaceholders(tt.content, tt.mentions) | ||
| if got != tt.want { | ||
| t.Errorf("stripMentionPlaceholders(%q, ...) = %q, want %q", tt.content, got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pkg/channels/feishu has a 32-bit stub (feishu_32.go) because the Feishu SDK isn’t supported on 32-bit, but this file is missing the same 64-bit-only build tag and imports the Feishu SDK (larkim). As-is, 32-bit builds will still try to compile this file and fail. Add the same
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64constraint here (or split SDK-dependent helpers into a *_64.go file and provide a *_32.go stub).