From e754b3bc1b1186affbde370566e5bfa94cf4377a Mon Sep 17 00:00:00 2001 From: sammi-bytedance Date: Fri, 8 May 2026 16:06:48 +0800 Subject: [PATCH] feat(im): add message_app_link to IM message outputs (#668) - Assemble applinks via net/url to ensure proper encoding - Normalize message position values across more numeric types - Avoid leaking null message_app_link; assemble when missing - Update unit tests to assert URL semantics and cover edge cases Change-Id: Ic473cb563c8a648c4f6677c32b25b9f371a0f84e --- internal/core/types.go | 3 + internal/core/types_test.go | 6 + shortcuts/im/convert_lib/content_convert.go | 171 +++++++++ .../im/convert_lib/content_media_misc_test.go | 327 ++++++++++++++++++ 4 files changed, 507 insertions(+) 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..894df34d2 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -4,9 +4,15 @@ package convertlib import ( + "encoding/json" "fmt" + "math" + "net/url" + "reflect" + "strconv" "strings" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/shortcuts/common" ) @@ -148,6 +154,27 @@ 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 + } + + // Prefer API-provided message_app_link when it's a non-empty string; otherwise assemble deterministically. + appLink, _ := m["message_app_link"].(string) + appLink = strings.TrimSpace(appLink) + if appLink == "" && runtime != nil && runtime.Config != nil { + appLink = assembleMessageAppLink(m, runtime.Config.Brand) + } + if appLink != "" { + msg["message_app_link"] = appLink + } + if len(mentions) > 0 { simplified := make([]map[string]interface{}, 0, len(mentions)) for _, raw := range mentions { @@ -166,6 +193,150 @@ 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. + // Emit both underscore-less (openthreadid/openchatid) and snake_case (open_thread_id/open_chat_id) + // query keys so PC and mobile clients can both resolve the link. + if threadID != "" && chatID != "" && okThreadPos { + u := &url.URL{Scheme: "https", Host: domain, Path: "/client/thread/open"} + q := url.Values{} + q.Set("openthreadid", threadID) + q.Set("openchatid", chatID) + q.Set("open_thread_id", threadID) + q.Set("open_chat_id", chatID) + q.Set("thread_position", threadPos) + u.RawQuery = q.Encode() + return u.String() + } + if chatID != "" && okMsgPos { + u := &url.URL{Scheme: "https", Host: domain, Path: "/client/chat/open"} + q := url.Values{} + q.Set("openChatId", chatID) + q.Set("position", msgPos) + u.RawQuery = q.Encode() + return u.String() + } + return "" +} + +func normalizeMessagePosition(v interface{}) (string, bool) { + if v == nil { + return "", false + } + switch vv := v.(type) { + case float32: + f := float64(vv) + if 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 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 int8: + return strconv.FormatInt(int64(vv), 10), true + case int16: + return strconv.FormatInt(int64(vv), 10), true + case int32: + return strconv.FormatInt(int64(vv), 10), true + case int64: + return strconv.FormatInt(vv, 10), true + case uint: + return strconv.FormatUint(uint64(vv), 10), true + case uint8: + return strconv.FormatUint(uint64(vv), 10), true + case uint16: + return strconv.FormatUint(uint64(vv), 10), true + case uint32: + return strconv.FormatUint(uint64(vv), 10), true + case uint64: + return strconv.FormatUint(vv, 10), true + case uintptr: + return strconv.FormatUint(uint64(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: + // Fallback for typed numeric values (e.g. int32/uint64 via struct -> interface{}), pointers, etc. + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return "", false + } + rv = rv.Elem() + } + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(rv.Int(), 10), true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return strconv.FormatUint(rv.Uint(), 10), true + case reflect.Float32, reflect.Float64: + f := rv.Float() + if 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..85f26216d 100644 --- a/shortcuts/im/convert_lib/content_media_misc_test.go +++ b/shortcuts/im/convert_lib/content_media_misc_test.go @@ -4,11 +4,44 @@ package convertlib import ( + "encoding/json" + "math" + "net/url" "testing" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/shortcuts/common" ) +func mustParseURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("url.Parse(%q) error: %v", raw, err) + } + return u +} + +func assertURLHasQuery(t *testing.T, raw, host, path string, want map[string]string) { + t.Helper() + u := mustParseURL(t, raw) + if u.Scheme != "https" { + t.Fatalf("url scheme = %q, want https (%q)", u.Scheme, raw) + } + if u.Host != host { + t.Fatalf("url host = %q, want %q (%q)", u.Host, host, raw) + } + if u.Path != path { + t.Fatalf("url path = %q, want %q (%q)", u.Path, path, raw) + } + q := u.Query() + for k, v := range want { + if got := q.Get(k); got != v { + t.Fatalf("query[%q] = %q, want %q (%q)", k, got, v, raw) + } + } +} + func TestConvertBodyContent(t *testing.T) { ctx := &ConvertContext{RawContent: `{"text":"hello"}`} @@ -62,6 +95,300 @@ 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) + assertURLHasQuery(t, got["message_app_link"].(string), "applink.feishu.cn", "/client/chat/open", map[string]string{ + "openChatId": "oc_1", + "position": "12", + }) +} + +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) + assertURLHasQuery(t, got["message_app_link"].(string), "applink.larksuite.com", "/client/thread/open", map[string]string{ + "openthreadid": "omt_1", + "openchatid": "oc_1", + "open_thread_id": "omt_1", + "open_chat_id": "oc_1", + "thread_position": "9", + }) +} + +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) + assertURLHasQuery(t, got["message_app_link"].(string), "applink.feishu.cn", "/client/chat/open", map[string]string{ + "openChatId": "oc_1", + "position": "12", + }) +} + +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) + assertURLHasQuery(t, got["message_app_link"].(string), "applink.feishu.cn", "/client/chat/open", map[string]string{ + "openChatId": "oc_1", + "position": "12", + }) +} + +func TestNormalizeMessagePosition_TypedIntsAndUints(t *testing.T) { + if got, ok := normalizeMessagePosition(int32(-3)); !ok || got != "-3" { + t.Fatalf("normalizeMessagePosition(int32(-3)) = (%q,%v)", got, ok) + } + if got, ok := normalizeMessagePosition(uint64(9)); !ok || got != "9" { + t.Fatalf("normalizeMessagePosition(uint64(9)) = (%q,%v)", got, ok) + } +} + +func TestNormalizeMessagePosition_CoversMoreNumericTypesAndInvalidInputs(t *testing.T) { + // ints + if got, ok := normalizeMessagePosition(int8(-1)); !ok || got != "-1" { + t.Fatalf("normalizeMessagePosition(int8(-1)) = (%q,%v)", got, ok) + } + if got, ok := normalizeMessagePosition(int16(2)); !ok || got != "2" { + t.Fatalf("normalizeMessagePosition(int16(2)) = (%q,%v)", got, ok) + } + + // uints + if got, ok := normalizeMessagePosition(uint(3)); !ok || got != "3" { + t.Fatalf("normalizeMessagePosition(uint(3)) = (%q,%v)", got, ok) + } + if got, ok := normalizeMessagePosition(uintptr(4)); !ok || got != "4" { + t.Fatalf("normalizeMessagePosition(uintptr(4)) = (%q,%v)", got, ok) + } + + // float32 + if got, ok := normalizeMessagePosition(float32(1)); !ok || got != "1" { + t.Fatalf("normalizeMessagePosition(float32(1)) = (%q,%v)", got, ok) + } + if got, ok := normalizeMessagePosition(float64(1.5)); !ok || got != "1.5" { + t.Fatalf("normalizeMessagePosition(float64(1.5)) = (%q,%v)", got, ok) + } + if got, ok := normalizeMessagePosition(float64(-1.5)); !ok || got != "-1.5" { + t.Fatalf("normalizeMessagePosition(float64(-1.5)) = (%q,%v)", got, ok) + } + if got, ok := normalizeMessagePosition(float32(math.NaN())); ok || got != "" { + t.Fatalf("normalizeMessagePosition(float32(NaN)) = (%q,%v), want ('',false)", got, ok) + } + if got, ok := normalizeMessagePosition(float32(math.Inf(1))); ok || got != "" { + t.Fatalf("normalizeMessagePosition(float32(+Inf)) = (%q,%v), want ('',false)", got, ok) + } + + // json.Number invalid + if got, ok := normalizeMessagePosition(json.Number("1.5")); !ok || got != "1.5" { + t.Fatalf("normalizeMessagePosition(json.Number(1.5)) = (%q,%v)", got, ok) + } + if got, ok := normalizeMessagePosition(json.Number("bad")); ok || got != "" { + t.Fatalf("normalizeMessagePosition(json.Number(bad)) = (%q,%v), want ('',false)", got, ok) + } + if got, ok := normalizeMessagePosition(json.Number("1e309")); ok || got != "" { + t.Fatalf("normalizeMessagePosition(json.Number(1e309)) = (%q,%v), want ('',false)", got, ok) + } + + // string invalid + if got, ok := normalizeMessagePosition(" 1.5 "); !ok || got != "1.5" { + t.Fatalf("normalizeMessagePosition(\" 1.5 \") = (%q,%v)", got, ok) + } + if got, ok := normalizeMessagePosition(" "); ok || got != "" { + t.Fatalf("normalizeMessagePosition(blank) = (%q,%v), want ('',false)", got, ok) + } + if got, ok := normalizeMessagePosition("not-a-number"); ok || got != "" { + t.Fatalf("normalizeMessagePosition(not-a-number) = (%q,%v), want ('',false)", got, ok) + } + + // reflect fallback: pointers + i := int32(7) + if got, ok := normalizeMessagePosition(&i); !ok || got != "7" { + t.Fatalf("normalizeMessagePosition(*int32(7)) = (%q,%v)", got, ok) + } + u := uint64(8) + if got, ok := normalizeMessagePosition(&u); !ok || got != "8" { + t.Fatalf("normalizeMessagePosition(*uint64(8)) = (%q,%v)", got, ok) + } + f := float64(2.25) + if got, ok := normalizeMessagePosition(&f); !ok || got != "2.25" { + t.Fatalf("normalizeMessagePosition(*float64(2.25)) = (%q,%v)", got, ok) + } + fNaN := float64(math.NaN()) + if got, ok := normalizeMessagePosition(&fNaN); ok || got != "" { + t.Fatalf("normalizeMessagePosition(*float64(NaN)) = (%q,%v), want ('',false)", got, ok) + } + var nilPtr *int + if got, ok := normalizeMessagePosition(nilPtr); ok || got != "" { + t.Fatalf("normalizeMessagePosition(nil ptr) = (%q,%v), want ('',false)", got, ok) + } + if got, ok := normalizeMessagePosition(struct{}{}); ok || got != "" { + t.Fatalf("normalizeMessagePosition(struct{}) = (%q,%v), want ('',false)", got, ok) + } +} + +func TestAssembleMessageAppLink_EncodesQueryValues(t *testing.T) { + // chat link encoding + chat := map[string]interface{}{ + "chat_id": "oc_1+2/3", + "message_position": 12, + } + gotChat := assembleMessageAppLink(chat, core.BrandFeishu) + assertURLHasQuery(t, gotChat, "applink.feishu.cn", "/client/chat/open", map[string]string{ + "openChatId": "oc_1+2/3", + "position": "12", + }) + + // thread link encoding + thread := map[string]interface{}{ + "chat_id": "oc_1+2/3", + "thread_id": "omt_1+2/3", + "thread_message_position": -1, + } + gotThread := assembleMessageAppLink(thread, core.BrandFeishu) + assertURLHasQuery(t, gotThread, "applink.feishu.cn", "/client/thread/open", map[string]string{ + "open_thread_id": "omt_1+2/3", + "open_chat_id": "oc_1+2/3", + "openthreadid": "omt_1+2/3", + "openchatid": "oc_1+2/3", + "thread_position": "-1", + }) +} + +func TestFormatMessageItem_MessageAppLink_NonStringDoesNotLeakNull(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": nil, + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + + got := FormatMessageItem(raw, runtime) + // Should assemble instead of emitting JSON null. + assertURLHasQuery(t, got["message_app_link"].(string), "applink.feishu.cn", "/client/chat/open", map[string]string{ + "openChatId": "oc_1", + "position": "12", + }) +} + +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)