diff --git a/internal/core/types.go b/internal/core/types.go index bae8613ae..cf842e6a4 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -27,6 +27,7 @@ type Endpoints struct { Open string // e.g. "https://open.feishu.cn" Accounts string // e.g. "https://accounts.feishu.cn" MCP string // e.g. "https://mcp.feishu.cn" + AppLink string // e.g. "https://applink.feishu.cn" } // ResolveEndpoints resolves endpoint URLs based on brand. @@ -37,12 +38,14 @@ func ResolveEndpoints(brand LarkBrand) Endpoints { Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "https://mcp.larksuite.com", + AppLink: "https://applink.larksuite.com", } default: return Endpoints{ Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn", + AppLink: "https://applink.feishu.cn", } } } diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 839b5b556..72f331700 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -16,6 +16,9 @@ func TestResolveEndpoints_Feishu(t *testing.T) { if ep.MCP != "https://mcp.feishu.cn" { t.Errorf("MCP = %q, want feishu.cn", ep.MCP) } + if ep.AppLink != "https://applink.feishu.cn" { + t.Errorf("AppLink = %q, want feishu.cn", ep.AppLink) + } } func TestResolveEndpoints_Lark(t *testing.T) { @@ -29,6 +32,9 @@ func TestResolveEndpoints_Lark(t *testing.T) { if ep.MCP != "https://mcp.larksuite.com" { t.Errorf("MCP = %q, want larksuite.com", ep.MCP) } + if ep.AppLink != "https://applink.larksuite.com" { + t.Errorf("AppLink = %q, want larksuite.com", ep.AppLink) + } } func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go index 8f9742d9a..e394815da 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -4,9 +4,14 @@ package convertlib import ( + "encoding/json" "fmt" + "math" + "net/url" + "strconv" "strings" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/shortcuts/common" ) @@ -148,6 +153,29 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, msg["reply_to"] = pid } + // Preserve API-provided fields (even if this formatter doesn't otherwise use them). + if v, ok := m["chat_id"]; ok { + msg["chat_id"] = v + } + if v, ok := m["message_position"]; ok { + msg["message_position"] = v + } + if v, ok := m["thread_message_position"]; ok { + msg["thread_message_position"] = v + } + if v, ok := m["message_app_link"]; ok { + msg["message_app_link"] = v + } + + // Assemble message_app_link deterministically when server doesn't provide one. + if runtime != nil && runtime.Config != nil { + if rawAppLink, _ := m["message_app_link"].(string); rawAppLink == "" { + if assembled := assembleMessageAppLink(m, runtime.Config.Brand); assembled != "" { + msg["message_app_link"] = assembled + } + } + } + if len(mentions) > 0 { simplified := make([]map[string]interface{}, 0, len(mentions)) for _, raw := range mentions { @@ -166,6 +194,81 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, return msg } +func assembleMessageAppLink(m map[string]interface{}, brand core.LarkBrand) string { + domain := resolveAppLinkDomain(brand) + if domain == "" { + return "" + } + + chatID, _ := m["chat_id"].(string) + threadID, _ := m["thread_id"].(string) + msgPos, okMsgPos := normalizeMessagePosition(m["message_position"]) + threadPos, okThreadPos := normalizeMessagePosition(m["thread_message_position"]) + + // Thread app link requires both thread_id and chat_id. + if threadID != "" && chatID != "" && okThreadPos { + return fmt.Sprintf("https://%s/client/thread/open?open_thread_id=%s&open_chat_id=%s&thread_position=%s", domain, threadID, chatID, threadPos) + } + if chatID != "" && okMsgPos { + return fmt.Sprintf("https://%s/client/chat/open?openChatId=%s&position=%s", domain, chatID, msgPos) + } + return "" +} + +func normalizeMessagePosition(v interface{}) (string, bool) { + switch vv := v.(type) { + case float64: + if math.IsNaN(vv) || math.IsInf(vv, 0) { + return "", false + } + if math.Trunc(vv) == vv { + return strconv.FormatInt(int64(vv), 10), true + } + return strconv.FormatFloat(vv, 'f', -1, 64), true + case int: + return strconv.Itoa(vv), true + case int64: + return strconv.FormatInt(vv, 10), true + case json.Number: + s := strings.TrimSpace(vv.String()) + if s == "" { + return "", false + } + f, err := strconv.ParseFloat(s, 64) + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { + return "", false + } + if math.Trunc(f) == f { + return strconv.FormatInt(int64(f), 10), true + } + return strconv.FormatFloat(f, 'f', -1, 64), true + case string: + s := strings.TrimSpace(vv) + if s == "" { + return "", false + } + f, err := strconv.ParseFloat(s, 64) + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { + return "", false + } + if math.Trunc(f) == f { + return strconv.FormatInt(int64(f), 10), true + } + return strconv.FormatFloat(f, 'f', -1, 64), true + default: + return "", false + } +} + +func resolveAppLinkDomain(brand core.LarkBrand) string { + appLink := core.ResolveEndpoints(brand).AppLink + u, err := url.Parse(appLink) + if err != nil { + return "" + } + return u.Host +} + // extractMentionOpenId extracts open_id from mention id (string or {"open_id":...} object). func extractMentionOpenId(id interface{}) string { if s, ok := id.(string); ok { diff --git a/shortcuts/im/convert_lib/content_media_misc_test.go b/shortcuts/im/convert_lib/content_media_misc_test.go index a36b7a0b0..069175710 100644 --- a/shortcuts/im/convert_lib/content_media_misc_test.go +++ b/shortcuts/im/convert_lib/content_media_misc_test.go @@ -6,6 +6,7 @@ package convertlib import ( "testing" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/shortcuts/common" ) @@ -62,6 +63,154 @@ func TestFormatMessageItem(t *testing.T) { } } +func TestResolveAppLinkDomain(t *testing.T) { + if got := resolveAppLinkDomain(core.BrandFeishu); got != "applink.feishu.cn" { + t.Fatalf("resolveAppLinkDomain(feishu) = %q", got) + } + if got := resolveAppLinkDomain(core.BrandLark); got != "applink.larksuite.com" { + t.Fatalf("resolveAppLinkDomain(lark) = %q", got) + } + if got := resolveAppLinkDomain(core.LarkBrand("other")); got != "applink.feishu.cn" { + t.Fatalf("resolveAppLinkDomain(other) = %q, want feishu", got) + } +} + +func TestFormatMessageItem_MessageAppLink_PassThrough(t *testing.T) { + runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}} + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_123", + "create_time": "1710500000", + "chat_id": "oc_1", + "message_position": 12, + "message_app_link": "https://applink.feishu.cn/client/chat/open?openChatId=oc_1&position=12", + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + + got := FormatMessageItem(raw, runtime) + if got["message_app_link"] != raw["message_app_link"] { + t.Fatalf("FormatMessageItem() message_app_link = %#v, want pass-through", got["message_app_link"]) + } +} + +func TestFormatMessageItem_MessageAppLink_AssembleChat(t *testing.T) { + runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}} + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_123", + "create_time": "1710500000", + "chat_id": "oc_1", + "message_position": float64(12), + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + + got := FormatMessageItem(raw, runtime) + if got["message_app_link"] != "https://applink.feishu.cn/client/chat/open?openChatId=oc_1&position=12" { + t.Fatalf("FormatMessageItem() message_app_link = %#v", got["message_app_link"]) + } +} + +func TestFormatMessageItem_MessageAppLink_AssembleThread(t *testing.T) { + runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandLark}} + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_123", + "create_time": "1710500000", + "chat_id": "oc_1", + "thread_id": "omt_1", + "thread_message_position": "9", + "message_position": 12, + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + + got := FormatMessageItem(raw, runtime) + if got["message_app_link"] != "https://applink.larksuite.com/client/thread/open?open_thread_id=omt_1&open_chat_id=oc_1&thread_position=9" { + t.Fatalf("FormatMessageItem() message_app_link = %#v", got["message_app_link"]) + } +} + +func TestFormatMessageItem_MessageAppLink_FallbackToChatWhenThreadPositionInvalid(t *testing.T) { + runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}} + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_123", + "create_time": "1710500000", + "chat_id": "oc_1", + "thread_id": "omt_1", + "thread_message_position": "bad", + "message_position": "12", + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + + got := FormatMessageItem(raw, runtime) + if got["message_app_link"] != "https://applink.feishu.cn/client/chat/open?openChatId=oc_1&position=12" { + t.Fatalf("FormatMessageItem() message_app_link = %#v", got["message_app_link"]) + } +} + +func TestFormatMessageItem_MessageAppLink_BrandUnknownDefaultsToFeishu(t *testing.T) { + runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.LarkBrand("other")}} + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_123", + "create_time": "1710500000", + "chat_id": "oc_1", + "message_position": 12, + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + + got := FormatMessageItem(raw, runtime) + if got["message_app_link"] != "https://applink.feishu.cn/client/chat/open?openChatId=oc_1&position=12" { + t.Fatalf("FormatMessageItem() message_app_link = %#v", got["message_app_link"]) + } +} + +func TestFormatMessageItem_MessageAppLink_RuntimeNilNoAssemble(t *testing.T) { + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_123", + "create_time": "1710500000", + "chat_id": "oc_1", + "message_position": 12, + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + + got := FormatMessageItem(raw, nil) + if _, ok := got["message_app_link"]; ok { + t.Fatalf("FormatMessageItem() should not assemble without runtime, got %#v", got["message_app_link"]) + } +} + +func TestFormatMessageItem_MessageAppLink_MissingFieldsNoPanic(t *testing.T) { + runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}} + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_123", + "create_time": "1710500000", + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + + got := FormatMessageItem(raw, runtime) + if _, ok := got["message_app_link"]; ok { + t.Fatalf("FormatMessageItem() message_app_link should be absent when fields are missing, got %#v", got["message_app_link"]) + } +} + +func TestNormalizeMessagePosition_AllowsZeroAndNegative(t *testing.T) { + if got, ok := normalizeMessagePosition("0"); !ok || got != "0" { + t.Fatalf("normalizeMessagePosition(\"0\") = (%q,%v)", got, ok) + } + if got, ok := normalizeMessagePosition("-3"); !ok || got != "-3" { + t.Fatalf("normalizeMessagePosition(\"-3\") = (%q,%v)", got, ok) + } + if got, ok := normalizeMessagePosition(float64(0)); !ok || got != "0" { + t.Fatalf("normalizeMessagePosition(0.0) = (%q,%v)", got, ok) + } + if got, ok := normalizeMessagePosition(float64(-1)); !ok || got != "-1" { + t.Fatalf("normalizeMessagePosition(-1.0) = (%q,%v)", got, ok) + } +} + func TestExtractMentionOpenIdAndTruncateContent(t *testing.T) { if got := extractMentionOpenId("ou_1"); got != "ou_1" { t.Fatalf("extractMentionOpenId(string) = %q", got) diff --git a/tests/cli_e2e/im/chat_message_workflow_test.go b/tests/cli_e2e/im/chat_message_workflow_test.go index 7263c01e5..8b60483eb 100644 --- a/tests/cli_e2e/im/chat_message_workflow_test.go +++ b/tests/cli_e2e/im/chat_message_workflow_test.go @@ -70,6 +70,12 @@ func TestIM_ChatMessageWorkflowAsUser(t *testing.T) { continue } require.True(t, strings.Contains(item.Get("content").String(), messageText), "stdout:\n%s", result.Stdout) + requireChatMessageAppLink( + t, + item.Get("message_app_link").String(), + item.Get("chat_id").String(), + item.Get("message_position").String(), + ) found = true break } diff --git a/tests/cli_e2e/im/helpers_test.go b/tests/cli_e2e/im/helpers_test.go index ac509bec8..3a3eb4faf 100644 --- a/tests/cli_e2e/im/helpers_test.go +++ b/tests/cli_e2e/im/helpers_test.go @@ -5,13 +5,61 @@ package im import ( "context" + "net/url" + "os" "testing" + "strings" clie2e "github.com/larksuite/cli/tests/cli_e2e" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) +func expectedAppLinkDomain() string { + brand := strings.ToLower(strings.TrimSpace(os.Getenv("LARKSUITE_CLI_BRAND"))) + if brand == "lark" || brand == "larksuite" { + return "applink.larksuite.com" + } + // Default to feishu; most test environments use feishu when unset. + return "applink.feishu.cn" +} + +func requireChatMessageAppLink(t *testing.T, appLink string, chatID string, messagePosition string) { + t.Helper() + require.NotEmpty(t, appLink, "message_app_link should not be empty") + require.NotEmpty(t, chatID, "chat_id should not be empty") + require.NotEmpty(t, messagePosition, "message_position should not be empty") + + u, err := url.Parse(appLink) + require.NoError(t, err, "invalid message_app_link: %s", appLink) + require.Equal(t, "https", u.Scheme) + require.Equal(t, expectedAppLinkDomain(), u.Host) + require.Equal(t, "/client/chat/open", u.Path) + + q := u.Query() + require.Equal(t, chatID, q.Get("openChatId")) + require.Equal(t, messagePosition, q.Get("position")) +} + +func requireThreadMessageAppLink(t *testing.T, appLink string, threadID string, chatID string, threadMessagePosition string) { + t.Helper() + require.NotEmpty(t, appLink, "message_app_link should not be empty") + require.NotEmpty(t, threadID, "thread_id should not be empty") + require.NotEmpty(t, chatID, "chat_id should not be empty") + require.NotEmpty(t, threadMessagePosition, "thread_message_position should not be empty") + + u, err := url.Parse(appLink) + require.NoError(t, err, "invalid message_app_link: %s", appLink) + require.Equal(t, "https", u.Scheme) + require.Equal(t, expectedAppLinkDomain(), u.Host) + require.Equal(t, "/client/thread/open", u.Path) + + q := u.Query() + require.Equal(t, threadID, q.Get("open_thread_id")) + require.Equal(t, chatID, q.Get("open_chat_id")) + require.Equal(t, threadMessagePosition, q.Get("thread_position")) +} + // createChat creates a private chat with the given name and returns the chatID. // The chat will be automatically cleaned up via parentT.Cleanup(). // Note: Chat deletion is not available via lark-cli im command. diff --git a/tests/cli_e2e/im/message_get_workflow_test.go b/tests/cli_e2e/im/message_get_workflow_test.go index 16e324a1f..22bec5a7c 100644 --- a/tests/cli_e2e/im/message_get_workflow_test.go +++ b/tests/cli_e2e/im/message_get_workflow_test.go @@ -41,5 +41,12 @@ func TestIM_MessageGetWorkflowAsUser(t *testing.T) { require.Len(t, messages, 1, "stdout:\n%s", result.Stdout) require.Equal(t, messageID, messages[0].Get("message_id").String(), "stdout:\n%s", result.Stdout) require.True(t, strings.Contains(messages[0].Get("content").String(), messageText), "stdout:\n%s", result.Stdout) + + requireChatMessageAppLink( + t, + messages[0].Get("message_app_link").String(), + messages[0].Get("chat_id").String(), + messages[0].Get("message_position").String(), + ) }) } diff --git a/tests/cli_e2e/im/message_reply_workflow_test.go b/tests/cli_e2e/im/message_reply_workflow_test.go index dd913f61c..3c5423ea8 100644 --- a/tests/cli_e2e/im/message_reply_workflow_test.go +++ b/tests/cli_e2e/im/message_reply_workflow_test.go @@ -102,6 +102,28 @@ func TestIM_MessageReplyWorkflowAsBot(t *testing.T) { var found bool for _, item := range gjson.Get(threadResult.Stdout, "data.messages").Array() { if strings.Contains(item.Get("content").String(), replyText) { + appLink := item.Get("message_app_link").String() + chatID := item.Get("chat_id").String() + threadMsgPos := item.Get("thread_message_position").String() + msgPos := item.Get("message_position").String() + if appLink == "" { + t.Fatalf("thread message_app_link is empty; chat_id=%q thread_id=%q thread_message_position=%q message_position=%q item=%s\nstdout:\n%s", + chatID, + item.Get("thread_id").String(), + threadMsgPos, + msgPos, + item.Raw, + threadResult.Stdout, + ) + } + requireThreadMessageAppLink( + t, + appLink, + threadID, + chatID, + threadMsgPos, + ) + require.Equal(t, threadID, item.Get("thread_id").String(), "stdout:\n%s", threadResult.Stdout) found = true break }