Skip to content
Merged
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
7 changes: 6 additions & 1 deletion skills/lark-im/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,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 @@ -120,6 +120,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 thread
coderabbitai[bot] marked this conversation as resolved.

### images

- `create` — 上传图片。Identity: `bot` only (`tenant_access_token`).
Expand Down Expand Up @@ -147,6 +151,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `messages.forward` | `im:message` |
| `messages.merge_forward` | `im:message` |
| `messages.read_users` | `im:message:readonly` |
| `threads.forward` | `im:message` |
| `reactions.batch_query` | `im:message.reactions:read` |
| `reactions.create` | `im:message.reactions:write_only` |
| `reactions.delete` | `im:message.reactions:write_only` |
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 thread
chenxingtong-bytedance marked this conversation as resolved.
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 thread
chenxingtong-bytedance marked this conversation as resolved.

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