diff --git a/cmd/root.go b/cmd/root.go index 06d3f7eed..618dd7daa 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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" } diff --git a/internal/core/types.go b/internal/core/types.go index bae8613ae..0c8f9814b 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -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", } } } diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 7388067fc..925511b54 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -94,7 +94,7 @@ lark-cli im [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. @@ -105,6 +105,10 @@ lark-cli im [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`. + ### images - `create` — 上传图片。Identity: `bot` only (`tenant_access_token`). diff --git a/tests/cli_e2e/im/coverage.md b/tests/cli_e2e/im/coverage.md index b5610d905..88a353f25 100644 --- a/tests/cli_e2e/im/coverage.md +++ b/tests/cli_e2e/im/coverage.md @@ -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`. @@ -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 @@ -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 | diff --git a/tests/cli_e2e/im/message_forward_workflow_test.go b/tests/cli_e2e/im/message_forward_workflow_test.go new file mode 100644 index 000000000..73d53889d --- /dev/null +++ b/tests/cli_e2e/im/message_forward_workflow_test.go @@ -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") + + 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) + }) +} + +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) + } +}