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
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
}

// Build admin console URL with the recommended scope
host := "open.feishu.cn"
host := "open.feishu-pre.cn"
if cfg.Brand == "lark" {
host = "open.larksuite.com"
}
Expand Down
6 changes: 3 additions & 3 deletions internal/core/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
}
default:
return Endpoints{
Open: "https://open.feishu.cn",
Accounts: "https://accounts.feishu.cn",
MCP: "https://mcp.feishu.cn",
Open: "https://open.feishu-pre.cn",
Accounts: "https://accounts.feishu-pre.cn",
MCP: "https://mcp.feishu-pre.cn",
Comment on lines +43 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether endpoint-resolution behavior is covered by tests.
rg -n --type=go -C3 'Test.*ResolveEndpoints|ResolveEndpoints\(|ResolveOpenBaseURL\('

Repository: larksuite/cli

Length of output: 12646


🏁 Script executed:

sed -n '34,39p' internal/core/types_test.go

Repository: larksuite/cli

Length of output: 261


🏁 Script executed:

sed -n '32,48p' internal/core/types.go

Repository: larksuite/cli

Length of output: 527


Update endpoint tests to verify the default domain change to feishu-pre.cn.

The default endpoint for non-Lark brands changed from https://open.feishu.cn to https://open.feishu-pre.cn, but the test TestResolveEndpoints_EmptyDefaultsToFeishu in internal/core/types_test.go still expects the old .cn domain. Update the test assertions to expect .feishu-pre.cn for the default case.

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

In `@internal/core/types.go` around lines 43 - 45, Update the test
TestResolveEndpoints_EmptyDefaultsToFeishu in internal/core/types_test.go to
expect the new default domain "feishu-pre.cn" instead of "feishu.cn";
specifically change assertions that compare resolved endpoints (Open, Accounts,
MCP) to expect "https://open.feishu-pre.cn", "https://accounts.feishu-pre.cn",
and "https://mcp.feishu-pre.cn" so they match the defaults defined for Open,
Accounts, and MCP in the types (the resolved endpoints used by
ResolveEndpoints/ResolveBrandDefaults logic).

}
}
}
Expand Down
6 changes: 5 additions & 1 deletion skills/lark-im/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
### messages

- `delete` — 撤回消息。Identity: supports `user` and `bot`; for `bot` calls, the bot must be in the chat to revoke group messages; to revoke another user's group message, the bot must be the owner, an admin, or the creator; for user P2P recalls, the target user must be within the bot's availability.
- `forward` — 转发消息。Identity: `bot` only (`tenant_access_token`).
- `forward` — 转发消息。Identity: supports `user` and `bot`.
- `merge_forward` — 合并转发消息。Identity: `bot` only (`tenant_access_token`).
- `read_users` — 查询消息已读信息。Identity: `bot` only (`tenant_access_token`); the bot must be in the chat, and can only query read status for messages it sent within the last 7 days.

Expand All @@ -105,6 +105,10 @@ lark-cli im <resource> <method> [flags] # 调用 API
- `delete` — 删除消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message, and can only delete reactions added by itself.[Must-read](references/lark-im-reactions.md)
- `list` — 获取消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)

### threads

- `forward` — 转发话题。Identity: supports `user` and `bot`.
Comment on lines +108 to +110
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Complete forward scope mapping in the permission table.

threads.forward is documented under API resources, but there is no corresponding permission row. Also, forward mapping in this PR includes im:message.send_as_user, which is not reflected in the table. Please add/align these entries so operators configure scopes correctly.

Suggested doc update shape
 | `messages.forward` | `im:message` |
+| `threads.forward` | `im:message` |

If your actual auth mapping differs by identity, document that explicitly (e.g., user path with im:message.send_as_user) in the same table row notes.

Also applies to: 124-147

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

In `@skills/lark-im/SKILL.md` around lines 108 - 110, The permission table is
missing a row for threads.forward and its scope mapping is inconsistent with the
API doc: add a permission entry for "threads.forward" in the table and include
the exact scope(s) used (e.g., im:message.send_as_user) in the same row's scopes
column or notes; if mappings differ by identity (user vs bot), state those
differences in the row notes (for example: "user: im:message.send_as_user; bot:
im:message.send") so the table aligns with the API doc and the PR's forward
mapping.


### images

- `create` — 上传图片。Identity: `bot` only (`tenant_access_token`).
Expand Down
10 changes: 6 additions & 4 deletions tests/cli_e2e/im/coverage.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# IM CLI E2E Coverage

## Metrics
- Denominator: 29 leaf commands
- Covered: 9
- Coverage: 31.0%
- Denominator: 30 leaf commands
- Covered: 11
- Coverage: 36.7%

## Summary
- TestIM_ChatUpdateWorkflow: proves `im +chat-create`, `im +chat-update`, and `im chats get`; key `t.Run(...)` proof points are `update chat name as bot`, `update chat description as bot`, and `get updated chat as bot`.
Expand All @@ -12,6 +12,7 @@
- TestIM_ChatMessageWorkflowAsUser: proves the user chat message flow through `create chat as user`, `send message as user`, and `list chat messages as user` with the created message ID and content asserted from read-after-write output.
- TestIM_MessageGetWorkflowAsUser: proves user message readback through `batch get message as user` after creating a fresh chat and sending a unique message.
- TestIM_MessageReplyWorkflowAsBot: proves threaded reply flow through `reply to message in thread as bot` and `list thread replies as bot`, reading back the reply from `im +threads-messages-list`.
- TestIM_MessageForwardWorkflowAsUser: proves UAT-backed API forwarding through `im messages forward` and `im threads forward` using a fresh message/thread fixture; skips the forward assertions when the current test app/UAT lacks IM forward permission.
- Blocked area: `im +chat-search` did not reliably return freshly created private chats in UAT, and `im +messages-search` did not reliably index freshly sent messages in time for a deterministic read-after-write assertion, so both remain uncovered.

## Command Table
Expand All @@ -37,9 +38,10 @@
| ✕ | im chats update | api | | none | only covered indirectly through `+chat-update` |
| ✕ | im images create | api | | none | no image upload workflow yet |
| ✕ | im messages delete | api | | none | no recall workflow yet |
| | im messages forward | api | | none | no forward workflow yet |
| | im messages forward | api | im/message_forward_workflow_test.go::TestIM_MessageForwardWorkflowAsUser/forward message with api command as user | `message_id`; `receive_id_type`; `uuid`; `receive_id` | forwards a fresh message back into the test chat using UAT |
| ✕ | im messages merge_forward | api | | none | no merge-forward workflow yet |
| ✕ | im messages read_users | api | | none | no read-user workflow yet |
| ✓ | im threads forward | api | im/message_forward_workflow_test.go::TestIM_MessageForwardWorkflowAsUser/forward thread with api command as user | `thread_id`; `receive_id_type`; `uuid`; `receive_id` | forwards a fresh thread back into the test chat using UAT |
| ✕ | im pins create | api | | none | pin workflows not covered |
| ✕ | im pins delete | api | | none | pin workflows not covered |
| ✕ | im pins list | api | | none | pin workflows not covered |
Expand Down
184 changes: 184 additions & 0 deletions tests/cli_e2e/im/message_forward_workflow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package im

import (
"context"
"strings"
"testing"
"time"

clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)

func TestIM_MessageForwardWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)

clie2e.SkipWithoutUserToken(t)

suffix := clie2e.GenerateSuffix()
messageText := "im-forward-msg-" + suffix
replyText := "im-forward-reply-" + suffix

selfOpenID := getSelfOpenID(t, ctx)
chatID, messageID := sendDirectMessageToUser(t, ctx, selfOpenID, messageText, "bot")

Comment on lines +21 to +29
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add a bot-token precheck for bot-based fixture steps.

This test uses bot identity for setup (sendDirectMessageToUser(..., "bot") and im +messages-reply) but only calls clie2e.SkipWithoutUserToken(t). In user-only envs this will fail instead of skipping cleanly.

Suggested patch
 func TestIM_MessageForwardWorkflowAsUser(t *testing.T) {
 	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
 	t.Cleanup(cancel)

 	clie2e.SkipWithoutUserToken(t)
+	clie2e.SkipWithoutBotToken(t)

Also applies to: 55-63

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

In `@tests/cli_e2e/im/message_forward_workflow_test.go` around lines 21 - 29, This
test performs bot-based setup (calls sendDirectMessageToUser(..., "bot") and
later uses the im messages-reply flow) but only calls
clie2e.SkipWithoutUserToken(t); update the test to guard bot-specific steps by
calling clie2e.SkipWithoutBotToken(t) (or a combined bot-or-user skip helper)
before invoking sendDirectMessageToUser and before the im +messages-reply
section so the test will be skipped cleanly in user-only environments; apply the
same change to the second bot-setup block referenced (the section around
sendDirectMessageToUser/im +messages-reply at lines 55-63).

t.Run("forward message with api command as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "messages", "forward"},
DefaultAs: "user",
Params: map[string]any{
"message_id": messageID,
"receive_id_type": "chat_id",
"uuid": "msg-forward-" + suffix,
},
Data: map[string]any{
"receive_id": chatID,
},
})
require.NoError(t, err)
skipIfMissingIMForwardPermission(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)

forwardedID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, forwardedID, "stdout:\n%s", result.Stdout)
require.NotEqual(t, messageID, forwardedID, "stdout:\n%s", result.Stdout)
require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
})

var threadID string
t.Run("create thread fixture as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", messageID,
"--text", replyText,
"--reply-in-thread",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)

threadID = findThreadIDForMessage(t, ctx, chatID, messageID, "bot")
})

t.Run("forward thread with api command as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "threads", "forward"},
DefaultAs: "user",
Params: map[string]any{
"thread_id": threadID,
"receive_id_type": "chat_id",
"uuid": "thread-forward-" + suffix,
},
Data: map[string]any{
"receive_id": chatID,
},
})
require.NoError(t, err)
skipIfMissingIMForwardPermission(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)

forwardedID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, forwardedID, "stdout:\n%s", result.Stdout)
require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, "merge_forward", gjson.Get(result.Stdout, "data.msg_type").String(), "stdout:\n%s", result.Stdout)
})
}
Comment on lines +17 to +94
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add fixture cleanup to keep this live E2E test self-contained.

The workflow creates source/reply/forwarded messages but does not include cleanup, so test artifacts are left behind in the target chat.

As per coding guidelines: Live E2E tests must be self-contained with create/use/cleanup flows and placed in tests/cli_e2e/<domain>/.

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

In `@tests/cli_e2e/im/message_forward_workflow_test.go` around lines 17 - 94,
TestIM_MessageForwardWorkflowAsUser leaves created messages behind; capture the
created IDs (the initial messageID from sendDirectMessageToUser, the threadID
from findThreadIDForMessage, and the forwardedIDs parsed from result.Stdout in
both forward steps) and register cleanup via t.Cleanup to delete them after the
test completes. Specifically, after obtaining messageID, threadID and each
forwardedID (variables messageID, threadID, forwardedID), call the same CLI/API
delete operations used elsewhere (e.g., the "im messages delete" / thread delete
endpoint via clie2e.RunCmd) inside t.Cleanup so removal always runs even on
failures; ensure you handle empty IDs and ignore not-found errors.


func findThreadIDForMessage(t *testing.T, ctx context.Context, chatID string, messageID string, defaultAs string) string {
t.Helper()

listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+chat-messages-list",
"--chat-id", chatID,
"--start", time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339),
"--end", time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339),
},
DefaultAs: defaultAs,
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID && item.Get("thread_id").String() != "" {
return false
}
}
return true
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)

for _, item := range gjson.Get(listResult.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID {
threadID := item.Get("thread_id").String()
require.NotEmpty(t, threadID, "expected thread_id for message %s in stdout:\n%s", messageID, listResult.Stdout)
return threadID
}
}

t.Fatalf("expected message %s in stdout:\n%s", messageID, listResult.Stdout)
return ""
}

func getSelfOpenID(t *testing.T, ctx context.Context) string {
t.Helper()

result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+get-user"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)

openID := gjson.Get(result.Stdout, "data.user.open_id").String()
require.NotEmpty(t, openID, "stdout:\n%s", result.Stdout)
return openID
}

func sendDirectMessageToUser(t *testing.T, ctx context.Context, userOpenID string, text string, defaultAs string) (string, string) {
t.Helper()

result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-send",
"--user-id", userOpenID,
"--text", text,
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)

chatID := gjson.Get(result.Stdout, "data.chat_id").String()
messageID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, chatID, "stdout:\n%s", result.Stdout)
require.NotEmpty(t, messageID, "stdout:\n%s", result.Stdout)
return chatID, messageID
}

func skipIfMissingIMForwardPermission(t *testing.T, result *clie2e.Result) {
t.Helper()
if result == nil || result.ExitCode == 0 {
return
}
stderrLower := strings.ToLower(result.Stderr)
if strings.Contains(stderrLower, "permission denied") ||
strings.Contains(stderrLower, "230027") ||
strings.Contains(stderrLower, "missing_scope") {
t.Skipf("skip UAT forward workflow due to missing IM forward permissions: %s", result.Stderr)
}
}
Loading