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
40 changes: 40 additions & 0 deletions shortcuts/im/builders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 <at id=ou_1/>",
}, 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",
Expand Down
2 changes: 2 additions & 0 deletions shortcuts/im/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
76 changes: 76 additions & 0 deletions shortcuts/im/im_messages_card_update.go
Original file line number Diff line number Diff line change
@@ -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
},
}
129 changes: 129 additions & 0 deletions shortcuts/im/im_messages_card_update_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading