diff --git a/shortcuts/drive/drive_add_comment.go b/shortcuts/drive/drive_add_comment.go index 18bdd04dc..41899054d 100644 --- a/shortcuts/drive/drive_add_comment.go +++ b/shortcuts/drive/drive_add_comment.go @@ -18,6 +18,35 @@ import ( const defaultLocateDocLimit = 10 +// maxCommentTotalRunes is the cap on the combined character (rune) count +// across all `reply_elements[].text` fields in a single +// `drive +add-comment` request. +// +// The open-platform `/open-apis/drive/v1/files/{token}/new_comments` +// endpoint returns an opaque `[1069302] Invalid or missing parameters` +// when this is exceeded — no indication that length is the cause or +// which element is at fault. +// +// Empirically (probing the live API): +// +// - 10000 runes in a single text element: OK (10000 ASCII / 30000 +// bytes for Chinese / 40000 bytes if all '<' — server counts the +// raw rune count, not byte width and not the post-escape form) +// - 10001 runes in a single text element: [1069302] +// - 5000 + 5000 across two elements (total 10000): OK +// - 5000 + 5001 across two elements (total 10001): [1069302] +// +// So the cap is applied to the *total* across all reply_elements, not +// per element. Splitting an over-the-cap message into multiple text +// elements does NOT help — the server enforces the same limit on the +// sum. +// +// The schema doc currently advertises a 1-1000 character limit, but +// the live API accepts up to 10000 runes; the schema is out of date. +// If this constant ever needs to track a server-side change, re-probe +// with `drive file.comments create_v2` against a fresh docx. +const maxCommentTotalRunes = 10000 + type commentDocRef struct { Kind string Token string @@ -604,6 +633,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { } replyElements := make([]map[string]interface{}, 0, len(inputs)) + totalRunes := 0 for i, input := range inputs { index := i + 1 elementType := strings.TrimSpace(input.Type) @@ -612,9 +642,27 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { if strings.TrimSpace(input.Text) == "" { return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index) } - if utf8.RuneCountInString(input.Text) > 1000 { - return nil, output.ErrValidation("--content element #%d text exceeds 1000 characters", index) + // Measure the raw rune count of the user input — that is what + // the server actually counts. byte width and post-escape form + // don't matter (10000 '<' chars succeed even though they + // expand to 40000 bytes when escaped, and 10000 Chinese chars + // succeed even though they encode as 30000 UTF-8 bytes). + runes := utf8.RuneCountInString(input.Text) + totalRunes += runes + if totalRunes > maxCommentTotalRunes { + return nil, output.ErrWithHint( + output.ExitValidation, + "text_too_long", + fmt.Sprintf("--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements", + totalRunes, index, runes, maxCommentTotalRunes), + fmt.Sprintf("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes), + ) } + // Escape '<' and '>' so the rendered comment displays them as + // literal characters instead of being interpreted as markup + // by Lark's comment renderer. This is independent of the + // length check — the server sees the escaped form, but + // counts characters by the raw input length above. replyElements = append(replyElements, map[string]interface{}{ "type": "text", "text": escapeCommentText(input.Text), diff --git a/shortcuts/drive/drive_add_comment_test.go b/shortcuts/drive/drive_add_comment_test.go index a6fd9a176..ed4278a22 100644 --- a/shortcuts/drive/drive_add_comment_test.go +++ b/shortcuts/drive/drive_add_comment_test.go @@ -5,11 +5,13 @@ package drive import ( "encoding/json" + "errors" "strings" "testing" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" ) func decodeJSONMap(t *testing.T, raw string) map[string]interface{} { @@ -292,6 +294,186 @@ func TestParseCommentReplyElementsEscapesAngleBrackets(t *testing.T) { } } +func TestParseCommentReplyElementsTextLength(t *testing.T) { + t.Parallel() + + // Cap is 10000 runes total across all reply_elements text fields, + // empirically derived from the live API. See the comment on + // maxCommentTotalRunes for the probe results. + exactCapASCII := strings.Repeat("a", 10000) + overCapASCII := strings.Repeat("a", 10001) + + // Chinese chars cost 3 bytes each in UTF-8 but the server counts + // runes, not bytes — so the cap is the same 10000 here. + exactCapCJK := strings.Repeat("文", 10000) + overCapCJK := strings.Repeat("文", 10001) + + // '<' would expand to '<' (4 bytes) under escapeCommentText, but + // since the server counts raw runes the cap is still 10000 chars, + // not 2500. This pins that distinction. + exactCapAngle := strings.Repeat("<", 10000) + overCapAngle := strings.Repeat("<", 10001) + + // Two-element split exactly hitting the cap together. + splitFiveK := strings.Repeat("a", 5000) + splitFiveKPlusOne := strings.Repeat("a", 5001) + + tests := []struct { + name string + input string + wantErr string + wantHint string // substring of the hint portion; "" means don't check hint + wantCount int // expected parsed element count when no error expected + }{ + { + name: "single element exactly at 10000 ASCII chars accepted", + input: `[{"type":"text","text":"` + exactCapASCII + `"}]`, + wantCount: 1, + }, + { + name: "single element at 10001 ASCII chars rejected", + input: `[{"type":"text","text":"` + overCapASCII + `"}]`, + wantErr: "totals 10001 characters at element #1", + wantHint: "splitting one long element into multiple smaller text elements does NOT help", + }, + { + name: "single element exactly at 10000 chinese chars accepted (server counts runes, not bytes)", + input: `[{"type":"text","text":"` + exactCapCJK + `"}]`, + wantCount: 1, + }, + { + name: "single element at 10001 chinese chars rejected", + input: `[{"type":"text","text":"` + overCapCJK + `"}]`, + wantErr: "totals 10001 characters at element #1", + }, + { + name: "10000 angle brackets accepted (server counts raw runes, not escaped form)", + input: `[{"type":"text","text":"` + exactCapAngle + `"}]`, + wantCount: 1, + }, + { + name: "10001 angle brackets rejected (escape state irrelevant to cap)", + input: `[{"type":"text","text":"` + overCapAngle + `"}]`, + wantErr: "totals 10001 characters at element #1", + }, + { + // Pins the multi-element TOTAL cap: two 5000-char elements + // fit together exactly (10000 sum). This is the boundary the + // previous PR's "split into multiple elements" advice + // implied was a workaround — it's actually only valid if + // the sum still fits. + name: "two elements totalling exactly 10000 accepted", + input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveK + `"}]`, + wantCount: 2, + }, + { + // Companion to the above and the headline reason the prior + // "split into multiple elements" hint is wrong: 5000+5001 + // sums to 10001 which the server rejects with the same + // opaque [1069302], regardless of how many elements it's + // distributed across. + name: "two elements totalling 10001 rejected with index pointing at offending element", + input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveKPlusOne + `"}]`, + wantErr: "totals 10001 characters at element #2", + wantHint: "splitting one long element into multiple smaller text elements does NOT help", + }, + { + // Streaming-cap correctness: when an EARLY element by itself + // already overshoots, the index reported is that early + // element (not the last one in the array). + name: "first element over the cap reports index 1", + input: `[{"type":"text","text":"` + overCapASCII + `"},{"type":"text","text":"trailing"}]`, + wantErr: "totals 10001 characters at element #1", + }, + { + // mention_user / link elements don't count toward the + // rune cap (their content is ID / URL, not user-visible + // running text). Pin that a moderate text plus a mention + // stays accepted even though the mention adds bytes. + name: "text plus mention_user does not double-count toward cap", + input: `[{"type":"text","text":"` + exactCapASCII + `"},{"type":"mention_user","text":"ou_1234567890abcdef"}]`, + wantCount: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := parseCommentReplyElements(tt.input) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil (parsed %d elements)", tt.wantErr, len(got)) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + if tt.wantHint != "" { + // Hint lives on ExitError.Detail.Hint, not err.Error(). + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err) + } + if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) { + t.Errorf("expected hint substring %q, got %q", tt.wantHint, exitErr.Detail.Hint) + } + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != tt.wantCount { + t.Fatalf("expected %d reply elements, got %d", tt.wantCount, len(got)) + } + }) + } +} + +// TestParseCommentReplyElementsHintForbidsSplitAdvice pins that the +// over-cap hint does NOT recommend splitting into multiple text +// elements as a workaround. An earlier version of this PR shipped +// that advice; live-API probing showed the cap is on the *total* run +// of characters across all reply_elements, so splitting doesn't +// bypass it. If the hint ever drifts back into recommending a split, +// users will be sent down a dead end where their first attempt fails +// pre-flight, their "fixed" attempt also fails server-side, and +// they're stuck. +func TestParseCommentReplyElementsHintForbidsSplitAdvice(t *testing.T) { + t.Parallel() + + _, err := parseCommentReplyElements(`[{"type":"text","text":"` + strings.Repeat("a", 10001) + `"}]`) + if err == nil { + t.Fatal("expected over-cap error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err) + } + hint := exitErr.Detail.Hint + + // The hint must explicitly call out that splitting does NOT help. + if !strings.Contains(hint, "does NOT help") { + t.Errorf("hint must explicitly say splitting does NOT help, got: %q", hint) + } + // Anti-pattern check: the hint must not phrase any "split into + // multiple elements" recommendation as a workaround. Look for the + // previous PR's exact phrasing variants. + for _, banned := range []string{ + "split the content across multiple", + "split into multiple text elements", + "renders them as one contiguous comment", + } { + if strings.Contains(hint, banned) { + t.Errorf("hint must not contain the discredited %q advice, got: %q", banned, hint) + } + } + // And it should reference the actual number so callers know the + // budget without having to read the source. + if !strings.Contains(hint, "10000") { + t.Errorf("hint should name the 10000-rune budget, got: %q", hint) + } +} + func TestParseCommentReplyElementsInvalid(t *testing.T) { t.Parallel() diff --git a/skills/lark-drive/references/lark-drive-add-comment.md b/skills/lark-drive/references/lark-drive-add-comment.md index 8c403d1e5..ae009e4a7 100644 --- a/skills/lark-drive/references/lark-drive-add-comment.md +++ b/skills/lark-drive/references/lark-drive-add-comment.md @@ -148,6 +148,10 @@ lark-cli drive +add-comment \ - `--content` 接收结构化评论元素数组;`type` 支持 `text`、`mention_user`、`link`。为便于书写,`mention_user` / `link` 元素可以直接把用户 ID 或链接地址放在 `text` 字段中,shortcut 会转换成 OpenAPI 所需字段。 - `type=text` 的评论文本不能直接包含 `<`、`>`;应优先传 `<`、`>`。shortcut 在发送前也会自动将 `<`、`>` 转义为 `<`、`>` 作为兜底。 +- **所有 `type=text` 元素的字符总和 ≤ 10000**(按字符算,中英文 / 符号一视同仁)。超过会被 shortcut 在发送前拒绝,并指出累计超长的元素。**拆成多个 text element 不能绕过这个上限**——上限是总额,不是每元素。需要更长内容就缩短或拆成多条评论。 +- 长度限制只对 `type=text` 生效,`mention_user` / `link` 不计入。 +- 局部评论走 `locate-doc` 时,内部固定使用 `limit=10`。 +- 当 `locate-doc` 命中多处时,shortcut 会中止并提示用户继续收窄 `--selection-with-ellipsis`,不支持手动指定匹配序号。 - 写入评论前会自动生成符合 OpenAPI 定义的请求体: - 统一接口:`POST /new_comments` - 统一字段:`file_type` + `reply_elements`