From 7e5b06f09638e53d885f445900467c5dc1397ad2 Mon Sep 17 00:00:00 2001 From: David-Buxy <239541535+David-Buxy@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:31:56 +0800 Subject: [PATCH] feat: add IM message update shortcut --- shortcuts/im/builders_test.go | 40 ++++ shortcuts/im/helpers_test.go | 2 + shortcuts/im/im_messages_card_update.go | 76 ++++++++ shortcuts/im/im_messages_card_update_test.go | 129 +++++++++++++ shortcuts/im/im_messages_update.go | 139 +++++++++++++ shortcuts/im/im_messages_update_test.go | 182 ++++++++++++++++++ shortcuts/im/shortcuts.go | 2 + skills/lark-im/SKILL.md | 7 +- .../lark-im-messages-card-update.md | 43 +++++ .../references/lark-im-messages-update.md | 39 ++++ .../cli_e2e/im/message_update_dryrun_test.go | 69 +++++++ .../im/message_update_workflow_test.go | 149 ++++++++++++++ 12 files changed, 876 insertions(+), 1 deletion(-) create mode 100644 shortcuts/im/im_messages_card_update.go create mode 100644 shortcuts/im/im_messages_card_update_test.go create mode 100644 shortcuts/im/im_messages_update.go create mode 100644 shortcuts/im/im_messages_update_test.go create mode 100644 skills/lark-im/references/lark-im-messages-card-update.md create mode 100644 skills/lark-im/references/lark-im-messages-update.md create mode 100644 tests/cli_e2e/im/message_update_dryrun_test.go create mode 100644 tests/cli_e2e/im/message_update_workflow_test.go diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go index c32bd6a32..cf7239c48 100644 --- a/shortcuts/im/builders_test.go +++ b/shortcuts/im/builders_test.go @@ -790,6 +790,19 @@ func TestShortcutDryRunShapes(t *testing.T) { } }) + t.Run("ImMessagesCardUpdate dry run uses PATCH card body", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "om_123", + "content": `{"config":{"update_multi":true},"elements":[{"tag":"div","text":{"tag":"plain_text","content":"updated"}}]}`, + }, nil) + got := mustMarshalDryRun(t, ImMessagesCardUpdate.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/messages/om_123"`) || + !strings.Contains(got, `"method":"PATCH"`) || + !strings.Contains(got, `update_multi`) { + t.Fatalf("ImMessagesCardUpdate.DryRun() = %s", got) + } + }) + t.Run("ImThreadsMessagesList dry run keeps requested thread params", func(t *testing.T) { runtime := newTestRuntimeContext(t, map[string]string{ "thread": "omt_123", @@ -829,6 +842,33 @@ func TestShortcutDryRunShapes(t *testing.T) { } }) + t.Run("ImMessagesUpdate dry run resolves message path and body", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "om_123", + "text": "hi ", + }, nil) + got := mustMarshalDryRun(t, ImMessagesUpdate.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/messages/om_123"`) || + !strings.Contains(got, `"method":"PUT"`) || + !strings.Contains(got, `"msg_type":"text"`) || + !strings.Contains(got, `user_id=\\\"ou_1\\\"`) { + t.Fatalf("ImMessagesUpdate.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesUpdate dry run uses markdown image placeholders", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "om_123", + "markdown": "![alt](https://example.com/a.png)", + }, nil) + got := mustMarshalDryRun(t, ImMessagesUpdate.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"description":"dry-run uses placeholder image keys for markdown image URLs; execution downloads and uploads them before sending"`) || + !strings.Contains(got, `"msg_type":"post"`) || + !strings.Contains(got, `img_dryrun_1`) { + t.Fatalf("ImMessagesUpdate.DryRun() = %s", got) + } + }) + t.Run("ImChatMessageList dry run notes p2p resolution", func(t *testing.T) { runtime := newTestRuntimeContext(t, map[string]string{ "user-id": "ou_123", diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 114ae524f..64398b36c 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -711,11 +711,13 @@ func TestShortcuts(t *testing.T) { "+chat-messages-list", "+chat-search", "+chat-update", + "+messages-card-update", "+messages-mget", "+messages-reply", "+messages-resources-download", "+messages-search", "+messages-send", + "+messages-update", "+threads-messages-list", "+flag-create", "+flag-cancel", diff --git a/shortcuts/im/im_messages_card_update.go b/shortcuts/im/im_messages_card_update.go new file mode 100644 index 000000000..6bacdc5a0 --- /dev/null +++ b/shortcuts/im/im_messages_card_update.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var ImMessagesCardUpdate = common.Shortcut{ + Service: "im", + Command: "+messages-card-update", + Description: "Update a sent interactive card message; supports shared cards sent by the same identity", + Risk: "write", + Scopes: []string{"im:message:update"}, + UserScopes: []string{"im:message"}, + BotScopes: []string{"im:message:update"}, + AuthTypes: []string{"bot", "user"}, + Flags: []common.Flag{ + {Name: "message-id", Desc: "interactive card message ID to update (om_xxx)", Required: true}, + {Name: "content", Desc: "interactive card JSON serialized as a string; shared cards must include config.update_multi=true", Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + messageID := runtime.Str("message-id") + content := runtime.Str("content") + + return common.NewDryRunAPI(). + PATCH("/open-apis/im/v1/messages/:message_id"). + Body(map[string]interface{}{"content": content}). + Set("message_id", messageID). + Desc("updates an interactive card message only; the original and replacement shared cards must declare config.update_multi=true") + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + messageID := runtime.Str("message-id") + if messageID == "" { + return output.ErrValidation("--message-id is required (om_xxx)") + } + if _, err := validateMessageID(messageID); err != nil { + return err + } + + content := runtime.Str("content") + if content == "" { + return output.ErrValidation("--content is required and must be interactive card JSON") + } + if !json.Valid([]byte(content)) { + return output.ErrValidation("--content is not valid JSON: %s\nexample: --content '{\"config\":{\"update_multi\":true},\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"plain_text\",\"content\":\"updated\"}}]}'", content) + } + + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + messageID := runtime.Str("message-id") + content := runtime.Str("content") + + if _, err := runtime.DoAPIJSON(http.MethodPatch, + fmt.Sprintf("/open-apis/im/v1/messages/%s", validate.EncodePathSegment(messageID)), + nil, + map[string]interface{}{"content": content}); err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "message_id": messageID, + "updated": true, + }, nil) + return nil + }, +} diff --git a/shortcuts/im/im_messages_card_update_test.go b/shortcuts/im/im_messages_card_update_test.go new file mode 100644 index 000000000..25ad493f0 --- /dev/null +++ b/shortcuts/im/im_messages_card_update_test.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestImMessagesCardUpdateValidate(t *testing.T) { + tests := []struct { + name string + flags map[string]string + wantErr string + }{ + { + name: "accepts card content", + flags: map[string]string{ + "message-id": "om_123", + "content": `{"config":{"update_multi":true},"elements":[{"tag":"div","text":{"tag":"plain_text","content":"updated"}}]}`, + }, + }, + { + name: "rejects thread id", + flags: map[string]string{ + "message-id": "omt_123", + "content": `{"config":{"update_multi":true}}`, + }, + wantErr: "must start with om_", + }, + { + name: "rejects missing content", + flags: map[string]string{ + "message-id": "om_123", + }, + wantErr: "--content is required", + }, + { + name: "rejects invalid json", + flags: map[string]string{ + "message-id": "om_123", + "content": `{"config":`, + }, + wantErr: "not valid JSON", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := newCardUpdateTestRuntime(t, tt.flags) + err := ImMessagesCardUpdate.Validate(context.Background(), rt) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate() error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestImMessagesCardUpdateExecute_PatchesCardRequest(t *testing.T) { + ctx := context.Background() + content := `{"config":{"update_multi":true},"elements":[{"tag":"div","text":{"tag":"plain_text","content":"updated"}}]}` + cmd := newCardUpdateTestCommand(t, map[string]string{ + "message-id": "om_123", + "content": content, + }) + cfg := &core.CliConfig{AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu} + f, _, _, reg := cmdutil.TestFactory(t, cfg) + stub := &httpmock.Stub{ + Method: http.MethodPatch, + URL: "/open-apis/im/v1/messages/om_123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + rt := common.TestNewRuntimeContextForAPI(ctx, cmd, cfg, f, core.AsBot) + if err := ImMessagesCardUpdate.Execute(ctx, rt); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("request body invalid JSON: %v\n%s", err, string(stub.CapturedBody)) + } + if body["content"] != content { + t.Fatalf("content = %v, want %s", body["content"], content) + } +} + +func newCardUpdateTestRuntime(t *testing.T, flags map[string]string) *common.RuntimeContext { + t.Helper() + return &common.RuntimeContext{Cmd: newCardUpdateTestCommand(t, flags)} +} + +func newCardUpdateTestCommand(t *testing.T, flags map[string]string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "test"} + for _, flag := range []string{"message-id", "content"} { + cmd.Flags().String(flag, "", "") + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, value := range flags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + return cmd +} diff --git a/shortcuts/im/im_messages_update.go b/shortcuts/im/im_messages_update.go new file mode 100644 index 000000000..1107ae58a --- /dev/null +++ b/shortcuts/im/im_messages_update.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var ImMessagesUpdate = common.Shortcut{ + Service: "im", + Command: "+messages-update", + Description: "Update a sent text or post message; bot-only; edits messages sent by the current app", + Risk: "write", + Scopes: []string{"im:message:update"}, + BotScopes: []string{"im:message:update"}, + AuthTypes: []string{"bot"}, + Flags: []common.Flag{ + {Name: "message-id", Desc: "message ID to update (om_xxx)", Required: true}, + {Name: "msg-type", Default: "text", Desc: "message type for --content JSON; --text forces text and --markdown forces post", Enum: []string{"text", "post"}}, + {Name: "content", Desc: "(one of --content/--text/--markdown required) message content JSON for text or post"}, + {Name: "text", Desc: "plain text message (auto-wrapped as JSON)"}, + {Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + messageID := runtime.Str("message-id") + msgType, content, desc := buildUpdateMessageContentForDryRun(runtime) + + body := map[string]interface{}{"msg_type": msgType, "content": content} + d := common.NewDryRunAPI() + if desc != "" { + d.Desc(desc) + } + return d. + PUT("/open-apis/im/v1/messages/:message_id"). + Body(body). + Set("message_id", messageID) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + messageID := runtime.Str("message-id") + if messageID == "" { + return output.ErrValidation("--message-id is required (om_xxx)") + } + if _, err := validateMessageID(messageID); err != nil { + return err + } + + msgType := runtime.Str("msg-type") + content := runtime.Str("content") + text := runtime.Str("text") + markdown := runtime.Str("markdown") + + set := 0 + for _, v := range []string{content, text, markdown} { + if v != "" { + set++ + } + } + if set != 1 { + return output.ErrValidation("exactly one of --content, --text, or --markdown is required") + } + if content != "" && !json.Valid([]byte(content)) { + return output.ErrValidation("--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content) + } + if msgType != "text" && msgType != "post" { + return output.ErrValidation("--msg-type must be text or post; editing image, file, audio, video, sticker, and interactive card messages is not supported") + } + + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + messageID := runtime.Str("message-id") + msgType, content, err := buildUpdateMessageContent(ctx, runtime) + if err != nil { + return err + } + + resData, err := runtime.DoAPIJSON(http.MethodPut, + fmt.Sprintf("/open-apis/im/v1/messages/%s", validate.EncodePathSegment(messageID)), + nil, + map[string]interface{}{ + "msg_type": msgType, + "content": content, + }) + if err != nil { + return err + } + + message := resData + if nested, ok := resData["message"].(map[string]interface{}); ok { + message = nested + } + runtime.Out(map[string]interface{}{ + "message_id": message["message_id"], + "chat_id": message["chat_id"], + "update_time": common.FormatTimeWithSeconds(message["update_time"]), + "updated": message["updated"], + }, nil) + return nil + }, +} + +func buildUpdateMessageContentForDryRun(runtime *common.RuntimeContext) (msgType, content, desc string) { + if markdown := runtime.Str("markdown"); markdown != "" { + content, desc = wrapMarkdownAsPostForDryRun(normalizeAtMentions(markdown)) + return "post", content, desc + } + if text := runtime.Str("text"); text != "" { + return "text", marshalUpdateTextContent(normalizeAtMentions(text)), "" + } + return runtime.Str("msg-type"), runtime.Str("content"), "" +} + +func buildUpdateMessageContent(ctx context.Context, runtime *common.RuntimeContext) (msgType, content string, err error) { + if markdown := runtime.Str("markdown"); markdown != "" { + return "post", resolveMarkdownAsPost(ctx, runtime, normalizeAtMentions(markdown)), nil + } + if text := runtime.Str("text"); text != "" { + return "text", marshalUpdateTextContent(normalizeAtMentions(text)), nil + } + return runtime.Str("msg-type"), runtime.Str("content"), nil +} + +func marshalUpdateTextContent(text string) string { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + _ = enc.Encode(map[string]string{"text": text}) + return strings.TrimSpace(buf.String()) +} diff --git a/shortcuts/im/im_messages_update_test.go b/shortcuts/im/im_messages_update_test.go new file mode 100644 index 000000000..e3e574f7d --- /dev/null +++ b/shortcuts/im/im_messages_update_test.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestImMessagesUpdateValidate(t *testing.T) { + tests := []struct { + name string + flags map[string]string + wantErr string + }{ + { + name: "accepts text", + flags: map[string]string{ + "message-id": "om_123", + "text": "updated", + "msg-type": "text", + }, + }, + { + name: "rejects thread id", + flags: map[string]string{ + "message-id": "omt_123", + "text": "updated", + "msg-type": "text", + }, + wantErr: "must start with om_", + }, + { + name: "rejects multiple content inputs", + flags: map[string]string{ + "message-id": "om_123", + "text": "updated", + "content": `{"text":"updated"}`, + "msg-type": "text", + }, + wantErr: "exactly one", + }, + { + name: "rejects unsupported message type", + flags: map[string]string{ + "message-id": "om_123", + "content": `{"image_key":"img_123"}`, + "msg-type": "image", + }, + wantErr: "text or post", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := newUpdateMessageTestRuntime(t, tt.flags) + err := ImMessagesUpdate.Validate(context.Background(), rt) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate() error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestImMessagesUpdateExecute_PutsUpdateRequest(t *testing.T) { + ctx := context.Background() + cmd := newUpdateMessageTestCommand(t, map[string]string{ + "message-id": "om_123", + "text": "updated ", + "msg-type": "text", + }) + cfg := &core.CliConfig{AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu} + f, _, _, reg := cmdutil.TestFactory(t, cfg) + stub := &httpmock.Stub{ + Method: http.MethodPut, + URL: "/open-apis/im/v1/messages/om_123", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "message_id": "om_123", + "chat_id": "oc_123", + "update_time": "1710000000000", + "updated": true, + }, + }, + } + reg.Register(stub) + + rt := common.TestNewRuntimeContextForAPI(ctx, cmd, cfg, f, core.AsBot) + if err := ImMessagesUpdate.Execute(ctx, rt); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("request body invalid JSON: %v\n%s", err, string(stub.CapturedBody)) + } + if body["msg_type"] != "text" { + t.Fatalf("msg_type = %v, want text", body["msg_type"]) + } + content, _ := body["content"].(string) + if !strings.Contains(content, ``) { + t.Fatalf("content = %q, want normalized mention", content) + } +} + +func TestImMessagesUpdateExecute_DoesNotRewriteRawContentJSON(t *testing.T) { + ctx := context.Background() + rawContent := `{"zh_cn":{"content":[[{"tag":"text","text":"keep literal"}]]}}` + cmd := newUpdateMessageTestCommand(t, map[string]string{ + "message-id": "om_123", + "content": rawContent, + "msg-type": "post", + }) + cfg := &core.CliConfig{AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu} + f, _, _, reg := cmdutil.TestFactory(t, cfg) + stub := &httpmock.Stub{ + Method: http.MethodPut, + URL: "/open-apis/im/v1/messages/om_123", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "message_id": "om_123", + "updated": true, + }, + }, + } + reg.Register(stub) + + rt := common.TestNewRuntimeContextForAPI(ctx, cmd, cfg, f, core.AsBot) + if err := ImMessagesUpdate.Execute(ctx, rt); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("request body invalid JSON: %v\n%s", err, string(stub.CapturedBody)) + } + if body["content"] != rawContent { + t.Fatalf("content = %v, want raw content unchanged", body["content"]) + } +} + +func newUpdateMessageTestRuntime(t *testing.T, flags map[string]string) *common.RuntimeContext { + t.Helper() + return &common.RuntimeContext{Cmd: newUpdateMessageTestCommand(t, flags)} +} + +func newUpdateMessageTestCommand(t *testing.T, flags map[string]string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "test"} + for _, flag := range []string{"message-id", "msg-type", "content", "text", "markdown"} { + cmd.Flags().String(flag, "", "") + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, value := range flags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + return cmd +} diff --git a/shortcuts/im/shortcuts.go b/shortcuts/im/shortcuts.go index 3c8aadfbe..2a435dde7 100644 --- a/shortcuts/im/shortcuts.go +++ b/shortcuts/im/shortcuts.go @@ -13,11 +13,13 @@ func Shortcuts() []common.Shortcut { ImChatMessageList, ImChatSearch, ImChatUpdate, + ImMessagesCardUpdate, ImMessagesMGet, ImMessagesReply, ImMessagesResourcesDownload, ImMessagesSearch, ImMessagesSend, + ImMessagesUpdate, ImThreadsMessagesList, ImFlagCreate, ImFlagCancel, diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 7ec3b7118..47b83e6d3 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -77,11 +77,13 @@ Shortcut 是对常用操作的高级封装(`lark-cli im + [flags]`)。 | [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination | | [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) | | [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description | +| [`+messages-card-update`](references/lark-im-messages-card-update.md) | Update a sent interactive card message; user/bot; supports shared cards sent by the same identity | | [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies | | [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key | | [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; supports automatic chunked download for large files (8MB chunks), auto-detects file extension from Content-Type | | [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query | | [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key | +| [`+messages-update`](references/lark-im-messages-update.md) | Update a sent text or post message; bot-only; edits messages sent by the current app | | [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination | | [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message or thread; user-only; defaults to message-layer flag; feed-layer flag requires explicit --item-type + --flag-type | | [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, checks if the message is a thread root message; if so, cancels both message and feed layers | @@ -116,6 +118,8 @@ lark-cli im [flags] # 调用 API - `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. + - Message editing is exposed through shortcut `+messages-update`; the current OpenAPI registry does not expose native `messages.update` yet. + - Interactive card message updates are exposed through shortcut `+messages-card-update`; the current OpenAPI registry does not expose native card update yet. - `urgent_app` — 发送应用内加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message. - `urgent_phone` — 发送电话加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message. - `urgent_sms` — 发送短信加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message. @@ -157,6 +161,8 @@ lark-cli im [flags] # 调用 API | `messages.forward` | `im:message` | | `messages.merge_forward` | `im:message` | | `messages.read_users` | `im:message:readonly` | +| `+messages-card-update` | `im:message:update` or `im:message` | +| `+messages-update` | `im:message:update` | | `messages.urgent_app` | `im:message.urgent` | | `messages.urgent_phone` | `im:message.urgent:phone` | | `messages.urgent_sms` | `im:message.urgent:sms` | @@ -169,4 +175,3 @@ lark-cli im [flags] # 调用 API | `pins.create` | `im:message.pins:write_only` | | `pins.delete` | `im:message.pins:write_only` | | `pins.list` | `im:message.pins:read` | - diff --git a/skills/lark-im/references/lark-im-messages-card-update.md b/skills/lark-im/references/lark-im-messages-card-update.md new file mode 100644 index 000000000..3690076c7 --- /dev/null +++ b/skills/lark-im/references/lark-im-messages-card-update.md @@ -0,0 +1,43 @@ +# lark-cli im +messages-card-update + +Update a sent interactive card message. + +Use this when an agent sent a shared interactive card and needs to replace card content, such as fixing a button URL or replacing a progress card with a final card. + +This is different from [`+messages-update`](lark-im-messages-update.md), which edits `text` and `post` messages. + +## Limits + +- Only interactive card messages are supported. +- The original card and replacement card must be shared cards with `config.update_multi=true`. +- The message must not be recalled. +- Feishu platform limits still apply, including the supported update window and exclusions for batch-sent cards. +- The caller identity must match an identity allowed to update the card. Use `--as bot` for app-sent cards and `--as user` for user-sent cards when permitted. + +## Examples + +```bash +lark-cli im +messages-card-update \ + --message-id om_xxx \ + --content '{"config":{"update_multi":true},"elements":[{"tag":"div","text":{"tag":"plain_text","content":"Done"}}]}' \ + --as bot +``` + +To update a button link, send the complete replacement card JSON, not a partial patch: + +```bash +lark-cli im +messages-card-update \ + --message-id om_xxx \ + --content '{"config":{"update_multi":true},"elements":[{"tag":"action","actions":[{"tag":"button","text":{"tag":"plain_text","content":"Open"},"url":"https://example.com/new"}]}]}' \ + --as bot +``` + +Preview the request without executing it: + +```bash +lark-cli im +messages-card-update \ + --message-id om_xxx \ + --content '{"config":{"update_multi":true},"elements":[{"tag":"div","text":{"tag":"plain_text","content":"Preview"}}]}' \ + --as bot \ + --dry-run +``` diff --git a/skills/lark-im/references/lark-im-messages-update.md b/skills/lark-im/references/lark-im-messages-update.md new file mode 100644 index 000000000..655231222 --- /dev/null +++ b/skills/lark-im/references/lark-im-messages-update.md @@ -0,0 +1,39 @@ +# lark-cli im +messages-update + +Update a sent text or post message. + +Use this when an agent sends a placeholder/progress message and later needs to replace it with the final result, without adding another chat message. + +## Limits + +- Bot identity only. +- Only messages sent by the current app can be edited. +- Only `text` and `post` messages are supported. +- Interactive card messages use a different card update API and are not handled by this shortcut. +- Feishu tenant admin settings and platform limits still apply, including edit windows and edit count limits. + +## Examples + +```bash +lark-cli im +messages-update \ + --message-id om_xxx \ + --text "Done: the report is ready" \ + --as bot +``` + +```bash +lark-cli im +messages-update \ + --message-id om_xxx \ + --markdown "**Done**\n\n- item 1\n- item 2" \ + --as bot +``` + +For raw content JSON: + +```bash +lark-cli im +messages-update \ + --message-id om_xxx \ + --msg-type text \ + --content '{"text":"updated text"}' \ + --as bot +``` diff --git a/tests/cli_e2e/im/message_update_dryrun_test.go b/tests/cli_e2e/im/message_update_dryrun_test.go new file mode 100644 index 000000000..734c4e6f4 --- /dev/null +++ b/tests/cli_e2e/im/message_update_dryrun_test.go @@ -0,0 +1,69 @@ +// 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" +) + +func TestIM_MessageUpdateDryRun(t *testing.T) { + setIMDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+messages-update", + "--message-id", "om_dryrun", + "--text", "updated", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + if !strings.Contains(result.Stdout, "/open-apis/im/v1/messages/om_dryrun") || + !strings.Contains(result.Stdout, `"method": "PUT"`) || + !strings.Contains(result.Stdout, `"msg_type": "text"`) { + t.Fatalf("dry-run output missing update request shape:\n%s", result.Stdout) + } +} + +func TestIM_MessageCardUpdateDryRun(t *testing.T) { + setIMDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+messages-card-update", + "--message-id", "om_dryrun", + "--content", `{"config":{"update_multi":true},"elements":[{"tag":"div","text":{"tag":"plain_text","content":"updated"}}]}`, + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + if !strings.Contains(result.Stdout, "/open-apis/im/v1/messages/om_dryrun") || + !strings.Contains(result.Stdout, `"method": "PATCH"`) || + !strings.Contains(result.Stdout, `update_multi`) { + t.Fatalf("dry-run output missing card update request shape:\n%s", result.Stdout) + } +} + +func setIMDryRunConfigEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +} diff --git a/tests/cli_e2e/im/message_update_workflow_test.go b/tests/cli_e2e/im/message_update_workflow_test.go new file mode 100644 index 000000000..7451c590b --- /dev/null +++ b/tests/cli_e2e/im/message_update_workflow_test.go @@ -0,0 +1,149 @@ +// 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_MessageUpdateWorkflowAsBot(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + chatName := "lark-cli-e2e-im-update-" + suffix + originalText := "lark-cli-e2e-update-original-" + suffix + updatedText := "lark-cli-e2e-update-edited-" + suffix + + chatID := createChat(t, parentT, ctx, chatName) + messageID := sendMessage(t, ctx, chatID, originalText) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-update", + "--message-id", messageID, + "--text", updatedText, + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, messageID, gjson.Get(result.Stdout, "data.message_id").String(), "stdout:\n%s", result.Stdout) + require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout) + require.True(t, gjson.Get(result.Stdout, "data.updated").Bool(), "stdout:\n%s", result.Stdout) + + getResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"im", "+messages-mget", + "--message-ids", messageID, + "--no-reactions", + }, + DefaultAs: "bot", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + if result == nil || result.ExitCode != 0 { + return true + } + messages := gjson.Get(result.Stdout, "data.messages").Array() + if len(messages) != 1 { + return true + } + return !strings.Contains(messages[0].Get("content").String(), updatedText) + }, + }) + require.NoError(t, err) + getResult.AssertExitCode(t, 0) + getResult.AssertStdoutStatus(t, true) + + messages := gjson.Get(getResult.Stdout, "data.messages").Array() + require.Len(t, messages, 1, "stdout:\n%s", getResult.Stdout) + require.Equal(t, messageID, messages[0].Get("message_id").String(), "stdout:\n%s", getResult.Stdout) + require.True(t, strings.Contains(messages[0].Get("content").String(), updatedText), "stdout:\n%s", getResult.Stdout) + require.True(t, messages[0].Get("updated").Bool(), "stdout:\n%s", getResult.Stdout) +} + +func TestIM_MessageCardUpdateWorkflowAsBot(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + chatName := "lark-cli-e2e-im-card-update-" + suffix + originalText := "lark-cli-e2e-card-original-" + suffix + updatedText := "lark-cli-e2e-card-updated-" + suffix + + chatID := createChat(t, parentT, ctx, chatName) + messageID := sendInteractiveCard(t, ctx, chatID, originalText) + updatedCard := simpleInteractiveCard(updatedText) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-card-update", + "--message-id", messageID, + "--content", updatedCard, + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, messageID, gjson.Get(result.Stdout, "data.message_id").String(), "stdout:\n%s", result.Stdout) + require.True(t, gjson.Get(result.Stdout, "data.updated").Bool(), "stdout:\n%s", result.Stdout) + + getResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"im", "+messages-mget", + "--message-ids", messageID, + "--no-reactions", + }, + DefaultAs: "bot", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + if result == nil || result.ExitCode != 0 { + return true + } + messages := gjson.Get(result.Stdout, "data.messages").Array() + if len(messages) != 1 { + return true + } + return !strings.Contains(messages[0].Get("content").String(), updatedText) + }, + }) + require.NoError(t, err) + getResult.AssertExitCode(t, 0) + getResult.AssertStdoutStatus(t, true) + + messages := gjson.Get(getResult.Stdout, "data.messages").Array() + require.Len(t, messages, 1, "stdout:\n%s", getResult.Stdout) + require.Equal(t, messageID, messages[0].Get("message_id").String(), "stdout:\n%s", getResult.Stdout) + require.True(t, strings.Contains(messages[0].Get("content").String(), updatedText), "stdout:\n%s", getResult.Stdout) +} + +func sendInteractiveCard(t *testing.T, ctx context.Context, chatID string, text string) string { + t.Helper() + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-send", + "--chat-id", chatID, + "--msg-type", "interactive", + "--content", simpleInteractiveCard(text), + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + messageID := gjson.Get(result.Stdout, "data.message_id").String() + require.NotEmpty(t, messageID, "message_id should not be empty") + return messageID +} + +func simpleInteractiveCard(text string) string { + return `{"config":{"update_multi":true},"elements":[{"tag":"div","text":{"tag":"plain_text","content":"` + text + `"}}]}` +}