mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
feat: validate IM idempotency key length (#1797)
Previously, keys longer than the OpenAPI uuid limit were sent to the server and returned a generic field validation failed error. This change rejects overlong keys locally with a typed validation error that identifies --idempotency-key and the 50-character limit.
This commit is contained in:
@@ -504,6 +504,84 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey empty string passes", func(t *testing.T) {
|
||||
if err := validateIdempotencyKey(""); err != nil {
|
||||
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey 50 chars passes", func(t *testing.T) {
|
||||
if err := validateIdempotencyKey(strings.Repeat("a", 50)); err != nil {
|
||||
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey 51 chars fails", func(t *testing.T) {
|
||||
err := validateIdempotencyKey(strings.Repeat("a", 51))
|
||||
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
|
||||
t.Fatalf("validateIdempotencyKey() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey 50 Chinese chars passes", func(t *testing.T) {
|
||||
if err := validateIdempotencyKey(strings.Repeat("中", 50)); err != nil {
|
||||
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey 51 Chinese chars fails", func(t *testing.T) {
|
||||
err := validateIdempotencyKey(strings.Repeat("中", 51))
|
||||
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
|
||||
t.Fatalf("validateIdempotencyKey() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend idempotency key too long", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"text": "hello",
|
||||
"idempotency-key": strings.Repeat("a", 51),
|
||||
}, nil)
|
||||
err := ImMessagesSend.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
|
||||
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend idempotency key valid", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"text": "hello",
|
||||
"idempotency-key": "my-key-001",
|
||||
}, nil)
|
||||
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesSend.Validate() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesReply idempotency key too long", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-id": "om_123",
|
||||
"text": "hello",
|
||||
"idempotency-key": strings.Repeat("b", 51),
|
||||
}, nil)
|
||||
err := ImMessagesReply.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
|
||||
t.Fatalf("ImMessagesReply.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesReply idempotency key valid", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-id": "om_123",
|
||||
"text": "hello",
|
||||
"idempotency-key": "reply-key-001",
|
||||
}, nil)
|
||||
if err := ImMessagesReply.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesReply.Validate() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesReply invalid message id", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-id": "bad_id",
|
||||
|
||||
@@ -35,7 +35,7 @@ var ImMessagesReply = common.Shortcut{
|
||||
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
|
||||
{Name: "audio", Desc: audioMessageInputDesc},
|
||||
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key, max 50 characters (prevents duplicate sends)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
@@ -85,6 +85,7 @@ var ImMessagesReply = common.Shortcut{
|
||||
content := runtime.Str("content")
|
||||
text := runtime.Str("text")
|
||||
markdown := runtime.Str("markdown")
|
||||
idempotencyKey := runtime.Str("idempotency-key")
|
||||
imageKey := runtime.Str("image")
|
||||
fileKey := runtime.Str("file")
|
||||
videoKey := runtime.Str("video")
|
||||
@@ -114,6 +115,9 @@ var ImMessagesReply = common.Shortcut{
|
||||
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
|
||||
}
|
||||
if err := validateIdempotencyKey(idempotencyKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if content != "" && !json.Valid([]byte(content)) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ var ImMessagesSend = common.Shortcut{
|
||||
{Name: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON"},
|
||||
{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)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key, max 50 characters (prevents duplicate sends)"},
|
||||
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
|
||||
@@ -97,6 +97,7 @@ var ImMessagesSend = common.Shortcut{
|
||||
content := runtime.Str("content")
|
||||
text := runtime.Str("text")
|
||||
markdown := runtime.Str("markdown")
|
||||
idempotencyKey := runtime.Str("idempotency-key")
|
||||
imageKey := runtime.Str("image")
|
||||
fileKey := runtime.Str("file")
|
||||
videoKey := runtime.Str("video")
|
||||
@@ -135,6 +136,9 @@ var ImMessagesSend = common.Shortcut{
|
||||
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, msg)
|
||||
}
|
||||
if err := validateIdempotencyKey(idempotencyKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if content != "" && !json.Valid([]byte(content)) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
|
||||
}
|
||||
@@ -211,6 +215,15 @@ var ImMessagesSend = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
const maxIdempotencyKeyChars = 50
|
||||
|
||||
func validateIdempotencyKey(value string) error {
|
||||
if chars := len([]rune(value)); chars > maxIdempotencyKeyChars {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotency-key exceeds the maximum of %d characters (got %d)", maxIdempotencyKeyChars, chars).WithParam("--idempotency-key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isMediaKey returns true if the value looks like an existing API key rather than a local file path.
|
||||
func isMediaKey(value string) bool {
|
||||
return strings.HasPrefix(value, "img_") || strings.HasPrefix(value, "file_")
|
||||
|
||||
@@ -188,7 +188,7 @@ lark-cli im +messages-reply --message-id om_xxx --msg-type interactive --content
|
||||
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`) |
|
||||
| `--audio <path\|url\|key>` | One content option | Voice-message audio key, URL, or cwd-relative local path. Local paths and URLs must be Opus (`.opus` or Ogg Opus `.ogg`) |
|
||||
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
|
||||
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
|
||||
| `--idempotency-key <key>` | No | Idempotency key, max 50 characters; the same key sends only one reply within 1 hour |
|
||||
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ lark-cli im +messages-send --chat-id oc_xxx --msg-type interactive --content '<c
|
||||
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
|
||||
| `--audio <path\|url\|key>` | One content option | Voice-message audio key, URL, or cwd-relative local path. Local paths and URLs must be Opus (`.opus` or Ogg Opus `.ogg`) |
|
||||
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
|
||||
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
|
||||
| `--idempotency-key <key>` | No | Idempotency key, max 50 characters; the same key sends only one message within 1 hour |
|
||||
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user