diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go index ca15f83c7..8f15ede21 100644 --- a/shortcuts/im/builders_test.go +++ b/shortcuts/im/builders_test.go @@ -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", diff --git a/shortcuts/im/im_messages_reply.go b/shortcuts/im/im_messages_reply.go index 97da156b1..1aaf4ba83 100644 --- a/shortcuts/im/im_messages_reply.go +++ b/shortcuts/im/im_messages_reply.go @@ -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") } diff --git a/shortcuts/im/im_messages_send.go b/shortcuts/im/im_messages_send.go index 75bb68610..8633aab16 100644 --- a/shortcuts/im/im_messages_send.go +++ b/shortcuts/im/im_messages_send.go @@ -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_") diff --git a/skills/lark-im/references/lark-im-messages-reply.md b/skills/lark-im/references/lark-im-messages-reply.md index a82f52080..70e3c87b2 100644 --- a/skills/lark-im/references/lark-im-messages-reply.md +++ b/skills/lark-im/references/lark-im-messages-reply.md @@ -188,7 +188,7 @@ lark-cli im +messages-reply --message-id om_xxx --msg-type interactive --content | `--video-cover ` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`) | | `--audio ` | 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 ` | No | Idempotency key; the same key sends only one reply within 1 hour | +| `--idempotency-key ` | No | Idempotency key, max 50 characters; the same key sends only one reply within 1 hour | | `--as ` | No | Identity type: `bot` or `user` (default `bot`) | | `--dry-run` | No | Print the request only, do not execute it | diff --git a/skills/lark-im/references/lark-im-messages-send.md b/skills/lark-im/references/lark-im-messages-send.md index 2215787d9..eb3a6b144 100644 --- a/skills/lark-im/references/lark-im-messages-send.md +++ b/skills/lark-im/references/lark-im-messages-send.md @@ -191,7 +191,7 @@ lark-cli im +messages-send --chat-id oc_xxx --msg-type interactive --content '` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically | | `--audio ` | 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 ` | 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 ` | No | Idempotency key; the same key sends only one message within 1 hour | +| `--idempotency-key ` | No | Idempotency key, max 50 characters; the same key sends only one message within 1 hour | | `--as ` | No | Identity type: `bot` or `user` (default `bot`) | | `--dry-run` | No | Print the request only, do not execute it |