Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/core/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions internal/core/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
103 changes: 103 additions & 0 deletions shortcuts/im/convert_lib/content_convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
149 changes: 149 additions & 0 deletions shortcuts/im/convert_lib/content_media_misc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package convertlib
import (
"testing"

"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
)

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions tests/cli_e2e/im/chat_message_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading