Skip to content

feat(im): add message_app_link to message outputs#668

Open
sammi-bytedance wants to merge 4 commits intolarksuite:mainfrom
sammi-bytedance:feat/im-message-applink
Open

feat(im): add message_app_link to message outputs#668
sammi-bytedance wants to merge 4 commits intolarksuite:mainfrom
sammi-bytedance:feat/im-message-applink

Conversation

@sammi-bytedance
Copy link
Copy Markdown
Contributor

@sammi-bytedance sammi-bytedance commented Apr 27, 2026

Summary

IM message-related shortcuts now return a deterministic deep link (message_app_link) assembled from message fields when the server does not provide one.

Changes

  • Assemble message_app_link for chat and thread messages in shortcuts/im/convert_lib/FormatMessageItem.
  • Preserve/forward chat_id, message_position, thread_message_position, and existing message_app_link fields.
  • Allow message_position / thread_message_position to be 0 or negative (as returned by IM APIs) when assembling the link.
  • Add IM E2E assertions for +messages-mget, +chat-messages-list, and +threads-messages-list.

Test Plan

  • go test ./shortcuts/im/convert_lib -count=1
  • go test -v ./tests/cli_e2e/im -run TestIM_MessageReplyWorkflowAsBot -count=1
  • go test -v ./tests/cli_e2e/im -run 'TestIM_(MessageGetWorkflowAsUser|ChatMessageWorkflowAsUser)$' -count=1 (skips without user token)

Related Issues

  • None

Adds a convertlib-level resolver that maps brand to open-domain via ResolveEndpoints.

Change-Id: I91fb000e6a04f82da08ccbb3ad80bff80fbf2e34
Change-Id: I67aac51e6f753201cef0ea0c5d96720dcec4040e
- Allow message_position/thread_message_position to be 0/negative as returned by IM APIs
- Default unknown brand to feishu endpoints via ResolveEndpoints

Change-Id: Ib532aeb1257d4bb138249e706e1d6750938f3775
Validate message_app_link structure for +messages-mget/+chat-messages-list and +threads-messages-list outputs.

Change-Id: I58a6f437db4e5314e2dbae5783f925293336fe79
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

The changes add support for preserving and constructing message_app_link fields in IM message formatting. When the app link is missing, the system deterministically constructs it using message/thread identifiers and normalized position values, with helper functions for resolving brand-specific hosts and assembling URLs.

Changes

Cohort / File(s) Summary
Core Implementation
shortcuts/im/convert_lib/content_convert.go
Adds logic to preserve additional API fields (chat_id, message_position, thread_message_position, message_app_link) and construct message_app_link when missing. Introduces helper functions for host resolution, position normalization across multiple input types, and deterministic URL assembly for /client/thread/open or /client/chat/open endpoints.
Implementation Tests
shortcuts/im/convert_lib/content_media_misc_test.go
Extends test coverage for app-link domain resolution, link preservation, link assembly for chat/thread contexts, fallback behavior, brand handling, and position normalization with zero and negative values.
E2E Test Infrastructure
tests/cli_e2e/im/helpers_test.go
Adds helper utilities that validate message_app_link fields by parsing URLs, asserting HTTPS scheme and matching expected open domain, verifying endpoint paths, and validating query parameters against provided identifiers. Includes environment-driven domain selection using LARKSUITE_CLI_BRAND.
E2E Test Workflows
tests/cli_e2e/im/chat_message_workflow_test.go, tests/cli_e2e/im/message_get_workflow_test.go, tests/cli_e2e/im/message_reply_workflow_test.go
Updates test assertions to validate message_app_link presence and format alongside existing message content checks. Extracts and validates app links with their associated chat/thread IDs and positions for both chat and thread message contexts.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A link for each message, now crafted with care,
When missing, we build it from IDs in the air,
Through threads and through chats, the URLs bloom,
Brand-aware helpers light up the room! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly matches the main change: adding message_app_link field to message outputs across IM shortcuts.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description follows the required template with all key sections completed: Summary clearly states the motivation, Changes lists the main modifications, Test Plan documents verification steps, and Related Issues is addressed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added domain/im PR touches the im domain size/M Single-domain feat or fix with limited business impact labels Apr 27, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (6)
shortcuts/im/convert_lib/content_convert.go (4)

197-216: Consider URL-escaping the path/query values when assembling message_app_link.

chatID, threadID, and the normalized position strings are interpolated directly into the URL via fmt.Sprintf without URL-encoding. While IM IDs (oc_*, omt_*) and numeric positions are typically URL-safe in practice, building URLs via net/url (e.g., url.URL{Scheme, Host, Path} plus url.Values) would be more defensive against unexpected characters and matches Go idioms for URL construction. Note the produced query parameter casing differs intentionally between the chat (openChatId/position) and thread (open_thread_id/open_chat_id/thread_position) endpoints, so a url.Values map preserves that.

♻️ Optional refactor using net/url
 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 ""
+	u := url.URL{Scheme: "https", Host: domain}
+	q := url.Values{}
+	switch {
+	case threadID != "" && chatID != "" && okThreadPos:
+		u.Path = "/client/thread/open"
+		q.Set("open_thread_id", threadID)
+		q.Set("open_chat_id", chatID)
+		q.Set("thread_position", threadPos)
+	case chatID != "" && okMsgPos:
+		u.Path = "/client/chat/open"
+		q.Set("openChatId", chatID)
+		q.Set("position", msgPos)
+	default:
+		return ""
+	}
+	u.RawQuery = q.Encode()
+	return u.String()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/im/convert_lib/content_convert.go` around lines 197 - 216, The
assembleMessageAppLink function interpolates chatID, threadID, msgPos and
threadPos directly into the URL which can risk unescaped characters; change it
to build the link with net/url (use url.URL{Scheme:"https", Host: domain, Path:
...} and url.Values for query params) and set the query parameters with the
exact casing used currently ("open_thread_id","open_chat_id","thread_position"
for thread links and "openChatId","position" for chat links) so values are
URL-encoded; update the thread and chat branches in assembleMessageAppLink to
construct and return url.String() rather than using fmt.Sprintf.

263-270: resolveAppLinkDomain swallows empty Host from a parsed URL.

url.Parse returns no error for many malformed inputs (e.g., an empty string, "feishu.cn" with no scheme), in which case u.Host may be empty and the assembler will quietly return "". This is fine if core.ResolveEndpoints(brand).Open is guaranteed to be a well-formed https://host[/...] URL for both BrandFeishu/BrandLark and the unknown-brand fallback (the unit tests confirm this for feishu/lark/other). Worth a brief comment documenting the contract, or a fallback to a literal default (e.g., open.feishu.cn) on empty Host, so a future endpoint config regression doesn't silently disable message_app_link everywhere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/im/convert_lib/content_convert.go` around lines 263 - 270,
resolveAppLinkDomain currently returns u.Host even when url.Parse succeeds but
yields an empty Host (e.g., no scheme), which can silently break
message_app_link; update resolveAppLinkDomain to check if u == nil or u.Host is
empty after parsing core.ResolveEndpoints(brand).Open and, if empty, return a
safe fallback host (for example "open.feishu.cn") or derive a sensible default
based on brand, and add a brief comment documenting the expectation that
ResolveEndpoints(...).Open should be a full https:// URL; reference the
resolveAppLinkDomain function and core.ResolveEndpoints(brand).Open when making
the change.

218-261: normalizeMessagePosition doesn't cover int32/uint* numeric types.

The type switch handles float64, int, int64, json.Number, and string, but not int32, uint, uint32, uint64. JSON unmarshalling into map[string]interface{} uses float64 (or json.Number when UseNumber is set), so this is unlikely to manifest in the current call path. However, if a caller ever feeds in a value from a typed struct or a different decoder, those numeric kinds will silently fall through to the default branch and the link won't be assembled. Consider adding the additional integer cases (or use reflect) for resilience.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/im/convert_lib/content_convert.go` around lines 218 - 261, The
normalizeMessagePosition function's type switch misses other integer kinds
(e.g., int32, uint, uint32, uint64), causing them to hit the default false path;
update the type switch inside normalizeMessagePosition to add cases for
int8/int16/int32, uint/uint8/uint16/uint32/uint64 (and uintptr if desired) and
convert those to a canonical integer representation (int64 or uint64) then
format with strconv.FormatInt/FormatUint (or convert unsigned to signed only
when safe) so they return the same string,true behavior as int/int64;
alternatively, replace the type-switch branch for numeric types with a
reflect-based check that accepts all integer kinds and floats and normalizes
them the same way.

156-177: Subtle interaction between field preservation and link assembly.

Lines 166-168 unconditionally copy raw["message_app_link"] (any underlying type) into msg, then lines 171-177 may overwrite it only when the type-assertion to string yields an empty string. If the server ever returns message_app_link as a non-string (unlikely but defensible), the preserved non-string value will be silently replaced by an assembled string, which is inconsistent with the intended “pass-through if server provided one” semantics.

Consider gating preservation through the same string type-assertion used by the assembler, so the two branches stay aligned:

♻️ Tighter pass-through gate
-	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
-			}
-		}
-	}
+	rawAppLink, _ := m["message_app_link"].(string)
+	if rawAppLink != "" {
+		msg["message_app_link"] = rawAppLink
+	} else if runtime != nil && runtime.Config != nil {
+		if assembled := assembleMessageAppLink(m, runtime.Config.Brand); assembled != "" {
+			msg["message_app_link"] = assembled
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/im/convert_lib/content_convert.go` around lines 156 - 177, The
current code copies m["message_app_link"] into msg unconditionally which can
conflict with assembleMessageAppLink; change the preservation to the same string
type-assertion/empty check used later: when handling the "message_app_link"
field, only copy it into msg if you can assert it to a non-empty string (use the
same rawAppLink, _ := m["message_app_link"].(string) pattern), otherwise leave
it unset so assembleMessageAppLink(m, runtime.Config.Brand) can
deterministically populate it; update the block that currently references
m["message_app_link"], msg, runtime, and assembleMessageAppLink to follow this
gated behavior.
shortcuts/im/convert_lib/content_media_misc_test.go (1)

66-212: Solid coverage for the new message_app_link and normalizeMessagePosition paths.

The added tests exercise pass-through, chat/thread assembly, brand fallback, nil-runtime no-op, missing-fields no-op, invalid-thread-position fallback, and zero/negative numeric positions across both numeric and string inputs. Two small optional gaps if you want belt-and-suspenders coverage:

  • json.Number input to normalizeMessagePosition (the case json.Number: branch isn't directly exercised — current tests rely on float64/string).
  • An integer-valued float case (float64(12.0)) is exercised indirectly in AssembleChat, but a direct unit assertion against normalizeMessagePosition for math.Trunc(f) == f formatting could make the contract explicit.

Non-blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/im/convert_lib/content_media_misc_test.go` around lines 66 - 212,
Add two small unit tests to exercise the json.Number and integer-valued float
branches of normalizeMessagePosition: call
normalizeMessagePosition(json.Number("12")) and assert it returns "12", and call
normalizeMessagePosition(float64(12.0)) and assert it returns "12"; place these
alongside the existing TestNormalizeMessagePosition_AllowsZeroAndNegative tests
so the json.Number and the integer float (math.Trunc(f) == f) code paths in
normalizeMessagePosition are covered.
tests/cli_e2e/im/message_reply_workflow_test.go (1)

105-126: Empty-link t.Fatalf is redundant with requireThreadMessageAppLink's non-empty assertion.

requireThreadMessageAppLink already calls require.NotEmpty(t, appLink, ...) first thing, so the if appLink == "" block effectively duplicates that check. The motivation appears to be richer diagnostics (the explicit field dump and item.Raw), which is understandable. If you want to keep both, fine — otherwise consider folding the diagnostic into the helper (e.g., as an additional msgAndArgs payload) so failure reporting lives in one place.

Also, the trailing require.Equal(t, threadID, item.Get("thread_id").String(), ...) on Line 126 is partially covered by the helper (which asserts the URL's open_thread_id equals the passed threadID); it's still a useful end-to-end self-consistency check between the URL and the response field, so leaving it is fine.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/cli_e2e/im/message_reply_workflow_test.go` around lines 105 - 126, The
explicit empty-link t.Fatalf block duplicates the non-empty assertion already
performed by requireThreadMessageAppLink; remove the if appLink == "" {
t.Fatalf(...) } block and rely on requireThreadMessageAppLink(t, appLink,
threadID, chatID, threadMsgPos) instead, or alternatively move the rich
diagnostic payload (item.Raw, threadResult.Stdout and field values) into
requireThreadMessageAppLink so the helper logs those details when it calls
require.NotEmpty; keep the trailing require.Equal(t, threadID,
item.Get("thread_id").String(), ...) as the end-to-end consistency check.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@shortcuts/im/convert_lib/content_convert.go`:
- Around line 197-216: The assembleMessageAppLink function interpolates chatID,
threadID, msgPos and threadPos directly into the URL which can risk unescaped
characters; change it to build the link with net/url (use
url.URL{Scheme:"https", Host: domain, Path: ...} and url.Values for query
params) and set the query parameters with the exact casing used currently
("open_thread_id","open_chat_id","thread_position" for thread links and
"openChatId","position" for chat links) so values are URL-encoded; update the
thread and chat branches in assembleMessageAppLink to construct and return
url.String() rather than using fmt.Sprintf.
- Around line 263-270: resolveAppLinkDomain currently returns u.Host even when
url.Parse succeeds but yields an empty Host (e.g., no scheme), which can
silently break message_app_link; update resolveAppLinkDomain to check if u ==
nil or u.Host is empty after parsing core.ResolveEndpoints(brand).Open and, if
empty, return a safe fallback host (for example "open.feishu.cn") or derive a
sensible default based on brand, and add a brief comment documenting the
expectation that ResolveEndpoints(...).Open should be a full https:// URL;
reference the resolveAppLinkDomain function and
core.ResolveEndpoints(brand).Open when making the change.
- Around line 218-261: The normalizeMessagePosition function's type switch
misses other integer kinds (e.g., int32, uint, uint32, uint64), causing them to
hit the default false path; update the type switch inside
normalizeMessagePosition to add cases for int8/int16/int32,
uint/uint8/uint16/uint32/uint64 (and uintptr if desired) and convert those to a
canonical integer representation (int64 or uint64) then format with
strconv.FormatInt/FormatUint (or convert unsigned to signed only when safe) so
they return the same string,true behavior as int/int64; alternatively, replace
the type-switch branch for numeric types with a reflect-based check that accepts
all integer kinds and floats and normalizes them the same way.
- Around line 156-177: The current code copies m["message_app_link"] into msg
unconditionally which can conflict with assembleMessageAppLink; change the
preservation to the same string type-assertion/empty check used later: when
handling the "message_app_link" field, only copy it into msg if you can assert
it to a non-empty string (use the same rawAppLink, _ :=
m["message_app_link"].(string) pattern), otherwise leave it unset so
assembleMessageAppLink(m, runtime.Config.Brand) can deterministically populate
it; update the block that currently references m["message_app_link"], msg,
runtime, and assembleMessageAppLink to follow this gated behavior.

In `@shortcuts/im/convert_lib/content_media_misc_test.go`:
- Around line 66-212: Add two small unit tests to exercise the json.Number and
integer-valued float branches of normalizeMessagePosition: call
normalizeMessagePosition(json.Number("12")) and assert it returns "12", and call
normalizeMessagePosition(float64(12.0)) and assert it returns "12"; place these
alongside the existing TestNormalizeMessagePosition_AllowsZeroAndNegative tests
so the json.Number and the integer float (math.Trunc(f) == f) code paths in
normalizeMessagePosition are covered.

In `@tests/cli_e2e/im/message_reply_workflow_test.go`:
- Around line 105-126: The explicit empty-link t.Fatalf block duplicates the
non-empty assertion already performed by requireThreadMessageAppLink; remove the
if appLink == "" { t.Fatalf(...) } block and rely on
requireThreadMessageAppLink(t, appLink, threadID, chatID, threadMsgPos) instead,
or alternatively move the rich diagnostic payload (item.Raw, threadResult.Stdout
and field values) into requireThreadMessageAppLink so the helper logs those
details when it calls require.NotEmpty; keep the trailing require.Equal(t,
threadID, item.Get("thread_id").String(), ...) as the end-to-end consistency
check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f304490c-57c2-470b-8fb6-9450ca8f87ee

📥 Commits

Reviewing files that changed from the base of the PR and between 1e2144e and 5ed0570.

📒 Files selected for processing (6)
  • shortcuts/im/convert_lib/content_convert.go
  • shortcuts/im/convert_lib/content_media_misc_test.go
  • tests/cli_e2e/im/chat_message_workflow_test.go
  • tests/cli_e2e/im/helpers_test.go
  • tests/cli_e2e/im/message_get_workflow_test.go
  • tests/cli_e2e/im/message_reply_workflow_test.go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain/im PR touches the im domain size/M Single-domain feat or fix with limited business impact

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant