mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
1 Commits
codex/html
...
fix/profil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e896fe2ad5 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,22 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.62] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Add meeting message send shortcut (#1643)
|
||||
- **doc**: Add document word statistics helper (#1697)
|
||||
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
|
||||
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
|
||||
- **base**: Support JSON array input for field create (#1661)
|
||||
- **task**: Expose completion state in `my tasks` output (#1641)
|
||||
- **cli**: Reduce public content credential false positives (#1700)
|
||||
|
||||
## [v1.0.61] - 2026-06-30
|
||||
|
||||
### Features
|
||||
@@ -1333,7 +1317,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
||||
|
||||
@@ -21,7 +21,7 @@ type GlobalOptions struct {
|
||||
// applies any visibility policy encoded in opts. Pure function: no disk,
|
||||
// network, or environment reads — the caller decides HideProfile.
|
||||
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
|
||||
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
|
||||
fs.StringVar(&opts.Profile, "profile", "", "use a specific configuration profile (see 'lark-cli profile list')")
|
||||
if opts.HideProfile {
|
||||
_ = fs.MarkHidden("profile")
|
||||
}
|
||||
|
||||
@@ -10,20 +10,8 @@ import "github.com/larksuite/cli/errs"
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -27,13 +27,6 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) {
|
||||
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
||||
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
||||
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
// Secure label endpoint codes observed from drive +secure-label-update
|
||||
// failure telemetry.
|
||||
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
|
||||
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
|
||||
@@ -102,35 +102,6 @@ func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{1061001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
{1061002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||
}
|
||||
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_Unknown(t *testing.T) {
|
||||
_, ok := LookupCodeMeta(999999)
|
||||
if ok {
|
||||
|
||||
@@ -52,9 +52,6 @@ func isPlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(trimmed)
|
||||
if normalized == "" ||
|
||||
normalized == "=" ||
|
||||
printfPlaceholderValue(normalized) ||
|
||||
htmlEntityAnglePlaceholder(normalized) ||
|
||||
starMaskedPlaceholder(normalized) ||
|
||||
percentWrappedPlaceholder(normalized) ||
|
||||
angleWrappedPlaceholder(normalized) ||
|
||||
urlWithAnglePlaceholder(normalized) ||
|
||||
@@ -64,28 +61,9 @@ func isPlaceholderValue(value string) bool {
|
||||
return namedPlaceholderValue(normalized)
|
||||
}
|
||||
|
||||
func htmlEntityAnglePlaceholder(value string) bool {
|
||||
if !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
|
||||
return false
|
||||
}
|
||||
return anglePlaceholderIdentifier(strings.TrimSuffix(strings.TrimPrefix(value, "<"), ">"))
|
||||
}
|
||||
|
||||
func starMaskedPlaceholder(value string) bool {
|
||||
var stars int
|
||||
for _, r := range value {
|
||||
if r == '*' {
|
||||
stars++
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return stars >= 3
|
||||
}
|
||||
|
||||
func namedPlaceholderValue(value string) bool {
|
||||
switch value {
|
||||
case "...", "***", "****", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret", "test-token", "dry-run", "dry_run":
|
||||
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
|
||||
return true
|
||||
}
|
||||
return strings.Contains(value, "cli_example") ||
|
||||
@@ -93,15 +71,6 @@ func namedPlaceholderValue(value string) bool {
|
||||
conventionalNamedPlaceholderValue(value)
|
||||
}
|
||||
|
||||
func printfPlaceholderValue(value string) bool {
|
||||
switch value {
|
||||
case "%d", "%s", "%q", "%v", "%w", "%x", "%T":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func allXPlaceholder(value string) bool {
|
||||
if len(value) < 4 {
|
||||
return false
|
||||
|
||||
@@ -54,9 +54,8 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||
if value == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
|
||||
isBenignCodeCredentialExpression(file, value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
isPermissionScopeIdentifierAssignment(keyName, value) ||
|
||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||
continue
|
||||
}
|
||||
@@ -267,7 +266,7 @@ func isResourceTokenPlaceholderAssignment(key, value string) bool {
|
||||
case key == "retry_without_token" && numericStringPlaceholderValue(value):
|
||||
return true
|
||||
case tokenLikePlaceholderKey(key):
|
||||
return tokenLikePlaceholderValue(key, value)
|
||||
return tokenLikePlaceholderValue(value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -279,13 +278,12 @@ func tokenLikePlaceholderKey(key string) bool {
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func tokenLikePlaceholderValue(key, value string) bool {
|
||||
func tokenLikePlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
return resourceTokenPlaceholderValue(value) ||
|
||||
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
||||
isPlaceholderValue(value) ||
|
||||
normalized == "token" ||
|
||||
strings.Contains(normalized, "...") ||
|
||||
@@ -295,51 +293,6 @@ func tokenLikePlaceholderValue(key, value string) bool {
|
||||
strings.HasPrefix(normalized, ".")
|
||||
}
|
||||
|
||||
func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
if authCredentialTokenKey(key) {
|
||||
return false
|
||||
}
|
||||
var stars, alnum int
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r == '*':
|
||||
stars++
|
||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||
alnum++
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return stars >= 6 && alnum > 0
|
||||
}
|
||||
|
||||
func authCredentialTokenKey(key string) bool {
|
||||
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
|
||||
case "access_token",
|
||||
"refresh_token",
|
||||
"session_token",
|
||||
"bearer_token",
|
||||
"auth_token",
|
||||
"authorization_token",
|
||||
"id_token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isPermissionScopeIdentifierAssignment(key, value string) bool {
|
||||
if !strings.HasSuffix(key, "_token") {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.Trim(value, `"',;`)) {
|
||||
case "read", "write", "modify", "readonly", "get_as_user":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func idempotencyTokenPlaceholderValue(value string) bool {
|
||||
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
|
||||
}
|
||||
@@ -380,87 +333,20 @@ func numericStringPlaceholderValue(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
func isBenignCodeCredentialExpression(file, value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) || credentialShapedValue(value) {
|
||||
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
|
||||
return isBenignTypedCredentialRHS(rhs)
|
||||
}
|
||||
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
|
||||
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
|
||||
return true
|
||||
}
|
||||
if sourceCodeFormatStringLiteral(normalized) && sourceCodeFormatArgumentContext(line, match) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(match, "+") {
|
||||
return true
|
||||
}
|
||||
if rawValueQuoted {
|
||||
return false
|
||||
}
|
||||
if quotedLiteral(value) {
|
||||
return sourceCodeLiteralLooksNonSecret(value, false)
|
||||
}
|
||||
return codeReferenceExpression(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
||||
idx := strings.Index(line, match)
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[idx+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
return "", false
|
||||
}
|
||||
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
|
||||
assignmentIdx := strings.Index(typeAndRHS, "=")
|
||||
if assignmentIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
|
||||
}
|
||||
|
||||
func isBenignTypedCredentialRHS(value string) bool {
|
||||
value = strings.TrimRight(strings.TrimSpace(value), ",;")
|
||||
if value == "" || isNonSecretLiteralValue(value) || isPlaceholderValue(value) {
|
||||
return true
|
||||
}
|
||||
if credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if sourceCodeLiteralLooksNonSecret(value, !quotedLiteral(value)) {
|
||||
return true
|
||||
}
|
||||
if quotedLiteral(value) {
|
||||
return false
|
||||
}
|
||||
return codeReferenceExpression(value)
|
||||
}
|
||||
|
||||
func credentialAssignmentRawValueQuoted(match string) bool {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(match[len(key):], ":"))
|
||||
rest = strings.TrimSpace(strings.TrimPrefix(rest, "="))
|
||||
return strings.HasPrefix(rest, `"`) || strings.HasPrefix(rest, `'`)
|
||||
}
|
||||
|
||||
func sourceCodeFile(file string) bool {
|
||||
switch filepath.Ext(file) {
|
||||
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
|
||||
case ".go", ".py":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -474,147 +360,7 @@ func quotedLiteral(value string) bool {
|
||||
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
|
||||
}
|
||||
|
||||
func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
|
||||
literal := strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
if strings.HasPrefix(literal, "/") {
|
||||
return true
|
||||
}
|
||||
return (allowNumeric && numericStringPlaceholderValue(literal)) ||
|
||||
sourceCodeEnvVarNameLiteral(literal) ||
|
||||
sourceCodeAttributeNameLiteral(literal) ||
|
||||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
|
||||
sourceCodeCredentialTermLiteral(literal) ||
|
||||
sourceCodeCredentialPrefixLiteral(literal) ||
|
||||
sourceCodeVocabularyLiteral(literal) ||
|
||||
sourceCodeSchemaTypeLiteral(literal) ||
|
||||
benignCredentialStatusLiteral(literal)
|
||||
}
|
||||
|
||||
func sourceCodeFormatArgumentContext(line, match string) bool {
|
||||
idx := strings.Index(line, match)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
prefix := line[:idx]
|
||||
if semicolon := strings.LastIndex(prefix, ";"); semicolon >= 0 {
|
||||
prefix = prefix[semicolon+1:]
|
||||
}
|
||||
return strings.Contains(prefix, "fmt.") ||
|
||||
strings.Contains(prefix, "log.") ||
|
||||
strings.Contains(prefix, "printf(") ||
|
||||
strings.Contains(prefix, "Printf(") ||
|
||||
strings.Contains(prefix, "Errorf(") ||
|
||||
strings.Contains(prefix, "Fprintf(")
|
||||
}
|
||||
|
||||
func sourceCodeFormatStringLiteral(value string) bool {
|
||||
for i := 0; i < len(value)-1; i++ {
|
||||
if value[i] != '%' {
|
||||
continue
|
||||
}
|
||||
if value[i+1] == '%' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
j := i + 1
|
||||
for j < len(value) && strings.ContainsRune("#+- 0.0123456789", rune(value[j])) {
|
||||
j++
|
||||
}
|
||||
if j < len(value) && strings.ContainsRune("vTtbcdoOqxXUeEfFgGspw", rune(value[j])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sourceCodeEnvVarNameLiteral(value string) bool {
|
||||
if value == "" || !strings.Contains(value, "_") {
|
||||
return false
|
||||
}
|
||||
var hasCredentialMarker bool
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"} {
|
||||
if strings.Contains(value, marker) {
|
||||
hasCredentialMarker = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return hasCredentialMarker
|
||||
}
|
||||
|
||||
func sourceCodeAttributeNameLiteral(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
return strings.HasPrefix(normalized, "data-") && delimitedPlaceholderIdentifier(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeFakeOrPlaceholderLiteral(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
return strings.HasPrefix(normalized, "fake_") ||
|
||||
strings.HasPrefix(normalized, "fake-") ||
|
||||
strings.Contains(normalized, "placeholder") ||
|
||||
(strings.Contains(normalized, "<") && strings.Contains(normalized, ">"))
|
||||
}
|
||||
|
||||
func sourceCodeCredentialTermLiteral(value string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
|
||||
return conventionalCredentialPlaceholderName(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeCredentialPrefixLiteral(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "appsecret:":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCodeVocabularyLiteral(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "bot", "tenant", "user":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCodeSchemaTypeLiteral(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
return normalized == "string" || strings.HasPrefix(normalized, "string(")
|
||||
}
|
||||
|
||||
func benignCredentialStatusLiteral(value string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
|
||||
if !delimitedPlaceholderIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"bad_fmt",
|
||||
"expired",
|
||||
"format",
|
||||
"invalid",
|
||||
"missing",
|
||||
"permission",
|
||||
"status",
|
||||
"type",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func codeReferenceExpression(value string) bool {
|
||||
value = strings.TrimRight(strings.TrimSpace(value), ";")
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
@@ -623,10 +369,7 @@ func codeReferenceExpression(value string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !codeIdentifier(value) {
|
||||
return false
|
||||
}
|
||||
return codeIdentifier(value)
|
||||
return codeIdentifier(value) && !credentialNameFragment(value)
|
||||
}
|
||||
|
||||
func codeIdentifier(value string) bool {
|
||||
@@ -643,6 +386,16 @@ func codeIdentifier(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialNameFragment(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
|
||||
@@ -770,172 +770,6 @@ func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPythonCredentialTypeAnnotations(t *testing.T) {
|
||||
got := ScanFile("fixtures/doc_word_stat.py", []byte(strings.Join([]string{
|
||||
"class Counter:",
|
||||
" def __init__(self) -> None:",
|
||||
" self._token_kind: TokenKind | None = None",
|
||||
" self.access_token: AccessToken | None = None",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("python credential-shaped type annotations should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
|
||||
got := ScanFile("fixtures/auth_paths.go", []byte(strings.Join([]string{
|
||||
`const PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"`,
|
||||
`return fmt.Errorf("failed to remove token: %v", err)`,
|
||||
`const LarkErrTokenMissing = "token_missing"`,
|
||||
`const LarkErrTokenExpired = 99991677`,
|
||||
`const CliAppSecret = "LARKSUITE_CLI_APP_SECRET"`,
|
||||
`const LargeAttachmentTokenAttr = "data-mail-token"`,
|
||||
`const fakeOfficeTokenPrefix = "fake_office_"`,
|
||||
`fmt.Fprintf(w, " - token=%s filename=%s\n", att.Token, att.FileName)`,
|
||||
`tokenTypeHint := "access_token"`,
|
||||
`const TokenTenant Token = "tenant"`,
|
||||
`const secretKeyPrefix = "appsecret:"`,
|
||||
`output.PrintJson(out, map[string]interface{}{"appSecret": "****"})`,
|
||||
`return &credential.TokenResult{Token: "test-token"}, nil`,
|
||||
`fmt.Fprintf(w, "password=%s\n", pat)`,
|
||||
`text += "(img_token:" + imgToken + ")"`,
|
||||
`map[string]interface{}{"token": "string(optional, from inspect)"}`,
|
||||
`this.token = token;`,
|
||||
`// AppSecret: "appsecret:<appId>"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("source code non-secret literals should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`app_secret=***`,
|
||||
`{"token":"<wiki_token>"}`,
|
||||
`{"token":"Pgrrwvr***********UnRb"}`,
|
||||
`"scope_name": "auth:user_access_token:read"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("public placeholders and scope identifiers should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
|
||||
"client_secret=realprefix***realsuffix",
|
||||
"client_secret=ab********cd",
|
||||
"access_token=ab********cd",
|
||||
"refresh_token=realprefix********realsuffix",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/ci.yml", []byte(strings.Join([]string{
|
||||
"LARKSUITE_CLI_APP_SECRET=dry-run",
|
||||
"client_secret: dry_run",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("dry-run credential placeholders should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
text string
|
||||
}{
|
||||
{
|
||||
name: "typescript simple secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "typescript numeric password",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const password: string = "12345678901234567890"`,
|
||||
},
|
||||
{
|
||||
name: "typescript union secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python simple secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python union secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str | None = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python optional secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ScanFile(tc.file, []byte(tc.text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("typed credential assignment should be reported: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
|
||||
`const ClientSecret = "real-client-secret-value"`,
|
||||
`const GithubToken = "` + githubToken + `"`,
|
||||
`const Password = "12345678901234567890"`,
|
||||
`const ClientSecretNumber = "12345678901234567890"`,
|
||||
`const ClientSecretFormat = "abc%sdefreal"`,
|
||||
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 6 {
|
||||
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPrintfCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
"client_secret=%s",
|
||||
"access_token=%v",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("printf placeholders should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{
|
||||
`<img token="..." url="https://..." width="..." height="..."/>`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.63",
|
||||
"version": "1.0.61",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -265,10 +265,9 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
const checksumsPath = path.join(dir, "checksums.txt");
|
||||
|
||||
if (!fs.existsSync(checksumsPath)) {
|
||||
console.error(
|
||||
"[WARN] checksums.txt not found, skipping checksum verification"
|
||||
throw new Error(
|
||||
"[SECURITY] checksums.txt not found; refusing to install an unverified binary."
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(checksumsPath, "utf8");
|
||||
@@ -286,7 +285,11 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
}
|
||||
|
||||
function verifyChecksum(archivePath, expectedHash) {
|
||||
if (expectedHash === null) return;
|
||||
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
|
||||
throw new Error(
|
||||
"[SECURITY] missing expected checksum; refusing to install an unverified binary."
|
||||
);
|
||||
}
|
||||
|
||||
// Stream the file to avoid loading the entire archive into memory.
|
||||
// Archives can be 10-100MB; streaming keeps RSS constant.
|
||||
|
||||
@@ -52,11 +52,17 @@ describe("getExpectedChecksum", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when checksums.txt does not exist", () => {
|
||||
it("throws [SECURITY] when checksums.txt does not exist (fail-closed)", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||
// No checksums.txt in dir
|
||||
const result = getExpectedChecksum("anything.tar.gz", dir);
|
||||
assert.equal(result, null);
|
||||
assert.throws(
|
||||
() => getExpectedChecksum("anything.tar.gz", dir),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
assert.match(err.message, /checksums\.txt not found/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("skips malformed lines and still finds valid entry", () => {
|
||||
@@ -125,6 +131,19 @@ describe("verifyChecksum", () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("verifyChecksum throws [SECURITY] on null/empty expectedHash (fail-closed)", () => {
|
||||
const filePath = makeTmpFile("content");
|
||||
for (const expectedHash of [null, ""]) {
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, expectedHash),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertAllowedHost", () => {
|
||||
|
||||
@@ -89,18 +89,6 @@ func TestDryRunFieldOps(t *testing.T) {
|
||||
)
|
||||
assertDryRunContains(t, dryRunFieldGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/fields")
|
||||
|
||||
arrayRT := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"json": `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunFieldCreate(ctx, arrayRT), `"name":"A"`, `"name":"B"`)
|
||||
|
||||
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open")
|
||||
|
||||
@@ -830,6 +830,11 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "field create",
|
||||
shortcut: BaseFieldCreate,
|
||||
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "field update",
|
||||
shortcut: BaseFieldUpdate,
|
||||
@@ -1097,54 +1102,6 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create array sequentially", func(t *testing.T) {
|
||||
oldDelay := fieldCreateBatchDelay
|
||||
fieldCreateBatchDelay = 0
|
||||
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
|
||||
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
firstStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"name":"A"`)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "text"},
|
||||
},
|
||||
}
|
||||
secondStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"name":"B"`)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_b", "name": "B", "type": "text"},
|
||||
},
|
||||
}
|
||||
reg.Register(firstStub)
|
||||
reg.Register(secondStub)
|
||||
|
||||
err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["created"] != true || data["total"] != float64(2) {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
fields, _ := data["fields"].([]interface{})
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("fields len=%d output=%#v", len(fields), data)
|
||||
}
|
||||
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
|
||||
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -1060,15 +1060,6 @@ func TestBaseFieldValidate(t *testing.T) {
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},{"name":"b","type":"text"}]`}, nil, nil)); err != nil {
|
||||
t.Fatalf("array create validate err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},1]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json item 2 must be an object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"formula"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
@@ -6,13 +6,10 @@ package base
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var fieldCreateBatchDelay = time.Second
|
||||
|
||||
func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
offset := runtime.Int("offset")
|
||||
if offset < 0 {
|
||||
@@ -36,14 +33,12 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
|
||||
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
|
||||
dr := common.NewDryRunAPI().
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").
|
||||
Body(body).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
for _, body := range bodies {
|
||||
dr.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").Body(body)
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -100,16 +95,11 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin
|
||||
}
|
||||
|
||||
func validateFieldCreate(runtime *common.RuntimeContext) error {
|
||||
bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json"))
|
||||
body, err := validateFieldJSON(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, body := range bodies {
|
||||
if err := validateFormulaLookupGuideAck(runtime, "+field-create", body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return validateFormulaLookupGuideAck(runtime, "+field-create", body)
|
||||
}
|
||||
|
||||
func validateFieldUpdate(runtime *common.RuntimeContext) error {
|
||||
@@ -150,38 +140,17 @@ func executeFieldGet(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeFieldCreate(runtime *common.RuntimeContext) error {
|
||||
bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json"))
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := make([]interface{}, 0, len(bodies))
|
||||
for idx, body := range bodies {
|
||||
if idx > 0 && fieldCreateBatchDelay > 0 {
|
||||
time.Sleep(fieldCreateBatchDelay)
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields = append(fields, data)
|
||||
}
|
||||
if len(fields) == 1 {
|
||||
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
|
||||
return nil
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseFieldCreateBodies(pc *parseCtx, raw string) ([]map[string]interface{}, error) {
|
||||
bodies, err := parseObjectList(pc, raw, "json")
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
if len(bodies) == 0 {
|
||||
return nil, baseFlagErrorf("--json must contain at least one field JSON object")
|
||||
}
|
||||
return bodies, nil
|
||||
runtime.Out(map[string]interface{}{"field": data, "created": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeFieldUpdate(runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -18,7 +18,6 @@ func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
|
||||
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
|
||||
@@ -33,8 +32,8 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("title") && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
|
||||
}
|
||||
if err := validateDocsV2ReferenceMapFlags(runtime); err != nil {
|
||||
return err
|
||||
if runtime.Str("content") == "" && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
|
||||
}
|
||||
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
|
||||
@@ -42,21 +41,11 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"},
|
||||
)
|
||||
}
|
||||
if runtime.Str("content") == "" && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
|
||||
}
|
||||
if runtime.Str("content") != "" {
|
||||
_, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
body := buildCreateBody(runtime)
|
||||
desc := "OpenAPI: create document"
|
||||
if runtime.IsBot() {
|
||||
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document."
|
||||
@@ -68,10 +57,7 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
}
|
||||
|
||||
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildCreateBody(runtime)
|
||||
|
||||
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
|
||||
if err != nil {
|
||||
@@ -100,10 +86,7 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
}
|
||||
|
||||
func buildCreateContent(runtime *common.RuntimeContext) string {
|
||||
return buildCreateContentWithBody(runtime, runtime.Str("content"))
|
||||
}
|
||||
|
||||
func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) string {
|
||||
content := runtime.Str("content")
|
||||
title := strings.TrimSpace(runtime.Str("title"))
|
||||
if title == "" {
|
||||
return content
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`
|
||||
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
|
||||
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
@@ -71,9 +71,6 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := processHTML5BlockReferenceMapForFetch(runtime, effectiveFetchFormat(runtime), ref.Token, data); err != nil {
|
||||
return err
|
||||
}
|
||||
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
|
||||
}
|
||||
|
||||
@@ -505,14 +505,14 @@ func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
|
||||
if got["enable_user_cite_reference_map"] != true {
|
||||
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
|
||||
}
|
||||
if got["return_html5_block_data"] != true {
|
||||
t.Fatalf("return_html5_block_data = %#v, want true in %#v", got["return_html5_block_data"], got)
|
||||
if _, ok := got["return_html5_block_data"]; ok {
|
||||
t.Fatalf("extra_param should not request html5 block data: %#v", got)
|
||||
}
|
||||
if _, ok := got["reference_map_mode"]; ok {
|
||||
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("extra_param should only contain fetch reference_map and html5 data toggles: %#v", got)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,46 +579,6 @@ func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchIMMarkdownIgnoresHTML5BlockInsideCodeFence(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown-code-fence"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdownFence/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchIMMarkdownFence",
|
||||
"revision_id": float64(1),
|
||||
"content": "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```\n",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchIMMarkdownFence",
|
||||
"--doc-format", "im-markdown",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if errField, ok := envelope["error"]; ok {
|
||||
t.Fatalf("fetch output should not contain error: %#v", errField)
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
content, _ := doc["content"].(string)
|
||||
if !strings.Contains(content, "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```") {
|
||||
t.Fatalf("fenced html5-block should stay in content, got:\n%s", content)
|
||||
}
|
||||
if _, ok := doc["reference_map"]; ok {
|
||||
t.Fatalf("fenced html5-block should not create reference_map side effects: %#v", doc["reference_map"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ package doc
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -64,39 +63,6 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateBodyWithHTML5ReferenceMapReportsPathError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
||||
"content": `<html5-block path="@missing.html"></html5-block>`,
|
||||
})
|
||||
|
||||
_, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("buildUpdateBodyWithHTML5ReferenceMap() succeeded, want error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "path" {
|
||||
t.Fatalf("param = %q, want path", validationErr.Param)
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("error should preserve os.ErrNotExist cause, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -24,9 +24,7 @@ var validCommandsV2 = map[string]bool{
|
||||
"append": true,
|
||||
}
|
||||
|
||||
const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
|
||||
|
||||
const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
|
||||
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
|
||||
|
||||
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
||||
func v2UpdateFlags() []common.Flag {
|
||||
@@ -117,20 +115,13 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content")
|
||||
}
|
||||
}
|
||||
if content != "" {
|
||||
_, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
body, _ := buildUpdateBodyWithReferenceMap(runtime)
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
return common.NewDryRunAPI().
|
||||
PUT(apiPath).
|
||||
@@ -143,7 +134,7 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
body, err := buildUpdateBodyWithReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,696 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
html5BlockTag = "html5-block"
|
||||
html5BlockPathAttr = "path"
|
||||
html5BlockDataRefAttr = "data-ref"
|
||||
html5BlockDataAttr = "data"
|
||||
html5BlockReferenceRoot = "doc-fetch-resources"
|
||||
html5BlockReferenceMaxRaw = 1024
|
||||
)
|
||||
|
||||
var (
|
||||
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
|
||||
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
|
||||
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
)
|
||||
|
||||
type html5BlockReferenceEntry struct {
|
||||
Data string `json:"data,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry
|
||||
|
||||
type docsV2WriteInput struct {
|
||||
Content string
|
||||
ReferenceMap map[string]interface{}
|
||||
}
|
||||
|
||||
type html5BlockAttr struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
type html5BlockStartTag struct {
|
||||
Attrs []html5BlockAttr
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildCreateBody(runtime)
|
||||
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
||||
return body, nil
|
||||
}
|
||||
input, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["content"] = buildCreateContentWithBody(runtime, input.Content)
|
||||
if len(input.ReferenceMap) > 0 {
|
||||
body["reference_map"] = input.ReferenceMap
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildUpdateBody(runtime)
|
||||
input, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.Content != "" {
|
||||
body["content"] = input.Content
|
||||
}
|
||||
if len(input.ReferenceMap) > 0 {
|
||||
body["reference_map"] = input.ReferenceMap
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("reference-map") && runtime.Str("content") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content").WithParam("--reference-map")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDocsV2ContentReferenceMap(runtime *common.RuntimeContext) (docsV2WriteInput, error) {
|
||||
input := docsV2WriteInput{Content: runtime.Str("content")}
|
||||
if raw := runtime.Str("reference-map"); strings.TrimSpace(raw) != "" {
|
||||
refMap, err := parseReferenceMapObject(raw, "--reference-map")
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
input.ReferenceMap = refMap
|
||||
}
|
||||
return prepareDocsV2WriteInput(runtime, input)
|
||||
}
|
||||
|
||||
func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteInput) (docsV2WriteInput, error) {
|
||||
refMap := cloneReferenceMapObject(input.ReferenceMap)
|
||||
html5RefMap, err := html5ReferenceMapFromObject(refMap)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
|
||||
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
if err := resolveReferenceMapPaths(runtime, html5RefMap); err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
refMap = mergeHTML5ReferenceMap(refMap, html5RefMap)
|
||||
return docsV2WriteInput{
|
||||
Content: content,
|
||||
ReferenceMap: refMap,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseReferenceMapObject(raw string, label string) (map[string]interface{}, error) {
|
||||
if len(bytes.TrimSpace([]byte(raw))) == 0 || string(bytes.TrimSpace([]byte(raw))) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var refMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
|
||||
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
|
||||
}
|
||||
return refMap, nil
|
||||
}
|
||||
|
||||
func parseHTML5BlockReferenceMapBytes(raw []byte, label string) (html5BlockReferenceMap, error) {
|
||||
if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var refMap html5BlockReferenceMap
|
||||
if err := json.Unmarshal(raw, &refMap); err != nil {
|
||||
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
|
||||
}
|
||||
return compactReferenceMap(refMap), nil
|
||||
}
|
||||
|
||||
func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string, content string, refMap html5BlockReferenceMap) (string, html5BlockReferenceMap, error) {
|
||||
if !strings.Contains(content, "<html5-block") {
|
||||
return content, compactReferenceMap(refMap), nil
|
||||
}
|
||||
if err := validateHTML5BlockWriteElementBodies(format, content); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
refMap = cloneReferenceMap(refMap)
|
||||
if refMap == nil {
|
||||
refMap = html5BlockReferenceMap{}
|
||||
}
|
||||
ensureReferenceGroup(refMap, html5BlockTag)
|
||||
nextRef := nextHTML5BlockRef(refMap)
|
||||
|
||||
rewrite := func(segment string) (string, error) {
|
||||
return rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
|
||||
tag, err := parseHTML5BlockStartTag(raw)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("invalid html5-block tag: %v", err).WithParam("html5-block")
|
||||
}
|
||||
if tag.hasAttr(html5BlockDataAttr) {
|
||||
return "", common.ValidationErrorf("html5-block data is reserved for SDK internals; use data-ref with reference_map or path=\"@relative.html\"").WithParam("html5-block")
|
||||
}
|
||||
|
||||
pathValue, hasPath := tag.attr(html5BlockPathAttr)
|
||||
dataRef, hasDataRef := tag.attr(html5BlockDataRefAttr)
|
||||
if hasPath && hasDataRef {
|
||||
return "", common.ValidationErrorf("html5-block cannot contain both path and data-ref").WithParam("html5-block")
|
||||
}
|
||||
if hasDataRef {
|
||||
ref := strings.TrimSpace(dataRef)
|
||||
if ref == "" {
|
||||
return "", common.ValidationErrorf("html5-block data-ref cannot be empty").WithParam("data-ref")
|
||||
}
|
||||
if _, ok := refMap[html5BlockTag][ref]; !ok {
|
||||
return "", common.ValidationErrorf("reference_map.%s.%s is required for html5-block data-ref", html5BlockTag, ref).WithParam("reference_map")
|
||||
}
|
||||
return tag.render(false), nil
|
||||
}
|
||||
if !hasPath {
|
||||
return "", common.ValidationErrorf("html5-block requires path=\"@relative.html\" or data-ref with reference_map").WithParam("html5-block")
|
||||
}
|
||||
|
||||
data, err := readHTML5BlockPath(runtime, pathValue, "html5-block path")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ref := nextRef()
|
||||
refMap[html5BlockTag][ref] = html5BlockReferenceEntry{Data: data}
|
||||
tag.removeAttrs(html5BlockPathAttr, html5BlockDataRefAttr, html5BlockDataAttr)
|
||||
tag.Attrs = append(tag.Attrs, html5BlockAttr{Name: html5BlockDataRefAttr, Value: ref})
|
||||
return tag.render(false), nil
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
out string
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(format) == "markdown" {
|
||||
out = applyOutsideCodeFences(content, func(segment string) string {
|
||||
if err != nil {
|
||||
return segment
|
||||
}
|
||||
outSegment, rewriteErr := rewrite(segment)
|
||||
if rewriteErr != nil {
|
||||
err = rewriteErr
|
||||
return segment
|
||||
}
|
||||
return outSegment
|
||||
})
|
||||
} else {
|
||||
out, err = rewrite(content)
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return out, compactReferenceMap(refMap), nil
|
||||
}
|
||||
|
||||
func validateHTML5BlockWriteElementBodies(format string, content string) error {
|
||||
validateSegment := func(segment string) error {
|
||||
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) < 4 || match[2] < 0 || match[3] < 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(segment[match[2]:match[3]]) != "" {
|
||||
return common.ValidationErrorf("html5-block content must be loaded from path=\"@relative.html\" or reference_map; remove content between <html5-block> and </html5-block>").WithParam("html5-block")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return validateSegment(content)
|
||||
}
|
||||
|
||||
var validateErr error
|
||||
_ = applyOutsideCodeFences(content, func(segment string) string {
|
||||
if validateErr != nil {
|
||||
return segment
|
||||
}
|
||||
validateErr = validateSegment(segment)
|
||||
return segment
|
||||
})
|
||||
return validateErr
|
||||
}
|
||||
|
||||
func processHTML5BlockReferenceMapForFetch(runtime *common.RuntimeContext, format string, docToken string, data map[string]interface{}) error {
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
content, _ := doc["content"].(string)
|
||||
if !hasProcessableHTML5Block(format, content) {
|
||||
return nil
|
||||
}
|
||||
|
||||
refMap, err := referenceMapFromDocument(doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
group := refMap[html5BlockTag]
|
||||
if group == nil {
|
||||
return common.ValidationErrorf("document.reference_map.%s is required for fetched html5-block content", html5BlockTag).WithParam("reference_map")
|
||||
}
|
||||
|
||||
if err := validateFetchedHTML5BlockRefs(format, content, refMap); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changed := false
|
||||
for ref, entry := range group {
|
||||
if entry.Data == "" || len([]byte(entry.Data)) <= html5BlockReferenceMaxRaw {
|
||||
continue
|
||||
}
|
||||
relPath, err := writeHTML5BlockReferenceFile(runtime, docToken, ref, entry.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.Data = ""
|
||||
entry.Path = "@" + filepath.ToSlash(relPath)
|
||||
group[ref] = entry
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
doc["reference_map"] = refMap
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func referenceMapFromDocument(doc map[string]interface{}) (html5BlockReferenceMap, error) {
|
||||
raw, ok := doc["reference_map"]
|
||||
if !ok || raw == nil {
|
||||
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
|
||||
}
|
||||
refMap, err := referenceMapFromValue(raw, "document.reference_map")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(refMap) == 0 {
|
||||
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
|
||||
}
|
||||
return refMap, nil
|
||||
}
|
||||
|
||||
func referenceMapFromValue(value interface{}, label string) (html5BlockReferenceMap, error) {
|
||||
if typed, ok := value.(html5BlockReferenceMap); ok {
|
||||
return compactReferenceMap(typed), nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam("reference_map").WithCause(err)
|
||||
}
|
||||
return parseHTML5BlockReferenceMapBytes(raw, label)
|
||||
}
|
||||
|
||||
func validateFetchedHTML5BlockRefs(format string, content string, refMap html5BlockReferenceMap) error {
|
||||
validateSegment := func(segment string) error {
|
||||
_, err := rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
|
||||
tag, parseErr := parseHTML5BlockStartTag(raw)
|
||||
if parseErr != nil {
|
||||
return raw, common.ValidationErrorf("invalid html5-block tag in fetched content: %v", parseErr).WithParam("html5-block")
|
||||
}
|
||||
ref, ok := tag.attr(html5BlockDataRefAttr)
|
||||
if !ok || strings.TrimSpace(ref) == "" {
|
||||
return raw, common.ValidationErrorf("fetched html5-block is missing data-ref; cannot resolve HTML reference").WithParam("html5-block")
|
||||
}
|
||||
ref = strings.TrimSpace(ref)
|
||||
if _, ok := refMap[html5BlockTag][ref]; !ok {
|
||||
return raw, common.ValidationErrorf("document.reference_map.%s.%s is missing; cannot resolve html5-block. Re-run fetch or check that the upstream document.reference_map field includes this ref.", html5BlockTag, ref).WithParam("reference_map")
|
||||
}
|
||||
return raw, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return validateSegment(content)
|
||||
}
|
||||
var validateErr error
|
||||
_ = applyOutsideCodeFences(content, func(segment string) string {
|
||||
if validateErr != nil {
|
||||
return segment
|
||||
}
|
||||
validateErr = validateSegment(segment)
|
||||
return segment
|
||||
})
|
||||
return validateErr
|
||||
}
|
||||
|
||||
func resolveReferenceMapPaths(runtime *common.RuntimeContext, refMap html5BlockReferenceMap) error {
|
||||
for typ, group := range refMap {
|
||||
for ref, entry := range group {
|
||||
if strings.TrimSpace(entry.Path) == "" {
|
||||
continue
|
||||
}
|
||||
if entry.Data != "" {
|
||||
return common.ValidationErrorf("reference_map.%s.%s must use either data or path, not both", typ, ref).WithParam("reference_map")
|
||||
}
|
||||
data, err := readHTML5BlockPath(runtime, entry.Path, fmt.Sprintf("reference_map.%s.%s.path", typ, ref))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.Data = data
|
||||
entry.Path = ""
|
||||
group[ref] = entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readHTML5BlockPath(runtime *common.RuntimeContext, pathValue string, label string) (string, error) {
|
||||
pathRaw := strings.TrimSpace(pathValue)
|
||||
if !strings.HasPrefix(pathRaw, "@") {
|
||||
return "", common.ValidationErrorf("%s %q must start with @, for example @widget.html", label, pathValue).WithParam("path")
|
||||
}
|
||||
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
|
||||
if relPath == "" {
|
||||
return "", common.ValidationErrorf("%s cannot be empty after @", label).WithParam("path")
|
||||
}
|
||||
clean := filepath.Clean(relPath)
|
||||
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", common.ValidationErrorf("%s %q must be a relative path within the current working directory", label, pathValue).WithParam("path")
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(clean)) != ".html" {
|
||||
return "", common.ValidationErrorf("%s %q must point to a .html file", label, pathValue).WithParam("path")
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("%s %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", label, clean, err).WithParam("path").WithCause(err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func hasProcessableHTML5Block(format string, content string) bool {
|
||||
if !strings.Contains(content, "<html5-block") {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return true
|
||||
}
|
||||
found := false
|
||||
_ = applyOutsideCodeFences(content, func(segment string) string {
|
||||
if strings.Contains(segment, "<html5-block") {
|
||||
found = true
|
||||
}
|
||||
return segment
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func applyOutsideCodeFences(content string, fn func(segment string) string) string {
|
||||
var out strings.Builder
|
||||
var segment strings.Builder
|
||||
inFence := false
|
||||
|
||||
flush := func() {
|
||||
if segment.Len() == 0 {
|
||||
return
|
||||
}
|
||||
out.WriteString(fn(segment.String()))
|
||||
segment.Reset()
|
||||
}
|
||||
|
||||
for _, line := range strings.SplitAfter(content, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
||||
if !inFence {
|
||||
flush()
|
||||
inFence = true
|
||||
} else {
|
||||
inFence = false
|
||||
}
|
||||
out.WriteString(line)
|
||||
continue
|
||||
}
|
||||
if inFence {
|
||||
out.WriteString(line)
|
||||
} else {
|
||||
segment.WriteString(line)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func cloneReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
|
||||
if len(refMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(html5BlockReferenceMap, len(refMap))
|
||||
for typ, group := range refMap {
|
||||
if len(group) == 0 {
|
||||
continue
|
||||
}
|
||||
outGroup := make(map[string]html5BlockReferenceEntry, len(group))
|
||||
for ref, entry := range group {
|
||||
outGroup[ref] = entry
|
||||
}
|
||||
out[typ] = outGroup
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneReferenceMapObject(refMap map[string]interface{}) map[string]interface{} {
|
||||
if len(refMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, len(refMap))
|
||||
for key, value := range refMap {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func html5ReferenceMapFromObject(refMap map[string]interface{}) (html5BlockReferenceMap, error) {
|
||||
if len(refMap) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
group, ok := refMap[html5BlockTag]
|
||||
if !ok || group == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return referenceMapFromValue(map[string]interface{}{html5BlockTag: group}, "reference_map."+html5BlockTag)
|
||||
}
|
||||
|
||||
func mergeHTML5ReferenceMap(refMap map[string]interface{}, html5RefMap html5BlockReferenceMap) map[string]interface{} {
|
||||
group := html5RefMap[html5BlockTag]
|
||||
if len(group) == 0 {
|
||||
return refMap
|
||||
}
|
||||
if refMap == nil {
|
||||
refMap = map[string]interface{}{}
|
||||
}
|
||||
refMap[html5BlockTag] = group
|
||||
return refMap
|
||||
}
|
||||
|
||||
func compactReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
|
||||
if len(refMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(html5BlockReferenceMap, len(refMap))
|
||||
for typ, group := range refMap {
|
||||
if len(group) == 0 {
|
||||
continue
|
||||
}
|
||||
out[typ] = group
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ensureReferenceGroup(refMap html5BlockReferenceMap, typ string) {
|
||||
if refMap[typ] == nil {
|
||||
refMap[typ] = map[string]html5BlockReferenceEntry{}
|
||||
}
|
||||
}
|
||||
|
||||
func nextHTML5BlockRef(refMap html5BlockReferenceMap) func() string {
|
||||
next := 1
|
||||
return func() string {
|
||||
for {
|
||||
ref := fmt.Sprintf("html5_%d", next)
|
||||
next++
|
||||
if _, exists := refMap[html5BlockTag][ref]; !exists {
|
||||
return ref
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeHTML5BlockReferenceFile(runtime *common.RuntimeContext, docToken string, ref string, html string) (string, error) {
|
||||
if !isSafeHTML5BlockResourceName(docToken) {
|
||||
return "", common.ValidationErrorf("document_id %q cannot be used as a resource directory name", docToken).WithParam("document_id")
|
||||
}
|
||||
if !isSafeHTML5BlockResourceName(ref) {
|
||||
return "", common.ValidationErrorf("html5-block data-ref %q cannot be used as a file name", ref).WithParam("data-ref")
|
||||
}
|
||||
relPath := filepath.Join(html5BlockReferenceRoot, docToken, ref+".html")
|
||||
data := []byte(html)
|
||||
_, err := runtime.FileIO().Save(relPath, fileio.SaveOptions{
|
||||
ContentType: "text/html; charset=utf-8",
|
||||
ContentLength: int64(len(data)),
|
||||
}, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return "", common.ValidationErrorf("cannot write html5-block reference file %q: %v", relPath, err).WithParam("reference_map").WithCause(err)
|
||||
}
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot write html5-block reference file %q: %v", relPath, err).WithCause(err)
|
||||
}
|
||||
return relPath, nil
|
||||
}
|
||||
|
||||
func isSafeHTML5BlockResourceName(name string) bool {
|
||||
return name != "." && name != ".." && html5BlockSafeNamePattern.MatchString(name)
|
||||
}
|
||||
|
||||
func rewriteHTML5BlockStartTags(content string, fn func(raw string) (string, error)) (string, error) {
|
||||
var rewriteErr error
|
||||
out := html5BlockStartTagPattern.ReplaceAllStringFunc(content, func(raw string) string {
|
||||
if rewriteErr != nil {
|
||||
return raw
|
||||
}
|
||||
rewritten, err := fn(raw)
|
||||
if err != nil {
|
||||
rewriteErr = err
|
||||
return raw
|
||||
}
|
||||
return rewritten
|
||||
})
|
||||
if rewriteErr != nil {
|
||||
return "", rewriteErr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
selfClosing := strings.HasSuffix(trimmed, "/>")
|
||||
decoder := xml.NewDecoder(strings.NewReader(raw))
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return html5BlockStartTag{}, err
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != html5BlockTag {
|
||||
return html5BlockStartTag{}, fmt.Errorf("expected <%s>, got <%s>", html5BlockTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
attrs := make([]html5BlockAttr, 0, len(start.Attr))
|
||||
for _, attr := range start.Attr {
|
||||
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
|
||||
}
|
||||
return html5BlockStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
|
||||
}
|
||||
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
return attr.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) hasAttr(name string) bool {
|
||||
_, ok := t.attr(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (t *html5BlockStartTag) removeAttrs(names ...string) {
|
||||
remove := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
remove[name] = struct{}{}
|
||||
}
|
||||
attrs := t.Attrs[:0]
|
||||
for _, attr := range t.Attrs {
|
||||
if _, ok := remove[attr.Name]; ok {
|
||||
continue
|
||||
}
|
||||
attrs = append(attrs, attr)
|
||||
}
|
||||
t.Attrs = attrs
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
b.WriteString(html5BlockTag)
|
||||
for _, attr := range t.Attrs {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(attr.Name)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeXMLAttr(attr.Value))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
if selfClosing {
|
||||
b.WriteString("/>")
|
||||
} else {
|
||||
b.WriteByte('>')
|
||||
}
|
||||
if t.SelfClosing && !selfClosing {
|
||||
b.WriteString("</")
|
||||
b.WriteString(html5BlockTag)
|
||||
b.WriteByte('>')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLAttr(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
b.WriteString("&")
|
||||
case '<':
|
||||
b.WriteString("<")
|
||||
case '>':
|
||||
b.WriteString(">")
|
||||
case '"':
|
||||
b.WriteString(""")
|
||||
case '\'':
|
||||
b.WriteString("'")
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
for name, flags := range map[string][]common.Flag{
|
||||
"create": v2CreateFlags(),
|
||||
"update": v2UpdateFlags(),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
flag := findDocsTestFlag(flags, "reference-map")
|
||||
if flag.Name == "" {
|
||||
t.Fatal("reference-map flag not found")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("reference-map flag should be public")
|
||||
}
|
||||
if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
|
||||
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
|
||||
}
|
||||
if !strings.Contains(flag.Desc, "@reference-map.json") {
|
||||
t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsV2InputFlagIsNotAvailable(t *testing.T) {
|
||||
for name, flags := range map[string][]common.Flag{
|
||||
"create": v2CreateFlags(),
|
||||
"update": v2UpdateFlags(),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == "input" {
|
||||
t.Fatalf("%s should not expose input flag", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateV2ReferenceMapPreservesGenericGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
||||
"command": "append",
|
||||
"content": `<p><widget data-ref="r1"></widget></p>`,
|
||||
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
})
|
||||
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("buildUpdateBodyWithHTML5ReferenceMap: %v", err)
|
||||
}
|
||||
|
||||
refMap, ok := body["reference_map"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
|
||||
}
|
||||
widget, _ := refMap["widget"].(map[string]interface{})
|
||||
r1, _ := widget["r1"].(map[string]interface{})
|
||||
if got := r1["label"]; got != "widget-ref-value" {
|
||||
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("widget.html", []byte("<html><body>hello</body></html>"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<title>demo</title><html5-block path="@widget.html"></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
|
||||
t.Fatalf("content was not rewritten with data-ref: %s", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>hello</body></html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
if _, ok := body["resources"]; ok {
|
||||
t.Fatalf("request body must not use resources: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name {
|
||||
return flag
|
||||
}
|
||||
}
|
||||
return common.Flag{}
|
||||
}
|
||||
|
||||
func hasDocsTestInput(flag common.Flag, input string) bool {
|
||||
for _, item := range flag.Input {
|
||||
if item == input {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestDocsUpdateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("widget.html", []byte("<section>updated</section>"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-update"))
|
||||
stub := registerDocsAIStub(reg, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_doc", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"revision_id": float64(2),
|
||||
"new_blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": "html5-block",
|
||||
"block_id": "blk_html5",
|
||||
"block_token": "boardXXXX",
|
||||
},
|
||||
},
|
||||
},
|
||||
"result": "success",
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsUpdate, []string{
|
||||
"+update",
|
||||
"--api-version", "v2",
|
||||
"--doc", "doxcn_doc",
|
||||
"--command", "append",
|
||||
"--content", `<html5-block path="@widget.html"></html5-block>`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
|
||||
t.Fatalf("content = %q", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<section>updated</section>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if blocks, _ := doc["new_blocks"].([]interface{}); len(blocks) != 1 {
|
||||
t.Fatalf("new_blocks not preserved in stdout: %#v", doc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2HTML5BlockKeepsSmallReferenceMapInline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_fetch",
|
||||
"revision_id": float64(3),
|
||||
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
|
||||
"reference_map": map[string]interface{}{
|
||||
"html5-block": map[string]interface{}{
|
||||
"html5_1": map[string]interface{}{"data": "<html><main>fetched</main></html>"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"tips": "must_read_html_code",
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--api-version", "v2",
|
||||
"--doc", "doxcn_fetch",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
|
||||
if _, err := os.Stat(written); err == nil {
|
||||
t.Fatalf("small html should stay inline, got file %s", written)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if got := doc["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
|
||||
t.Fatalf("content should keep data-ref: %s", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><main>fetched</main></html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
if _, ok := doc["resources"]; ok {
|
||||
t.Fatalf("fetch output must not use resources: %#v", doc)
|
||||
}
|
||||
if _, ok := data["suggestions"]; ok {
|
||||
t.Fatalf("CLI must not add suggestions; service tips is enough: %#v", data["suggestions"])
|
||||
}
|
||||
if got := data["tips"]; got != "must_read_html_code" {
|
||||
t.Fatalf("tips should be preserved from service response, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2HTML5BlockLargeReferenceMapUsesPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
largeHTML := "<html><main>" + strings.Repeat("x", html5BlockReferenceMaxRaw+1) + "</main></html>"
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-large"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_fetch",
|
||||
"revision_id": float64(3),
|
||||
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
|
||||
"reference_map": map[string]interface{}{
|
||||
"html5-block": map[string]interface{}{
|
||||
"html5_1": map[string]interface{}{"data": largeHTML},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--api-version", "v2",
|
||||
"--doc", "doxcn_fetch",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
|
||||
raw, err := os.ReadFile(written)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error: %v", written, err)
|
||||
}
|
||||
if string(raw) != largeHTML {
|
||||
t.Fatalf("materialized html = %q", raw)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if got := doc["content"].(string); strings.Contains(got, `path="@`) || !strings.Contains(got, `data-ref="html5_1"`) {
|
||||
t.Fatalf("content should keep data-ref and not path: %s", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
|
||||
entry := refMap[html5BlockTag]["html5_1"]
|
||||
if entry.Data != "" || entry.Path != "@doc-fetch-resources/doxcn_fetch/html5_1.html" {
|
||||
t.Fatalf("large html should be represented as path, got %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockReferenceMapAdvancedInput(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
|
||||
"--reference-map", `{"html5-block":{"html5_1":{"data":"<html></html>"}}}`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
|
||||
t.Fatalf("content = %q", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html></html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockReferenceMapFromFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("reference-map.json", []byte(`{"html5-block":{"html5_1":{"data":"<html>from file</html>"}}}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(reference-map.json) error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
|
||||
"--reference-map", "@reference-map.json",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html>from file</html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsMissingReferenceMap(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `reference_map.html5-block.html5_1 is required`) {
|
||||
t.Fatalf("expected missing reference_map error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsInternalDataAttr(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block data="PGh0bWw+PC9odG1sPg=="></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `html5-block data is reserved for SDK internals`) {
|
||||
t.Fatalf("expected internal data attr error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block path="@missing.html"></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `html5-block path "missing.html" cannot be read from the current working directory`) {
|
||||
t.Fatalf("expected path read error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("widget.html", []byte("<section>from file</section>"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block path="@widget.html"><section>inline</section></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `html5-block content must be loaded from path="@relative.html"`) {
|
||||
t.Fatalf("expected inline content error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2MissingHTML5BlockReferenceFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-missing"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_fetch",
|
||||
"revision_id": float64(3),
|
||||
"content": `<docx><html5-block data-ref="html5_missing"></html5-block></docx>`,
|
||||
"reference_map": map[string]interface{}{
|
||||
"html5-block": map[string]interface{}{
|
||||
"html5_1": map[string]interface{}{"data": "<html></html>"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--api-version", "v2",
|
||||
"--doc", "doxcn_fetch",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "Re-run fetch or check that the upstream document.reference_map field includes this ref") {
|
||||
t.Fatalf("expected missing reference_map error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTML5BlockMarkdownCodeFenceIsIgnored(t *testing.T) {
|
||||
for _, fence := range []string{"```", "~~~"} {
|
||||
t.Run(fence, func(t *testing.T) {
|
||||
content := fence + "xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n" + fence + "\n"
|
||||
if hasProcessableHTML5Block("markdown", content) {
|
||||
t.Fatalf("html5-block inside markdown code fence should be ignored")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHTML5BlockReferenceFileRejectsDotNames(t *testing.T) {
|
||||
runtime := newFetchShortcutTestRuntime(t, "", nil)
|
||||
tests := []struct {
|
||||
name string
|
||||
docToken string
|
||||
ref string
|
||||
want string
|
||||
}{
|
||||
{name: "dot doc token", docToken: ".", ref: "html5_1", want: "document_id"},
|
||||
{name: "dotdot doc token", docToken: "..", ref: "html5_1", want: "document_id"},
|
||||
{name: "dot ref", docToken: "doxcn_fetch", ref: ".", want: "data-ref"},
|
||||
{name: "dotdot ref", docToken: "doxcn_fetch", ref: "..", want: "data-ref"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := writeHTML5BlockReferenceFile(runtime, tt.docToken, tt.ref, "<html></html>")
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("writeHTML5BlockReferenceFile() error = %v, want %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTML5BlockWriteContentMarkdownRaw(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("widget.html", []byte("<html><body>markdown</body></html>"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--doc-format", "markdown",
|
||||
"--content", "before\n<html5-block path=\"@widget.html\"></html5-block>\nafter",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
|
||||
t.Fatalf("content was not rewritten: %s", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>markdown</body></html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func registerDocsAIStub(reg *httpmock.Registry, method string, url string, data map[string]interface{}) *httpmock.Stub {
|
||||
stub := &httpmock.Stub{
|
||||
Method: method,
|
||||
URL: url,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
return stub
|
||||
}
|
||||
|
||||
func decodeRequestBody(t *testing.T, raw []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(bytes.TrimSpace(raw), &body); err != nil {
|
||||
t.Fatalf("decode request body: %v\n%s", err, raw)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func decodeHTML5ReferenceMap(t *testing.T, raw interface{}) html5BlockReferenceMap {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal reference_map: %v\n%#v", err, raw)
|
||||
}
|
||||
var refMap html5BlockReferenceMap
|
||||
if err := json.Unmarshal(data, &refMap); err != nil {
|
||||
t.Fatalf("decode reference_map: %v\n%s", err, data)
|
||||
}
|
||||
return refMap
|
||||
}
|
||||
@@ -37,16 +37,11 @@ const (
|
||||
)
|
||||
|
||||
type drivePullItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
ErrorClass string `json:"error_class,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type drivePullTarget struct {
|
||||
@@ -194,9 +189,6 @@ var DrivePull = common.Shortcut{
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
if drivePullHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
targetFile := remoteFiles[rel]
|
||||
downloadToken := targetFile.DownloadToken
|
||||
itemFileToken := targetFile.ItemFileToken
|
||||
@@ -212,9 +204,13 @@ var DrivePull = common.Shortcut{
|
||||
// pre-existing file under --if-exists=skip silently
|
||||
// hides the conflict. Surface as a failure.
|
||||
if info.IsDir() {
|
||||
conflictErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "local path is a directory, remote is a regular file: %s", target)
|
||||
item, _ := drivePullFailedItem(rel, itemFileToken, itemSourceID, "failed", "local", conflictErr)
|
||||
items = append(items, item)
|
||||
items = append(items, drivePullItem{
|
||||
RelPath: rel,
|
||||
FileToken: itemFileToken,
|
||||
SourceID: itemSourceID,
|
||||
Action: "failed",
|
||||
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
|
||||
})
|
||||
failed++
|
||||
downloadFailed++
|
||||
continue
|
||||
@@ -227,14 +223,9 @@ var DrivePull = common.Shortcut{
|
||||
}
|
||||
|
||||
if err := drivePullDownload(ctx, runtime, downloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
item, terminal := drivePullFailedItem(rel, itemFileToken, itemSourceID, "failed", "download", err)
|
||||
items = append(items, item)
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()})
|
||||
failed++
|
||||
downloadFailed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "downloaded"})
|
||||
@@ -260,8 +251,7 @@ var DrivePull = common.Shortcut{
|
||||
for _, absPath := range localAbsPaths {
|
||||
rel, relErr := filepath.Rel(safeRoot, absPath)
|
||||
if relErr != nil {
|
||||
item, _ := drivePullFailedItem(absPath, "", "", "delete_failed", "delete_local", relErr)
|
||||
items = append(items, item)
|
||||
items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
@@ -281,9 +271,7 @@ var DrivePull = common.Shortcut{
|
||||
// acceptable here. Shortcuts cannot import internal/vfs
|
||||
// directly (depguard rule shortcuts-no-vfs).
|
||||
if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above
|
||||
deleteErr := errs.NewInternalError(errs.SubtypeFileIO, "delete local %q: %s", rel, err).WithCause(err)
|
||||
item, _ := drivePullFailedItem(rel, "", "", "delete_failed", "delete_local", deleteErr)
|
||||
items = append(items, item)
|
||||
items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
@@ -298,7 +286,6 @@ var DrivePull = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_local": deletedLocal,
|
||||
"aborted": drivePullHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -330,32 +317,6 @@ var DrivePull = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err error) (drivePullItem, bool) {
|
||||
decision := driveClassifyBatchFailure(err)
|
||||
item := drivePullItem{
|
||||
RelPath: relPath,
|
||||
FileToken: fileToken,
|
||||
SourceID: sourceID,
|
||||
Action: action,
|
||||
Error: err.Error(),
|
||||
Phase: phase,
|
||||
ErrorClass: decision.Class,
|
||||
Code: decision.Code,
|
||||
Subtype: decision.Subtype,
|
||||
Retryable: driveBoolPtr(decision.Retryable),
|
||||
}
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func drivePullHasTerminalFailure(items []drivePullItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// drivePullDownload streams one Drive file into the local mirror target and
|
||||
// then best-effort aligns the local mtime to Drive's modified_time.
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
||||
|
||||
@@ -1032,66 +1032,6 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePullAbortsAfterDownloadForbidden(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
|
||||
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_a/download",
|
||||
Status: http.StatusForbidden,
|
||||
RawBody: []byte("forbidden"),
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDrivePullPartialFailure(t, err)
|
||||
|
||||
summary, items := splitDrivePullStdout(t, stdout.Bytes())
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a.txt" || item["phase"] != "download" || item["error_class"] != "permission_denied" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(http.StatusForbidden) || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure classification: %#v", item)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("local", "b.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("b.txt should not be downloaded after terminal permission failure; stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef is the
|
||||
// regression for the "link/.." escape applied to --delete-local — the
|
||||
// most dangerous variant, since the bug would otherwise let the kernel
|
||||
|
||||
@@ -29,25 +29,12 @@ const (
|
||||
)
|
||||
|
||||
type drivePushItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Version string `json:"version,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
ErrorClass string `json:"error_class,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
type driveBatchFailureDecision struct {
|
||||
Class string
|
||||
Code int
|
||||
Subtype string
|
||||
Retryable bool
|
||||
Terminal bool
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Version string `json:"version,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||
@@ -261,14 +248,9 @@ var DrivePush = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
item, terminal := drivePushFailedItem(relDir, "", "failed", "create_folder", 0, ensureErr)
|
||||
items = append(items, item)
|
||||
items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"})
|
||||
@@ -284,9 +266,6 @@ var DrivePush = common.Shortcut{
|
||||
|
||||
for _, rel := range localPaths {
|
||||
localFile := localFiles[rel]
|
||||
if uploadFailed && drivePushHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
|
||||
if entry, ok := remoteFiles[rel]; ok {
|
||||
if drivePushShouldSkipExisting(localFile, entry, ifExists) {
|
||||
@@ -296,14 +275,9 @@ var DrivePush = common.Shortcut{
|
||||
}
|
||||
parentToken, parentErr := drivePushEnsureParentToken(ctx, runtime, folderToken, rel, folderCache)
|
||||
if parentErr != nil {
|
||||
item, terminal := drivePushFailedItem(rel, entry.FileToken, "failed", "create_folder", localFile.Size, parentErr)
|
||||
items = append(items, item)
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "failed", SizeBytes: localFile.Size, Error: parentErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, parentToken)
|
||||
@@ -327,14 +301,9 @@ var DrivePush = common.Shortcut{
|
||||
if failedToken == "" {
|
||||
failedToken = entry.FileToken
|
||||
}
|
||||
item, terminal := drivePushFailedItem(rel, failedToken, "failed", "upload", localFile.Size, upErr)
|
||||
items = append(items, item)
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size})
|
||||
@@ -345,26 +314,16 @@ var DrivePush = common.Shortcut{
|
||||
parentRel := drivePushParentRel(rel)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
item, terminal := drivePushFailedItem(rel, "", "failed", "create_folder", localFile.Size, ensureErr)
|
||||
items = append(items, item)
|
||||
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
if upErr != nil {
|
||||
item, terminal := drivePushFailedItem(rel, "", "failed", "upload", localFile.Size, upErr)
|
||||
items = append(items, item)
|
||||
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size})
|
||||
@@ -391,11 +350,7 @@ var DrivePush = common.Shortcut{
|
||||
}
|
||||
sort.Strings(remoteRelPaths)
|
||||
|
||||
abortDelete := false
|
||||
for _, rel := range remoteRelPaths {
|
||||
if abortDelete {
|
||||
break
|
||||
}
|
||||
keepToken := ""
|
||||
if _, ok := localFiles[rel]; ok {
|
||||
if chosen, ok := remoteFiles[rel]; ok {
|
||||
@@ -407,14 +362,8 @@ var DrivePush = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
|
||||
items = append(items, item)
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
|
||||
failed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
||||
abortDelete = true
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
|
||||
@@ -429,7 +378,6 @@ var DrivePush = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_remote": deletedRemote,
|
||||
"aborted": drivePushHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -559,91 +507,6 @@ func drivePushShouldSkipSmart(localFile drivePushLocalFile, remoteFile driveRemo
|
||||
return cmp >= 0
|
||||
}
|
||||
|
||||
func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int64, err error) (drivePushItem, bool) {
|
||||
decision := driveClassifyBatchFailure(err)
|
||||
item := drivePushItem{
|
||||
RelPath: relPath,
|
||||
FileToken: fileToken,
|
||||
Action: action,
|
||||
SizeBytes: sizeBytes,
|
||||
Error: err.Error(),
|
||||
Phase: phase,
|
||||
ErrorClass: decision.Class,
|
||||
Code: decision.Code,
|
||||
Subtype: decision.Subtype,
|
||||
Retryable: driveBoolPtr(decision.Retryable),
|
||||
}
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func driveBoolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
decision := driveBatchFailureDecision{Class: "unknown", Retryable: errs.IsRetryable(err)}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return decision
|
||||
}
|
||||
decision.Code = problem.Code
|
||||
decision.Subtype = string(problem.Subtype)
|
||||
decision.Retryable = problem.Retryable
|
||||
|
||||
switch {
|
||||
case problem.Category == errs.CategoryAuthorization && problem.Code == 99991672:
|
||||
decision.Class = "app_scope_missing"
|
||||
decision.Terminal = true
|
||||
case problem.Category == errs.CategoryAuthorization && problem.Code == 99991679:
|
||||
decision.Class = "user_scope_missing"
|
||||
decision.Terminal = true
|
||||
case problem.Category == errs.CategoryAuthorization && problem.Subtype == errs.SubtypePermissionDenied:
|
||||
decision.Class = "permission_denied"
|
||||
decision.Terminal = true
|
||||
case problem.Category == errs.CategoryNetwork && problem.Code == http.StatusForbidden:
|
||||
decision.Class = "permission_denied"
|
||||
decision.Terminal = true
|
||||
case problem.Subtype == errs.SubtypeInvalidParameters || problem.Code == 1061002:
|
||||
decision.Class = "invalid_api_parameters"
|
||||
decision.Terminal = true
|
||||
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
|
||||
decision.Class = "rate_limited"
|
||||
decision.Terminal = true
|
||||
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
|
||||
decision.Class = "file_size_limit"
|
||||
case problem.Code == 1062009:
|
||||
decision.Class = "upload_size_mismatch"
|
||||
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
|
||||
decision.Class = "remote_not_found"
|
||||
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
||||
decision.Class = "server_error"
|
||||
decision.Terminal = true
|
||||
case problem.Subtype == errs.SubtypeFailedPrecondition:
|
||||
decision.Class = "local_file_changed"
|
||||
default:
|
||||
decision.Class = string(problem.Subtype)
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
func drivePushHasTerminalFailure(items []drivePushItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveTerminalBatchErrorClass(errorClass string) bool {
|
||||
switch errorClass {
|
||||
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
|
||||
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
|
||||
@@ -737,12 +600,6 @@ func drivePushEnsureParentToken(ctx context.Context, runtime *common.RuntimeCont
|
||||
// the three-step prepare/part/finish flow, which mirrors drive +upload's
|
||||
// existing multipart logic.
|
||||
func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
||||
if err := drivePushValidateUploadRequest(file, existingToken, parentToken); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := drivePushVerifyLocalSnapshot(runtime, file); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if file.Size > common.MaxDriveMediaUploadSinglePartSize {
|
||||
token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken)
|
||||
// Multipart finish does not return version on the existing
|
||||
@@ -755,44 +612,6 @@ func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, fi
|
||||
return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken)
|
||||
}
|
||||
|
||||
func drivePushValidateUploadRequest(file drivePushLocalFile, existingToken, parentToken string) error {
|
||||
if strings.TrimSpace(file.FileName) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: file name is empty", file.RelPath)
|
||||
}
|
||||
if file.Size < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: file size is negative", file.RelPath)
|
||||
}
|
||||
if strings.TrimSpace(parentToken) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: parent folder token is empty", file.RelPath)
|
||||
}
|
||||
if err := validate.ResourceName(parentToken, "parent_node"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: %s", file.RelPath, err)
|
||||
}
|
||||
if existingToken != "" {
|
||||
if err := validate.ResourceName(existingToken, "file_token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot overwrite %q: %s", file.RelPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func drivePushVerifyLocalSnapshot(runtime *common.RuntimeContext, file drivePushLocalFile) error {
|
||||
info, err := runtime.FileIO().Stat(file.OpenPath)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s is no longer readable: %v", file.RelPath, err).WithCause(err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s is no longer a regular file", file.RelPath)
|
||||
}
|
||||
if info.Size() != file.Size {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s snapshot size no longer matches", file.RelPath)
|
||||
}
|
||||
if modTimer, ok := info.(interface{ ModTime() time.Time }); ok && !modTimer.ModTime().Equal(file.ModTime) {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s snapshot modtime no longer matches", file.RelPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
||||
f, err := runtime.FileIO().Open(file.OpenPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,10 +5,8 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -16,14 +14,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// countingOpenProvider wraps a fileio.Provider and counts FileIO.Open
|
||||
@@ -656,82 +652,6 @@ func TestDrivePushDeleteRemoteSkipsOnlineDocs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
|
||||
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/tok_a",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061004,
|
||||
"msg": "forbidden",
|
||||
},
|
||||
})
|
||||
// No DELETE stub for tok_b: terminal delete failure must stop before it.
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI *output.PartialFailureError, got %T %v", err, err)
|
||||
}
|
||||
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if got := summary["deleted_remote"]; got != float64(0) {
|
||||
t.Fatalf("summary.deleted_remote = %v, want 0", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["action"] != "delete_failed" || item["phase"] != "delete" || item["error_class"] != "permission_denied" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1061004) || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["file_token"] == "tok_b" {
|
||||
t.Fatalf("terminal delete failure must abort before tok_b, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
@@ -966,22 +886,21 @@ func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) {
|
||||
t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, pfErr.Code)
|
||||
}
|
||||
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Errorf("summary.failed = %v, want 1", got)
|
||||
out := stdout.String()
|
||||
// summary.failed should reflect the missing version; summary.uploaded
|
||||
// should not pretend the overwrite succeeded.
|
||||
if !strings.Contains(out, `"failed": 1`) {
|
||||
t.Errorf("expected failed=1, got: %s", out)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
if got, _ := items[0]["error"].(string); !strings.Contains(got, "no version") {
|
||||
t.Errorf("items[0].error = %q, want missing-version message", got)
|
||||
if !strings.Contains(out, "no version") {
|
||||
t.Errorf("expected error about missing version in items[].error, got: %s", out)
|
||||
}
|
||||
// Pin the token-stability contract: the failed item must surface the
|
||||
// token returned by upload_all (tok_keep_new), NOT the fallback
|
||||
// entry.FileToken (tok_keep). Without this, a regression that always
|
||||
// uses entry.FileToken on failure would slip through.
|
||||
if got := items[0]["file_token"]; got != "tok_keep_new" {
|
||||
t.Errorf("items[0].file_token = %v, want tok_keep_new", got)
|
||||
if !strings.Contains(out, `"file_token": "tok_keep_new"`) {
|
||||
t.Errorf("expected failed item to surface upload_all's returned file_token (tok_keep_new), got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,313 +962,24 @@ func TestDrivePushOverwritePartialSuccessSurfacesReturnedToken(t *testing.T) {
|
||||
t.Fatalf("expected ExitAPI from *output.PartialFailureError, got %T %v", err, err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Partial failure reports an ok:false result envelope on stdout (not a
|
||||
// misleading ok:true) while still carrying BOTH the succeeded and failed
|
||||
// items — consistent with the pre-change payload. The failed side is
|
||||
// asserted via "failed": 1 and the succeeded side via tok_keep_partial.
|
||||
envelope := decodeDrivePushStdout(t, stdout.Bytes())
|
||||
if envelope.OK {
|
||||
t.Fatalf("partial failure must emit ok=false; stdout=%s", stdout.String())
|
||||
if !strings.Contains(out, `"ok": false`) {
|
||||
t.Errorf("partial failure must emit an ok:false result envelope, got: %s", out)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Errorf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
if !strings.Contains(out, `"failed": 1`) {
|
||||
t.Errorf("expected failed=1, got: %s", out)
|
||||
}
|
||||
// The freshly returned token must be the one in items[].file_token,
|
||||
// not the stale entry.FileToken (tok_keep_old).
|
||||
if got := items[0]["file_token"]; got != "tok_keep_partial" {
|
||||
t.Errorf("items[0].file_token = %v, want tok_keep_partial", got)
|
||||
if !strings.Contains(out, `"file_token": "tok_keep_partial"`) {
|
||||
t.Errorf("expected items[].file_token to surface upload_all's returned token (tok_keep_partial), got: %s", out)
|
||||
}
|
||||
if got := items[0]["file_token"]; got == "tok_keep_old" {
|
||||
t.Errorf("must NOT fall back to entry.FileToken when upload_all returned a token; item=%#v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061002,
|
||||
"msg": "params error.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "invalid_api_parameters" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1061002) || item["subtype"] != "invalid_parameters" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b.txt" {
|
||||
t.Fatalf("terminal upload params error must abort before b.txt, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll a: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "Access denied. One of the following scopes is required: [drive:drive, space:folder:create].",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "app_scope_missing" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(99991672) || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b" {
|
||||
t.Fatalf("missing folder-create scope must abort before b, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
localPath := filepath.Join("local", "changing.txt")
|
||||
if err := os.WriteFile(localPath, []byte("before"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if err := os.WriteFile(localPath, []byte("after-change"), 0o644); err != nil {
|
||||
t.Fatalf("mutate local file: %v", err)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != false {
|
||||
t.Fatalf("summary.aborted = %v, want false", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["error_class"] != "local_file_changed" || item["subtype"] != "failed_precondition" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["error"].(string); !strings.Contains(got, "local file changed during push") {
|
||||
t.Fatalf("items[0].error = %q, want local-change message", got)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "httpmock: no stub") {
|
||||
t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
problemErr := drivePushVerifyLocalSnapshot(common.TestNewRuntimeContext(&cobra.Command{Use: "drive +push"}, &core.CliConfig{}), drivePushLocalFile{
|
||||
RelPath: "missing.txt",
|
||||
OpenPath: filepath.Join("local", "missing.txt"),
|
||||
FileName: "missing.txt",
|
||||
Size: 1,
|
||||
ModTime: time.Now(),
|
||||
})
|
||||
problem, ok := errs.ProblemOf(problemErr)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(snapshot error) ok=false, err=%T %v", problemErr, problemErr)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("snapshot error subtype = %q, want %q", problem.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("snapshot error category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
if errors.Unwrap(problemErr) == nil {
|
||||
t.Fatalf("snapshot error cause was not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDetectsSameSizeLocalFileChangedBeforeUpload(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
localPath := filepath.Join("local", "changing.txt")
|
||||
if err := os.WriteFile(localPath, []byte("before"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
originalModTime := time.Unix(100, 0)
|
||||
changedModTime := time.Unix(200, 0)
|
||||
if err := os.Chtimes(localPath, originalModTime, originalModTime); err != nil {
|
||||
t.Fatalf("Chtimes original: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if err := os.WriteFile(localPath, []byte("AFTER!"), 0o644); err != nil {
|
||||
t.Fatalf("mutate local file: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(localPath, changedModTime, changedModTime); err != nil {
|
||||
t.Fatalf("Chtimes changed: %v", err)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "changing.txt" || item["error_class"] != "local_file_changed" || item["subtype"] != "failed_precondition" {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["error"].(string); !strings.Contains(got, "snapshot modtime no longer matches") {
|
||||
t.Fatalf("items[0].error = %q, want modtime mismatch", got)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "httpmock: no stub") {
|
||||
t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String())
|
||||
if strings.Contains(out, `"file_token": "tok_keep_old"`) {
|
||||
t.Errorf("must NOT fall back to entry.FileToken when upload_all returned a token; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1483,32 +1113,6 @@ func TestDrivePushExitsZeroOnCleanRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type drivePushStdoutEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Summary map[string]interface{} `json:"summary"`
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeDrivePushStdout(t *testing.T, stdout []byte) drivePushStdoutEnvelope {
|
||||
t.Helper()
|
||||
var envelope drivePushStdoutEnvelope
|
||||
if err := json.Unmarshal(stdout, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
func splitDrivePushStdout(t *testing.T, stdout []byte) (map[string]interface{}, []map[string]interface{}) {
|
||||
t.Helper()
|
||||
envelope := decodeDrivePushStdout(t, stdout)
|
||||
if envelope.Data.Summary == nil {
|
||||
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
|
||||
}
|
||||
return envelope.Data.Summary, envelope.Data.Items
|
||||
}
|
||||
|
||||
// TestDrivePushUploadsSiblingWhenRemoteSameNameIsNativeDoc pins the
|
||||
// behavior when a local regular file shares its rel_path with a Lark
|
||||
// native cloud document on Drive (sheet/docx/bitable/...).
|
||||
|
||||
@@ -6,7 +6,6 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -18,13 +17,6 @@ const (
|
||||
secureLabelUpdateScope = "docs:secure_label:write_only"
|
||||
)
|
||||
|
||||
type secureLabelOperation string
|
||||
|
||||
const (
|
||||
secureLabelOperationList secureLabelOperation = "list"
|
||||
secureLabelOperationUpdate secureLabelOperation = "update"
|
||||
)
|
||||
|
||||
var secureLabelTypes = permApplyTypes
|
||||
|
||||
// DriveSecureLabelList lists secure labels available to the current user.
|
||||
@@ -36,9 +28,6 @@ var DriveSecureLabelList = common.Shortcut{
|
||||
Scopes: []string{secureLabelReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Tips: []string{
|
||||
"Use the `id` field from this command as --label-id for +secure-label-update; do not use the display name.",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
@@ -64,7 +53,7 @@ var DriveSecureLabelList = common.Shortcut{
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return decorateSecureLabelError(err, secureLabelOperationList)
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(data, nil, nil)
|
||||
return nil
|
||||
@@ -79,21 +68,13 @@ var DriveSecureLabelUpdate = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{secureLabelUpdateScope},
|
||||
AuthTypes: []string{"user"},
|
||||
Tips: []string{
|
||||
"Pass the numeric label id returned by +secure-label-list; display names like Public(D) are rejected.",
|
||||
"Downgrading a secure label may require approval; retrying the same request will not bypass approval.",
|
||||
"When updating many files, serialize requests and back off on rate_limit errors.",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes},
|
||||
{Name: "label-id", Desc: "secure label ID to set", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := normalizeSecureLabelID(runtime.Str("label-id"))
|
||||
_, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -101,15 +82,11 @@ var DriveSecureLabelUpdate = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Update Drive secure label").
|
||||
PATCH("/open-apis/drive/v2/files/:file_token/secure_label").
|
||||
Params(map[string]interface{}{"type": docType}).
|
||||
Body(map[string]interface{}{"id": labelID}).
|
||||
Body(map[string]interface{}{"id": runtime.Str("label-id")}).
|
||||
Set("file_token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -117,18 +94,14 @@ var DriveSecureLabelUpdate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"id": labelID}
|
||||
body := map[string]interface{}{"id": runtime.Str("label-id")}
|
||||
data, err := runtime.CallAPITyped("PATCH",
|
||||
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
|
||||
map[string]interface{}{"type": docType},
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return decorateSecureLabelError(err, secureLabelOperationUpdate)
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
@@ -149,70 +122,3 @@ func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]inter
|
||||
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
|
||||
return resolvePermApplyTarget(raw, explicitType)
|
||||
}
|
||||
|
||||
// normalizeSecureLabelID trims a label id and rejects display names before the
|
||||
// request reaches Drive, where they otherwise surface as opaque JSON errors.
|
||||
func normalizeSecureLabelID(raw string) (string, error) {
|
||||
labelID := strings.TrimSpace(raw)
|
||||
if labelID == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id is required").
|
||||
WithParam("--label-id")
|
||||
}
|
||||
for _, r := range labelID {
|
||||
if r < '0' || r > '9' {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id must be a numeric secure label ID, not a display name: %q", raw).
|
||||
WithParam("--label-id").
|
||||
WithHint("run `lark-cli drive +secure-label-list` and pass the numeric `id` value; do not pass label names like `Public(D)`")
|
||||
}
|
||||
}
|
||||
return labelID, nil
|
||||
}
|
||||
|
||||
// decorateSecureLabelError appends command-aware recovery guidance while
|
||||
// preserving upstream/classifier hints already attached to the typed error.
|
||||
func decorateSecureLabelError(err error, operation secureLabelOperation) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
guidance := secureLabelErrorGuidance(p.Code, operation)
|
||||
if guidance == "" {
|
||||
return err
|
||||
}
|
||||
if p.Hint == "" {
|
||||
p.Hint = guidance
|
||||
} else if !strings.Contains(p.Hint, guidance) {
|
||||
p.Hint = p.Hint + "; " + guidance
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// secureLabelErrorGuidance returns recovery guidance for secure-label API
|
||||
// failures whose generic code-level classification needs command context.
|
||||
func secureLabelErrorGuidance(code int, operation secureLabelOperation) string {
|
||||
switch code {
|
||||
case 99991400:
|
||||
if operation == secureLabelOperationUpdate {
|
||||
return "secure label updates are rate limited; retry later with exponential backoff and serialize bulk updates"
|
||||
}
|
||||
return "secure label listing is rate limited; retry later with exponential backoff"
|
||||
case 1063013:
|
||||
if operation == secureLabelOperationUpdate {
|
||||
return "secure label downgrade requires approval; request approval or choose a non-downgrade label before retrying"
|
||||
}
|
||||
case 1063002:
|
||||
if operation == secureLabelOperationUpdate {
|
||||
return "the current user lacks permission to update this file's secure label; use a user with file and security-label permission"
|
||||
}
|
||||
return "the current user lacks permission to list secure labels; use a user with security-label read permission"
|
||||
case 1063001, 99992402, 9499:
|
||||
if operation == secureLabelOperationUpdate {
|
||||
return "check --token/--type and pass a secure label ID from `lark-cli drive +secure-label-list`, not the display name"
|
||||
}
|
||||
return "check secure label list parameters such as --page-size, --page-token, and --lang"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
@@ -92,54 +90,13 @@ func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelList_RateLimitPreservesUpstreamHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/my_secure_labels?page_size=10",
|
||||
Status: 429,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400,
|
||||
"msg": "rate limit exceeded",
|
||||
"error": map[string]interface{}{
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "server says slow down"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
|
||||
"+secure-label-list",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected rate limit error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
|
||||
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
|
||||
}
|
||||
for _, want := range []string{"server says slow down", "secure label listing is rate limited"} {
|
||||
if !strings.Contains(apiErr.Hint, want) {
|
||||
t.Fatalf("hint missing %q: %q", want, apiErr.Hint)
|
||||
}
|
||||
}
|
||||
if strings.Contains(apiErr.Hint, "updates are rate limited") {
|
||||
t.Fatalf("list hint should not use update-specific wording: %q", apiErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
|
||||
"--label-id", " 7217780879644737539 ",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
@@ -175,7 +132,7 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
|
||||
"+secure-label-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--label-id", " 7217780879644737539 ",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
@@ -191,32 +148,7 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_RejectsDisplayNameAsLabelID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--label-id", "Public(D)",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected label id validation error")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--label-id" {
|
||||
t.Fatalf("Param = %q, want --label-id", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "+secure-label-list") {
|
||||
t.Fatalf("hint missing list guidance: %q", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsFailedPrecondition(t *testing.T) {
|
||||
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
@@ -237,78 +169,7 @@ func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsFailedPrecondition(t *te
|
||||
if err == nil {
|
||||
t.Fatal("expected 1063013 error")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Code != 1063013 {
|
||||
t.Fatalf("problem = %+v, want code=1063013 subtype=failed_precondition", validationErr.Problem)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "approval") {
|
||||
t.Fatalf("hint missing approval guidance: %q", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_InvalidJSONTypeGetsLabelHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 9499, "msg": "Invalid parameter type in json: id",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected 9499 error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeInvalidParameters || apiErr.Code != 9499 {
|
||||
t.Fatalf("problem = %+v, want code=9499 subtype=invalid_parameters", apiErr.Problem)
|
||||
}
|
||||
if !strings.Contains(apiErr.Hint, "+secure-label-list") {
|
||||
t.Fatalf("hint missing secure label list guidance: %q", apiErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_RateLimitIsRetryableWithBackoffHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
|
||||
Status: 429,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400, "msg": "rate limit exceeded",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected rate limit error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
|
||||
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
|
||||
}
|
||||
if !strings.Contains(apiErr.Hint, "backoff") {
|
||||
t.Fatalf("hint missing backoff guidance: %q", apiErr.Hint)
|
||||
if !strings.Contains(err.Error(), "Security label downgrade requires approval") {
|
||||
t.Fatalf("expected raw API error message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,21 +25,12 @@ const (
|
||||
driveSyncOnConflictAsk = "ask"
|
||||
)
|
||||
|
||||
func driveSyncActionScopes() []string {
|
||||
return []string{"drive:file:download", "drive:file:upload", "space:folder:create"}
|
||||
}
|
||||
|
||||
type driveSyncItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Direction string `json:"direction,omitempty"` // "pull" or "push"
|
||||
Error string `json:"error,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
ErrorClass string `json:"error_class,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Direction string `json:"direction,omitempty"` // "pull" or "push"
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DriveSync performs a two-way sync between a local directory and a Drive
|
||||
@@ -75,7 +66,6 @@ var DriveSync = common.Shortcut{
|
||||
"Default --on-conflict=remote-wins pulls the remote version when both sides changed a file. Use local-wins to push instead, keep-both to rename and keep both copies, or ask for interactive resolution.",
|
||||
"Pass --quick for faster best-effort diff detection using modified_time instead of SHA-256 hash (no remote file downloads needed during diffing).",
|
||||
"Because +sync acts on the diff, --quick can still pull, overwrite, or rename files when timestamps differ even if file contents are actually unchanged.",
|
||||
"Actual sync execution pre-flights download, upload, and folder-create scopes before listing or walking, so missing grants fail before any partial sync can start.",
|
||||
"Only entries with type=file are synced; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -120,8 +110,10 @@ var DriveSync = common.Shortcut{
|
||||
duplicateRemote = driveDuplicateRemoteFail
|
||||
}
|
||||
quick := runtime.Bool("quick")
|
||||
if err := runtime.EnsureScopes(driveSyncActionScopes()); err != nil {
|
||||
return err
|
||||
if !quick {
|
||||
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
@@ -270,6 +262,18 @@ var DriveSync = common.Shortcut{
|
||||
var pulled, pushed, skipped, failed int
|
||||
items := make([]driveSyncItem, 0)
|
||||
|
||||
if quick && driveSyncNeedsDownloadScope(newRemote, modified, conflictResolutions) {
|
||||
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
plannedUploads := driveSyncPlannedUploadPaths(newLocal, modified, conflictResolutions)
|
||||
if len(plannedUploads) > 0 {
|
||||
if err := runtime.EnsureScopes([]string{"drive:file:upload"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Build push infrastructure: local walk for push + remote views + folder cache.
|
||||
folderCache := map[string]string{"": folderToken}
|
||||
for relDir, entry := range remoteFolders {
|
||||
@@ -283,18 +287,20 @@ var DriveSync = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
if driveSyncNeedsCreateScope(plannedUploads, localDirs, folderCache) {
|
||||
if err := runtime.EnsureScopes([]string{"space:folder:create"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror local directory structure first (same as +push), so
|
||||
// empty local directories are not silently dropped.
|
||||
for _, relDir := range localDirs {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
items = append(items, driveSyncItem{RelPath: relDir, Action: "failed", Direction: "push", Error: ensureErr.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
@@ -304,9 +310,6 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2a. Pull new_remote files.
|
||||
for _, entry := range newRemote {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||
if !ok {
|
||||
// Non-file type (doc, shortcut, etc.) — skip.
|
||||
@@ -314,13 +317,8 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
target := filepath.Join(rootRelToCwd, entry.RelPath)
|
||||
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", err)
|
||||
items = append(items, item)
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
|
||||
failed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
|
||||
@@ -329,9 +327,6 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2b. Push new_local files.
|
||||
for _, entry := range newLocal {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
localFile, ok := pushLocalFiles[entry.RelPath]
|
||||
if !ok {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "push", Error: "local file disappeared during sync"})
|
||||
@@ -341,20 +336,14 @@ var DriveSync = common.Shortcut{
|
||||
parentRel := drivePushParentRel(entry.RelPath)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: ensureErr.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
if upErr != nil {
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, token, "failed", "push", "upload", upErr)
|
||||
items = append(items, item)
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: upErr.Error()})
|
||||
failed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "uploaded", Direction: "push"})
|
||||
@@ -363,9 +352,6 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2c. Resolve modified files by --on-conflict strategy.
|
||||
for _, entry := range modified {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
remoteFile := remoteFiles[entry.RelPath]
|
||||
localFile, hasLocal := pushLocalFiles[entry.RelPath]
|
||||
if !hasLocal {
|
||||
@@ -393,13 +379,8 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
target := filepath.Join(rootRelToCwd, entry.RelPath)
|
||||
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", err)
|
||||
items = append(items, item)
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
|
||||
failed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
|
||||
@@ -415,8 +396,7 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
||||
if parentErr != nil {
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
items = append(items, item)
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: parentErr.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
@@ -431,13 +411,8 @@ var DriveSync = common.Shortcut{
|
||||
if failedToken == "" {
|
||||
failedToken = existingToken
|
||||
}
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, failedToken, "failed", "push", "upload", upErr)
|
||||
items = append(items, item)
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: failedToken, Action: "failed", Direction: "push", Error: upErr.Error()})
|
||||
failed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "overwritten", Direction: "push"})
|
||||
@@ -458,8 +433,7 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
suffixedRel, err := relPathWithUniqueFileTokenSuffix(entry.RelPath, remoteFile.FileToken, occupied)
|
||||
if err != nil {
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "conflict", "conflict", err)
|
||||
items = append(items, item)
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
@@ -467,9 +441,7 @@ var DriveSync = common.Shortcut{
|
||||
oldAbsPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath))
|
||||
newAbsPath := filepath.Join(safeRoot, filepath.FromSlash(suffixedRel))
|
||||
if err := os.Rename(oldAbsPath, newAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
|
||||
renameErr := errs.NewInternalError(errs.SubtypeFileIO, "rename local: %s", err).WithCause(err)
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "conflict", "conflict", renameErr)
|
||||
items = append(items, item)
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: fmt.Sprintf("rename local: %s", err)})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
@@ -482,30 +454,19 @@ var DriveSync = common.Shortcut{
|
||||
if rollbackErr != nil {
|
||||
errMsg += "; rollback failed: " + rollbackErr.Error()
|
||||
}
|
||||
notFoundErr := errs.NewAPIError(errs.SubtypeNotFound, "%s", errMsg)
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "pull", "download", notFoundErr)
|
||||
items = append(items, item)
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: errMsg})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(rootRelToCwd, entry.RelPath)
|
||||
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
downloadErr := err
|
||||
rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath)
|
||||
errMsg := err.Error()
|
||||
if rollbackErr != nil {
|
||||
errMsg += "; rollback failed: " + rollbackErr.Error()
|
||||
}
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", downloadErr)
|
||||
if rollbackErr != nil {
|
||||
item.Error = errMsg
|
||||
}
|
||||
items = append(items, item)
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: errMsg})
|
||||
failed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "renamed_local", Direction: "conflict"})
|
||||
@@ -531,7 +492,6 @@ var DriveSync = common.Shortcut{
|
||||
"pushed": pushed,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"aborted": driveSyncHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -560,32 +520,6 @@ func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[
|
||||
return remoteFiles
|
||||
}
|
||||
|
||||
func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, err error) (driveSyncItem, bool) {
|
||||
decision := driveClassifyBatchFailure(err)
|
||||
item := driveSyncItem{
|
||||
RelPath: relPath,
|
||||
FileToken: fileToken,
|
||||
Action: action,
|
||||
Direction: direction,
|
||||
Error: err.Error(),
|
||||
Phase: phase,
|
||||
ErrorClass: decision.Class,
|
||||
Code: decision.Code,
|
||||
Subtype: decision.Subtype,
|
||||
Retryable: driveBoolPtr(decision.Retryable),
|
||||
}
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// driveSyncAskConflict prompts the user for a conflict resolution strategy
|
||||
// for a single file. Returns the strategy string, or empty string if the
|
||||
// user chose to skip.
|
||||
@@ -624,6 +558,51 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin
|
||||
}
|
||||
}
|
||||
|
||||
func driveSyncNeedsDownloadScope(newRemote, modified []driveStatusEntry, conflictResolutions map[string]string) bool {
|
||||
if len(newRemote) > 0 {
|
||||
return true
|
||||
}
|
||||
for _, entry := range modified {
|
||||
switch conflictResolutions[entry.RelPath] {
|
||||
case driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveSyncPlannedUploadPaths(newLocal, modified []driveStatusEntry, conflictResolutions map[string]string) []string {
|
||||
planned := make([]string, 0, len(newLocal)+len(modified))
|
||||
for _, entry := range newLocal {
|
||||
planned = append(planned, entry.RelPath)
|
||||
}
|
||||
for _, entry := range modified {
|
||||
if conflictResolutions[entry.RelPath] == driveSyncOnConflictLocalWins {
|
||||
planned = append(planned, entry.RelPath)
|
||||
}
|
||||
}
|
||||
return planned
|
||||
}
|
||||
|
||||
func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderCache map[string]string) bool {
|
||||
for _, relPath := range uploadPaths {
|
||||
parentRel := drivePushParentRel(relPath)
|
||||
if parentRel == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := folderCache[parentRel]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Empty local directories also need create_folder if not already on Drive.
|
||||
for _, relDir := range localDirs {
|
||||
if _, ok := folderCache[relDir]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error {
|
||||
if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
|
||||
if info.IsDir() {
|
||||
|
||||
@@ -311,71 +311,6 @@ func TestDriveSyncRemoteWinsPullsNewRemoteAndPushesNewLocal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSyncAbortsAfterNewRemoteDownloadForbidden(t *testing.T) {
|
||||
syncTestConfig := &core.CliConfig{
|
||||
AppID: "drive-sync-forbidden", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "100"},
|
||||
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file", "modified_time": "100"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_a/download",
|
||||
Status: http.StatusForbidden,
|
||||
RawBody: []byte("forbidden"),
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSync, []string{
|
||||
"+sync",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--quick",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDriveSyncPartialFailure(t, err)
|
||||
|
||||
summary := driveSyncStdoutSummary(t, stdout.Bytes())
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
items := driveSyncStdoutItems(t, stdout.Bytes())
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item.RelPath != "a.txt" || item.Direction != "pull" || item.Phase != "download" || item.ErrorClass != "permission_denied" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item.Code != http.StatusForbidden || item.Retryable == nil || *item.Retryable {
|
||||
t.Fatalf("unexpected failure classification: %#v", item)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("local", "b.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("b.txt should not be downloaded after terminal permission failure; stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveSyncLocalWinsPushesOverRemote verifies that --on-conflict=local-wins
|
||||
// pushes the local version over the remote file.
|
||||
func TestDriveSyncLocalWinsPushesOverRemote(t *testing.T) {
|
||||
@@ -1617,11 +1552,11 @@ func TestDriveSyncDryRunQuickAcceptsMetadataOnlyScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSyncPreflightsActionScopesBeforeListing(t *testing.T) {
|
||||
func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) {
|
||||
syncTestConfig := &core.CliConfig{
|
||||
AppID: "drive-sync-download-scope-only", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, syncTestConfig)
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig)
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"}, nil)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
@@ -1633,6 +1568,34 @@ func TestDriveSyncPreflightsActionScopesBeforeListing(t *testing.T) {
|
||||
t.Fatalf("WriteFile a.txt: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_a/download",
|
||||
Status: 200,
|
||||
Body: []byte("remote-a"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_a/download",
|
||||
Status: 200,
|
||||
Body: []byte("remote-a"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSync, []string{
|
||||
"+sync",
|
||||
"--local-dir", "local",
|
||||
@@ -1640,30 +1603,11 @@ func TestDriveSyncPreflightsActionScopesBeforeListing(t *testing.T) {
|
||||
"--on-conflict", "remote-wins",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected action-scope preflight to reject download-only scope\nstdout: %s", stdout.String())
|
||||
if err != nil {
|
||||
t.Fatalf("expected exact remote-wins to succeed with download-only scope, got: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if permErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Fatalf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope)
|
||||
}
|
||||
for _, scope := range []string{"drive:file:upload", "space:folder:create"} {
|
||||
found := false
|
||||
for _, missing := range permErr.MissingScopes {
|
||||
if missing == scope {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("MissingScopes = %v, want %s", permErr.MissingScopes, scope)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout.String(), "folder_root") {
|
||||
t.Fatalf("preflight should fail before remote listing, got stdout: %s", stdout.String())
|
||||
if strings.Contains(strings.ToLower(stdout.String()), "missing_scope") {
|
||||
t.Fatalf("should not surface missing_scope, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2608,6 +2552,30 @@ func TestDriveSyncAskConflictRemoteShortForms(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveSyncNeedsDownloadScopeReturnsFalseForLocalWinsOnly verifies
|
||||
// that driveSyncNeedsDownloadScope returns false when there are no
|
||||
// new_remote entries and all modified entries resolve to local-wins.
|
||||
func TestDriveSyncNeedsDownloadScopeReturnsFalseForLocalWinsOnly(t *testing.T) {
|
||||
modified := []driveStatusEntry{{RelPath: "a.txt"}, {RelPath: "b.txt"}}
|
||||
resolutions := map[string]string{"a.txt": driveSyncOnConflictLocalWins, "b.txt": driveSyncOnConflictLocalWins}
|
||||
|
||||
if driveSyncNeedsDownloadScope(nil, modified, resolutions) {
|
||||
t.Fatal("expected false when no new_remote and all conflicts are local-wins")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveSyncNeedsDownloadScopeReturnsTrueForKeepBoth verifies that
|
||||
// driveSyncNeedsDownloadScope returns true when a modified entry resolves
|
||||
// to keep-both (which requires pulling the remote version).
|
||||
func TestDriveSyncNeedsDownloadScopeReturnsTrueForKeepBoth(t *testing.T) {
|
||||
modified := []driveStatusEntry{{RelPath: "a.txt"}}
|
||||
resolutions := map[string]string{"a.txt": driveSyncOnConflictKeepBoth}
|
||||
|
||||
if !driveSyncNeedsDownloadScope(nil, modified, resolutions) {
|
||||
t.Fatal("expected true when a conflict resolves to keep-both")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveSyncRemoteWinsReportsMissingPullView verifies that when a
|
||||
// modified file's rel_path is not in pullRemoteFiles during the
|
||||
// remote-wins branch, a failed item is reported instead of a panic.
|
||||
@@ -3115,19 +3083,3 @@ func driveSyncStdoutItems(t *testing.T, stdout []byte) []driveSyncItem {
|
||||
}
|
||||
return envelope.Data.Items
|
||||
}
|
||||
|
||||
func driveSyncStdoutSummary(t *testing.T, stdout []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Summary map[string]interface{} `json:"summary"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
|
||||
}
|
||||
if envelope.Data.Summary == nil {
|
||||
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
|
||||
}
|
||||
return envelope.Data.Summary
|
||||
}
|
||||
|
||||
@@ -16,6 +16,5 @@ func Shortcuts() []common.Shortcut {
|
||||
VCMeetingLeave,
|
||||
VCMeetingListActive,
|
||||
VCMeetingEvents,
|
||||
VCMeetingMessageSend,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,7 +838,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
|
||||
for _, shortcut := range got {
|
||||
commands = append(commands, shortcut.Command)
|
||||
}
|
||||
want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events", "+meeting-message-send"}
|
||||
want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
|
||||
if !reflect.DeepEqual(commands, want) {
|
||||
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
meetingMessageTypeText = "text"
|
||||
meetingMessageTypeReaction = "reaction"
|
||||
// Keep the client-side cap below the server-side content limit.
|
||||
meetingMessageMaxTextBytes = 48 * 1024
|
||||
meetingMessageMaxUUIDBytes = 128
|
||||
)
|
||||
|
||||
// VCMeetingMessageSend sends an in-meeting text message or reaction emoji.
|
||||
var VCMeetingMessageSend = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-message-send",
|
||||
Description: "Send an in-meeting text message or reaction emoji",
|
||||
Risk: "write",
|
||||
Scopes: []string{"vc:meeting.message:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-id", Required: true, Desc: "meeting ID to send into"},
|
||||
{Name: "msg-type", Desc: "message type: text or reaction"},
|
||||
{Name: "text", Desc: "text content when --msg-type text"},
|
||||
{Name: "emoji-type", Desc: "emoji key when --msg-type reaction, for example LOVE, THUMBSUP, VC_NoSound"},
|
||||
{Name: "uuid", Desc: "optional idempotency key"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateMeetingEventsMeetingID(runtime.Str("meeting-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateMeetingMessagePayload(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildMeetingMessageSendBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(buildMeetingMessageSendPath()).
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildMeetingMessageSendBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := runtime.CallAPITyped(http.MethodPost, buildMeetingMessageSendPath(), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "Meeting message sent.")
|
||||
if msgType := common.GetString(data, "msg_type"); msgType != "" {
|
||||
fmt.Fprintf(w, " Type: %s\n", msgType)
|
||||
} else if msgType, _ := body["msg_type"].(string); msgType != "" {
|
||||
fmt.Fprintf(w, " Type: %s\n", msgType)
|
||||
}
|
||||
if uuid := common.GetString(data, "uuid"); uuid != "" {
|
||||
fmt.Fprintf(w, " UUID: %s\n", uuid)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildMeetingMessageSendPath() string {
|
||||
return "/open-apis/vc/v1/bots/message"
|
||||
}
|
||||
|
||||
func buildMeetingMessageSendBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
msgType, err := validateMeetingMessagePayload(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"meeting_id": strings.TrimSpace(runtime.Str("meeting-id")),
|
||||
"msg_type": msgType,
|
||||
}
|
||||
switch msgType {
|
||||
case meetingMessageTypeText:
|
||||
body["content"] = strings.TrimSpace(runtime.Str("text"))
|
||||
case meetingMessageTypeReaction:
|
||||
body["content"] = strings.TrimSpace(runtime.Str("emoji-type"))
|
||||
}
|
||||
if uuid := strings.TrimSpace(runtime.Str("uuid")); uuid != "" {
|
||||
body["uuid"] = uuid
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func validateMeetingMessagePayload(runtime *common.RuntimeContext) (string, error) {
|
||||
msgType, err := resolveMeetingMessageType(runtime)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if msgType == meetingMessageTypeText {
|
||||
text := strings.TrimSpace(runtime.Str("text"))
|
||||
if len(text) > meetingMessageMaxTextBytes {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, fmt.Sprintf("--text is too long; max %d bytes", meetingMessageMaxTextBytes)).WithParam("--text")
|
||||
}
|
||||
}
|
||||
if uuid := strings.TrimSpace(runtime.Str("uuid")); len(uuid) > meetingMessageMaxUUIDBytes {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, fmt.Sprintf("--uuid is too long; max %d bytes", meetingMessageMaxUUIDBytes)).WithParam("--uuid")
|
||||
}
|
||||
return msgType, nil
|
||||
}
|
||||
|
||||
func resolveMeetingMessageType(runtime *common.RuntimeContext) (string, error) {
|
||||
msgType := strings.ToLower(strings.TrimSpace(runtime.Str("msg-type")))
|
||||
text := strings.TrimSpace(runtime.Str("text"))
|
||||
emojiType := strings.TrimSpace(runtime.Str("emoji-type"))
|
||||
|
||||
if msgType == "" {
|
||||
switch {
|
||||
case text != "" && emojiType == "":
|
||||
msgType = meetingMessageTypeText
|
||||
case text == "" && emojiType != "":
|
||||
msgType = meetingMessageTypeReaction
|
||||
default:
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type is required when both --text and --emoji-type are empty or both are set").WithParam("--msg-type")
|
||||
}
|
||||
}
|
||||
|
||||
switch msgType {
|
||||
case meetingMessageTypeText:
|
||||
if text == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--text is required when --msg-type text").WithParam("--text")
|
||||
}
|
||||
if emojiType != "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type cannot be used when --msg-type text").WithParam("--emoji-type")
|
||||
}
|
||||
case meetingMessageTypeReaction:
|
||||
if emojiType == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type is required when --msg-type reaction").WithParam("--emoji-type")
|
||||
}
|
||||
if text != "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--text cannot be used when --msg-type reaction").WithParam("--text")
|
||||
}
|
||||
default:
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type must be text or reaction").WithParam("--msg-type")
|
||||
}
|
||||
return msgType, nil
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newMeetingMessageSendRuntime() *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-id", "", "")
|
||||
cmd.Flags().String("msg-type", "", "")
|
||||
cmd.Flags().String("text", "", "")
|
||||
cmd.Flags().String("emoji-type", "", "")
|
||||
cmd.Flags().String("uuid", "", "")
|
||||
return common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
}
|
||||
|
||||
func mustSetMeetingMessageSendFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
|
||||
t.Helper()
|
||||
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("Flags().Set(%q, %q) error = %v", name, value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMeetingMessageSendValidationError(t *testing.T, err error, wantParam string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendBuildBody_Text(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "text", " hello ")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "uuid", " cid-1 ")
|
||||
|
||||
body, err := buildMeetingMessageSendBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
|
||||
}
|
||||
if body["msg_type"] != meetingMessageTypeText {
|
||||
t.Fatalf("msg_type = %v, want text", body["msg_type"])
|
||||
}
|
||||
if body["content"] != "hello" {
|
||||
t.Fatalf("content = %v, want hello", body["content"])
|
||||
}
|
||||
if body["uuid"] != "cid-1" {
|
||||
t.Fatalf("uuid = %v, want cid-1", body["uuid"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendBuildBody_Reaction(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
|
||||
|
||||
body, err := buildMeetingMessageSendBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
|
||||
}
|
||||
if body["msg_type"] != meetingMessageTypeReaction {
|
||||
t.Fatalf("msg_type = %v, want reaction", body["msg_type"])
|
||||
}
|
||||
if body["content"] != "LOVE" {
|
||||
t.Fatalf("content = %v, want LOVE", body["content"])
|
||||
}
|
||||
if _, ok := body["text"]; ok {
|
||||
t.Fatalf("text should be omitted for reaction, got %#v", body["text"])
|
||||
}
|
||||
if _, ok := body["emoji_type"]; ok {
|
||||
t.Fatalf("emoji_type should be omitted for reaction, got %#v", body["emoji_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendBuildBody_ReactionVCFeedbackKey(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "VC_NoSound")
|
||||
|
||||
body, err := buildMeetingMessageSendBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
|
||||
}
|
||||
if body["content"] != "VC_NoSound" {
|
||||
t.Fatalf("content = %v, want VC_NoSound", body["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendValidateRejectsMeetingNumber(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "123456789")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
|
||||
|
||||
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
|
||||
assertMeetingMessageSendValidationError(t, err, "--meeting-id")
|
||||
if !strings.Contains(err.Error(), "9-digit meeting number") {
|
||||
t.Fatalf("error = %v, want 9-digit meeting number hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendValidateRejectsMissingEmojiType(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
|
||||
|
||||
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
|
||||
assertMeetingMessageSendValidationError(t, err, "--emoji-type")
|
||||
if !strings.Contains(err.Error(), "--emoji-type is required") {
|
||||
t.Fatalf("error = %v, want --emoji-type required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendValidateRejectsTextMessageWithEmojiType(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "text")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
|
||||
|
||||
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
|
||||
assertMeetingMessageSendValidationError(t, err, "--emoji-type")
|
||||
if !strings.Contains(err.Error(), "--emoji-type cannot be used") {
|
||||
t.Fatalf("error = %v, want --emoji-type conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendValidateRejectsReactionMessageWithText(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
|
||||
|
||||
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
|
||||
assertMeetingMessageSendValidationError(t, err, "--text")
|
||||
if !strings.Contains(err.Error(), "--text cannot be used") {
|
||||
t.Fatalf("error = %v, want --text conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendValidateRejectsLongText(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "text", strings.Repeat("a", meetingMessageMaxTextBytes+1))
|
||||
|
||||
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
|
||||
assertMeetingMessageSendValidationError(t, err, "--text")
|
||||
if !strings.Contains(err.Error(), "--text is too long") {
|
||||
t.Fatalf("error = %v, want --text too long", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendValidateRejectsLongUUID(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "uuid", strings.Repeat("u", meetingMessageMaxUUIDBytes+1))
|
||||
|
||||
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
|
||||
assertMeetingMessageSendValidationError(t, err, "--uuid")
|
||||
if !strings.Contains(err.Error(), "--uuid is too long") {
|
||||
t.Fatalf("error = %v, want --uuid too long", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendDryRun_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingMessageSend, []string{
|
||||
"+meeting-message-send", "--dry-run", "--as", "user",
|
||||
"--meeting-id", "7651377260537433044",
|
||||
"--text", "hello",
|
||||
"--uuid", "cid-1",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/vc/v1/bots/message",
|
||||
"\"meeting_id\": \"7651377260537433044\"",
|
||||
"\"msg_type\": \"text\"",
|
||||
"\"content\": \"hello\"",
|
||||
"\"uuid\": \"cid-1\"",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendDryRun_ValidationErrorEnvelope(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
|
||||
|
||||
dryRun := VCMeetingMessageSend.DryRun(context.Background(), runtime)
|
||||
if got := dryRun.Format(); !strings.Contains(got, "--msg-type is required") {
|
||||
t.Fatalf("dry-run error = %v, want --msg-type required", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendExecute_Text(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: buildMeetingMessageSendPath(),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"msg_type": "text",
|
||||
"uuid": "cid-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, VCMeetingMessageSend, []string{
|
||||
"+meeting-message-send", "--as", "user",
|
||||
"--format", "pretty",
|
||||
"--meeting-id", "7651377260537433044",
|
||||
"--text", "hello",
|
||||
"--uuid", "cid-1",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
|
||||
t.Fatalf("failed to parse request body: %v", err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"meeting_id": "7651377260537433044",
|
||||
"msg_type": "text",
|
||||
"content": "hello",
|
||||
"uuid": "cid-1",
|
||||
} {
|
||||
if req[key] != want {
|
||||
t.Errorf("%s = %v, want %s", key, req[key], want)
|
||||
}
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Meeting message sent.",
|
||||
"Type: text",
|
||||
"UUID: cid-1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendExecute_ReactionFallsBackToRequestType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: buildMeetingMessageSendPath(),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingMessageSend, []string{
|
||||
"+meeting-message-send", "--as", "user",
|
||||
"--format", "pretty",
|
||||
"--meeting-id", "7651377260537433044",
|
||||
"--msg-type", "reaction",
|
||||
"--emoji-type", "LOVE",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
if out := stdout.String(); !strings.Contains(out, "Type: reaction") {
|
||||
t.Fatalf("output missing fallback type: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -243,7 +243,6 @@
|
||||
|
||||
默认值 / 约束:
|
||||
- `style.format` 默认 `yyyy/MM/dd` 可用格式:`yyyy/MM/dd`、`yyyy/MM/dd HH:mm`、`yyyy/MM/dd HH:mm Z`、`yyyy-MM-dd`、`yyyy-MM-dd HH:mm`、`yyyy-MM-dd HH:mm Z`、`MM-dd`、`MM/dd/yyyy`、`dd/MM/yyyy`
|
||||
- `style.format` 只控制前端显示格式;当前可配置格式最多显示到分钟,底层时间值仍可保留秒级精度。
|
||||
|
||||
常用写法:
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
|
||||
- 用户需要统计文档的**总字数 / 总字符数**(word count / character count)时,先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
|
||||
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
|
||||
- 文档内容中出现嵌入的 `<sheet>`、`<bitable>` 或 `<cite file-type="sheets|bitable">` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
|
||||
| ------------------- | -- |---------------------------------------------|
|
||||
| `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `<title>...</title>`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
|
||||
| `--content` | 视情况 | 文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
| `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) |
|
||||
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| `--command` | 是 | 操作指令(见下方指令速查表) |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。 |
|
||||
| `--pattern` | 视指令 | 匹配文本(str_replace) |
|
||||
| `--block-id` | 视指令 | 目标 block ID(block_* 操作),逗号分隔可批量删除,-1 表示末尾 |
|
||||
| `--src-block-ids` | 视指令 | 源 block ID(逗号分隔),用于 block_copy_insert_after / block_move_after |
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# 文档统计:总字数 / 总字符数
|
||||
|
||||
当用户需要统计 Docx / Wiki 文档的总字数或总字符数时,使用本 skill 附带脚本 `scripts/doc_word_stat.py`。统计口径以该脚本为准,不要改用其他方式自行计算,也不要只读取 simple 摘要后统计。
|
||||
|
||||
## 调用方式
|
||||
|
||||
在线文档使用 XML full 内容,并让脚本读取 `docs +fetch --format json` 的 envelope:
|
||||
|
||||
```bash
|
||||
lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
| python3 skills/lark-doc/scripts/doc_word_stat.py --protocol xml --lark-json --pretty
|
||||
```
|
||||
|
||||
`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。
|
||||
|
||||
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
|
||||
|
||||
```bash
|
||||
lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
| python3 skills/lark-doc/scripts/doc_word_stat.py --protocol xml --lark-json --pretty --fail-on-unsupported --fail-on-unknown
|
||||
```
|
||||
|
||||
## 如何读取结果
|
||||
|
||||
脚本输出 JSON。对用户汇报时默认只读两个核心字段:
|
||||
|
||||
- `word_count`:总字数。按语义单位统计汉字、英文单词/URL/code path、数字、中文标点;普通贴着英文的英文标点不计入,但独立 ASCII 符号、中文之间的 `/` 等以脚本结果为准。
|
||||
- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点和脚本识别的可见符号;空格不计入。
|
||||
|
||||
其余字段用于排查或解释:
|
||||
|
||||
- `breakdown`:拆分统计来源,例如 `han_chars`、`english_words`、`digits`、`chinese_punctuations`。
|
||||
- `unknown_blocks`:脚本遇到未知 XML/Markdown 块类型;通常表示需要扩展解析规则。
|
||||
- `unsupported_blocks`:脚本识别到块类型,但当前无法可靠提取可见文本。
|
||||
- `diagnostics.has_unknown` / `diagnostics.has_unsupported`:快速判断统计是否存在覆盖风险。
|
||||
|
||||
如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
|
||||
|
||||
## 输出示例
|
||||
|
||||
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"word_count": 10,
|
||||
"char_count": 15,
|
||||
"breakdown": {
|
||||
"han_chars": 7,
|
||||
"english_words": 2,
|
||||
"number_words": 0,
|
||||
"chinese_punctuations": 1,
|
||||
"english_letters": 7,
|
||||
"digits": 0,
|
||||
"english_punctuations": 0,
|
||||
"symbol_words": 0,
|
||||
"symbol_chars": 0
|
||||
},
|
||||
"protocol": "xml",
|
||||
"unknown_blocks": [],
|
||||
"unsupported_blocks": [],
|
||||
"diagnostics": {
|
||||
"has_unknown": false,
|
||||
"has_unsupported": false,
|
||||
"types": {},
|
||||
"unknown_types": {},
|
||||
"unsupported_types": {},
|
||||
"actions": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
面向用户的回复可简化为:
|
||||
|
||||
```text
|
||||
总字数:10
|
||||
总字符数:15
|
||||
```
|
||||
@@ -46,30 +46,8 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
- `<chat_card>` — `<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
|
||||
- `<sub-page-list>` — `<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
|
||||
- `<html5-block>` — 在飞书文档「HTML 块」iframe 里加载的单文件 HTML。
|
||||
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
|
||||
|
||||
## html
|
||||
|
||||
1. 写入 HTML 内容块时,把 HTML 存为本地 `.html` 文件,XML 写 `<html5-block path="@widget.html"></html5-block>`;已有 `data-ref` 时配合 `--reference-map @reference-map.json`。读取时 `<html5-block data-ref="html5_1"></html5-block>` 只是占位,必须从 `document.reference_map["html5-block"]["html5_1"].data` 读取 HTML;若 entry 是 `path`,读取对应 `@doc-fetch-resources/...html` 文件。
|
||||
2. 格式如下:
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="use-iframe" content="true">
|
||||
<meta name="html-box-height-mode" content="auto">
|
||||
<meta name="description" content="内容摘要,会导出为 html5-block 的 alt 属性,帮助模型理解该 HTML 块的用途">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
...
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
# 四、块级复制与移动
|
||||
|
||||
## 移动(block_move_after)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-vc-agent
|
||||
version: 1.0.0
|
||||
description: "飞书视频会议会中能力:用于让应用机器人真实加入或离开正在进行的会议,并读取当前身份可见的会中事件、发送会中文本消息或会中表情。适用于用户询问正在开的会议发生了什么、谁在发言、是否共享内容,或需要发现当前可读的进行中会议 ID。不负责已结束会议搜索、参会人快照、纪要、逐字稿或录制查询,这些使用 lark-vc 技能。"
|
||||
description: "飞书视频会议会中能力:用于让应用机器人真实加入或离开正在进行的会议,并读取当前身份可见的会中事件,如参会人加入/离开、发言、聊天、屏幕共享。适用于用户询问正在开的会议发生了什么、谁在发言、是否共享内容,或需要发现当前可读的进行中会议 ID。不负责已结束会议搜索、参会人快照、纪要、逐字稿或录制查询,这些使用 lark-vc 技能。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -26,7 +26,7 @@ metadata:
|
||||
本 skill 与 [`lark-vc`](../lark-vc/SKILL.md) 并列:
|
||||
|
||||
- **`lark-vc`** **负责"会后查询"**:搜索历史会议、参会人快照、纪要/逐字稿/录制
|
||||
- **`lark-vc-agent`** **负责"会中动作"**:机器人入会 / 读取进行中会议的实时事件 / 发送会中文本或会中表情 / 机器人离会
|
||||
- **`lark-vc-agent`** **负责"会中动作"**:机器人入会 / 读取进行中会议的实时事件 / 机器人离会
|
||||
|
||||
按此分工路由,避免两个 skill 语义混淆。
|
||||
|
||||
@@ -35,7 +35,6 @@ metadata:
|
||||
| "帮我入会 123456789"、"代我参会"、"让机器人进会旁听" | **本 skill** `+meeting-join` |
|
||||
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"(**进行中会议**) | **本 skill** `+meeting-events` |
|
||||
| "我/某个用户现在在哪个会里"、"给我找当前可拉事件的 meeting_id" | **本 skill** `+meeting-list-active` |
|
||||
| "在会里发一句 xx"、"提示大家 xx"、"反馈听不到/看不到/声音清楚/效果不错"(**进行中会议**) | **本 skill** `+meeting-message-send` |
|
||||
| "退出会议"、"让机器人离开" | **本 skill** `+meeting-leave` |
|
||||
| "昨天那场会有谁参加过"、"搜昨天的会"、"查纪要/逐字稿/录制" | [`lark-vc`](../lark-vc/SKILL.md) |
|
||||
| "帮我参会,结束后把纪要发到群" 等跨阶段场景 | 按序编排:本 skill(入会 → 读事件)→ 会议结束后用 [`lark-vc`](../lark-vc/SKILL.md) / [`lark-minutes`](../lark-minutes/SKILL.md) 拉纪要 → [`lark-im`](../lark-im/SKILL.md) 发群 |
|
||||
@@ -50,7 +49,7 @@ metadata:
|
||||
| 查询目标用户且应用机器人也在会中的会议 | `--as bot --user-id <user_open_id>` | `--user-id` 必须是 `ou_...`;拿到的 `meeting_id` 后续继续用 `--as bot` 读事件 |
|
||||
| 用户明确要求应用机器人入会/旁听/代参会 | `--as bot` | 这是写操作,会真实产生入会记录;返回的 `meeting.id` 后续继续用 `--as bot` |
|
||||
|
||||
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` / `+meeting-message-send` 就沿用哪种身份,除非用户明确要求切换场景(例如从“仅查询我当前会”改成“让应用机器人入会旁听”)。
|
||||
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份,除非用户明确要求切换场景(例如从“仅查询我当前会”改成“让应用机器人入会旁听”)。
|
||||
|
||||
## 核心场景
|
||||
|
||||
@@ -80,33 +79,14 @@ metadata:
|
||||
10. 用户直接问“这个会议讲了什么 / 现在讲到哪了”且上下文没有明确 `meeting_id` 时,先用用户身份发现当前会议;如果用户明确要求应用机器人视角,或上下文已经是应用机器人参会流程,再用应用身份发现。若返回多个会议,展示候选并让用户选择。
|
||||
11. 用户直接提供 **9 位会议号** 并询问会中事件/会议内容时,默认把它当作 active meeting 的筛选条件:先按当前身份查 active meetings,并在返回里匹配 `meeting_no == <9位会议号>`;匹配到唯一会议后取长数字 `meeting_id`,再用同一身份查事件。只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才改用 `+meeting-join`。
|
||||
|
||||
### 3. 发送会中文本或会中表情(写操作)
|
||||
|
||||
1. 用户明确要求在当前进行中的会议里发送提示、说明、会中表情,或反馈“听不到 / 看不到 / 声音清楚 / 效果不错”时,用 `+meeting-message-send`。
|
||||
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。
|
||||
3. 身份必须延续:`meeting_id` 来自用户身份发现,就继续 `--as user`;来自应用身份发现或应用机器人入会,就继续 `--as bot`。
|
||||
4. 文本消息使用 `--text`;会中表情 / 反馈使用 `--emoji-type`。`--emoji-type` 必须从 reference 里的完整列表中选择,大小写敏感。
|
||||
5. 支持普通 Feishu reaction emoji(如 `LOVE`、`SMILE`、`THUMBSUP`)和 4 个 VC 反馈 key(`VC_CanNotSee`、`VC_NoSound`、`VC_LooksGood`、`VC_SoundsClear`)。
|
||||
6. 不要编造列表外的 `emoji_type`,也不要把 natural language 硬编码成不存在的 key;如果用户只给语义,可在完整列表中选择最接近的 key,无法判断时先确认。
|
||||
7. 该命令只暴露会中文本和会中表情,不作为“发送绑定群消息”的默认能力;如果用户明确要发群聊,请路由到 [`lark-im`](../lark-im/SKILL.md)。
|
||||
8. 若使用应用身份发送,应用机器人必须在会中;若使用用户身份发送,当前用户必须正在该会议中。权限错误时按“应用身份权限配置检查”或“用户身份被拒绝时”处理。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-message-send --as user --meeting-id <meeting_id> --text "稍等,我在看文档"
|
||||
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type LOVE
|
||||
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type VC_NoSound
|
||||
```
|
||||
|
||||
### 4. 离开会议(写操作)
|
||||
### 3. 离开会议(写操作)
|
||||
|
||||
1. 只有用户明确要求机器人退出 / 离开 / 结束参会时,才用应用身份执行 `+meeting-leave --as bot --meeting-id <长数字 meeting_id>`;不应因任务完成而执行离会。
|
||||
2. `--meeting-id` **必须**是长数字会议 ID,通常来自 `+meeting-join` 返回的 `meeting.id`,也可以来自应用身份 `+meeting-list-active` 返回的 `meeting_id`。如果来自 list-active,必须确认应用机器人当前就在该会中。**不接受 9 位会议号**。
|
||||
3. 离会**立即生效**,机器人从会议的参会人列表中消失,对其他参会人可见;若需要重新入会,再跑一次 `+meeting-join` 即可(非真正"不可逆")。
|
||||
4. 使用与入会或 active meeting 发现相同的应用身份离会。
|
||||
|
||||
### 5. 获取当前可用的进行中会议 ID(读操作)
|
||||
### 4. 获取当前可用的进行中会议 ID(读操作)
|
||||
|
||||
1. `+meeting-list-active` 用来发现当前进行中的会议,并拿到后续 `+meeting-events` 需要的长数字 `meeting_id`。
|
||||
2. 用户身份:`lark-cli vc +meeting-list-active --as user --format json`,用于发现当前登录用户正在参加的会议;后续 `+meeting-events` 继续 `--as user`。
|
||||
@@ -115,7 +95,7 @@ lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type
|
||||
5. 如果返回多个会议,不要自动任选一个;按 `meeting_title` / `meeting_no` / `meeting_id` 展示候选,等待用户明确选择后再调用 `+meeting-events`。
|
||||
6. 如果用户给了 9 位会议号,先在 active meeting 结果中按 `meeting_no` 匹配。匹配失败时,不要自动入会;只有用户明确要求应用机器人真实入会时,才询问或执行 `+meeting-join`。
|
||||
|
||||
### 6. Agent 参会示范
|
||||
### 5. Agent 参会示范
|
||||
|
||||
```bash
|
||||
# 1. 入会,捕获 meeting.id
|
||||
@@ -156,13 +136,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
|
||||
| [`+meeting-join`](references/lark-vc-agent-meeting-join.md) | 写 | Join an in-progress meeting by 9-digit meeting number |
|
||||
| [`+meeting-list-active`](references/lark-vc-agent-meeting-list-active.md) | 读 | List active meetings and discover meeting_id for event reads |
|
||||
| [`+meeting-events`](references/lark-vc-agent-meeting-events.md) | 读 | List meeting events visible to the app agent (participant joined/left, transcript, chat, share) |
|
||||
| [`+meeting-message-send`](references/lark-vc-agent-meeting-message-send.md) | 写 | Send an in-meeting text message or reaction emoji |
|
||||
| [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md) | 写 | Leave a meeting by meeting\_id |
|
||||
|
||||
- [`+meeting-join`](references/lark-vc-agent-meeting-join.md):入参格式、写操作可见性风险、入会失败排查。
|
||||
- [`+meeting-list-active`](references/lark-vc-agent-meeting-list-active.md):用户身份和应用身份的不同返回范围。
|
||||
- [`+meeting-events`](references/lark-vc-agent-meeting-events.md):`meeting_id` 来源、身份延续、分页和错误码(10005 / 20001 / 20002)。
|
||||
- [`+meeting-message-send`](references/lark-vc-agent-meeting-message-send.md):会中文本、完整 `emoji_type` 列表、身份延续和写操作风险。
|
||||
- [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md):`meeting_id` 的来源与写操作可见性。
|
||||
|
||||
## 应用身份权限配置检查
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
# vc +meeting-message-send
|
||||
|
||||
发送会中文本消息或会中 reaction emoji。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +meeting-message-send`(调用 `POST /open-apis/vc/v1/bots/message`)。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 用户要求“在会里发一句话”“提示大家”“给当前会议发消息”。
|
||||
- 用户要求发送会中表情,例如“发个点赞”“发个 OK”“发个爱心”。
|
||||
- 用户要求表达会中反馈,例如“听不到”“看不到”“声音清楚”“效果不错”。
|
||||
- 只用于正在进行中的会议;已结束会议不支持。
|
||||
|
||||
## 身份规则
|
||||
|
||||
`meeting_id` 从哪种身份路径拿到,发送消息时就沿用哪种身份:
|
||||
|
||||
| meeting_id 来源 | 发送时身份 |
|
||||
| --- | --- |
|
||||
| `+meeting-list-active --as user` | `+meeting-message-send --as user` |
|
||||
| `+meeting-list-active --as bot --user-id <user_open_id>` | `+meeting-message-send --as bot` |
|
||||
| `+meeting-join --as bot` 返回的 `meeting.id` | `+meeting-message-send --as bot` |
|
||||
|
||||
不要把用户身份发现的 `meeting_id` 改用应用身份发送,也不要把应用身份发现的 `meeting_id` 改用用户身份发送,除非用户明确要求切换。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `--meeting-id` | 必填,长数字 `meeting_id`,不是 9 位会议号 |
|
||||
| `--msg-type` | 可选,`text` 或 `reaction`;只传 `--text` 或只传 `--emoji-type` 时可自动推断 |
|
||||
| `--text` | 文本消息内容 |
|
||||
| `--emoji-type` | 会中 reaction emoji key,大小写敏感,必须从本文“完整 `emoji_type` 列表”中选择 |
|
||||
| `--uuid` | 可选,幂等 key;不传则服务端生成 |
|
||||
|
||||
CLI 会把 `--text` 或 `--emoji-type` 统一映射到 OpenAPI 请求体的 `content` 字段;`meeting_id` 也在请求体中传递。
|
||||
|
||||
## 文本消息
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-message-send --as user --meeting-id <meeting_id> --text "稍等,我在看文档"
|
||||
```
|
||||
|
||||
文本消息会出现在会议内的文本互动区。不要把它当成绑定群消息发送能力;如果用户明确要求发到群聊,路由到 `lark-im`。
|
||||
|
||||
## 会中表情
|
||||
|
||||
会中 reaction 支持普通 Feishu reaction emoji,也支持 4 个 VC 反馈 key。
|
||||
|
||||
常见语义:
|
||||
|
||||
| 用户表达 | 推荐 `emoji_type` |
|
||||
| --- | --- |
|
||||
| 点赞、赞一下、认可 | `THUMBSUP` |
|
||||
| +1、加一、附议、同上 | `JIAYI` |
|
||||
| OK、好的 | `OK` |
|
||||
| 收到、了解 | `Get` |
|
||||
| 爱心、红心 | `HEART` |
|
||||
| 喜欢、爱了 | `LOVE` |
|
||||
| 比心 | `FINGERHEART` |
|
||||
| 看起来没问题、可以继续 | `LGTM` |
|
||||
| 搞定、已完成 | `DONE` |
|
||||
| -1、减一 | `MinusOne` |
|
||||
| 不赞同、踩 | `ThumbsDown` |
|
||||
| 听不到、没声音 | `VC_NoSound` |
|
||||
| 看不到、画面有问题 | `VC_CanNotSee` |
|
||||
| 声音清楚 | `VC_SoundsClear` |
|
||||
| 会议画面效果不错、画面看起来可以 | `VC_LooksGood` |
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type LOVE
|
||||
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type VC_NoSound
|
||||
```
|
||||
|
||||
不要编造列表外的 `emoji_type`,也不要把 mixed-case 值改成全大写,例如 `EatingFood`、`CheckMark`、`StatusInFlight` 都要按原值传。
|
||||
|
||||
如果用户给的是自然语言语义,可以在下方列表中选择语义最接近的 key;如果不确定,先向用户确认。
|
||||
|
||||
### 完整 `emoji_type` 列表
|
||||
|
||||
以下列表与 IM reaction 官方 emoji 列表保持一致,并额外包含 VC 会中特定反馈 key:
|
||||
|
||||
```text
|
||||
OK, THUMBSUP, THANKS, MUSCLE, FINGERHEART, APPLAUSE, FISTBUMP, JIAYI
|
||||
DONE, SMILE, BLUSH, LAUGH, SMIRK, LOL, FACEPALM, LOVE
|
||||
WINK, PROUD, WITTY, SMART, SCOWL, THINKING, SOB, CRY
|
||||
ERROR, NOSEPICK, HAUGHTY, SLAP, SPITBLOOD, TOASTED, GLANCE, DULL
|
||||
INNOCENTSMILE, JOYFUL, WOW, TRICK, YEAH, ENOUGH, TEARS, EMBARRASSED
|
||||
KISS, SMOOCH, DROOL, OBSESSED, MONEY, TEASE, SHOWOFF, COMFORT
|
||||
CLAP, PRAISE, STRIVE, XBLUSH, SILENT, WAVE, WHAT, FROWN
|
||||
SHY, DIZZY, LOOKDOWN, CHUCKLE, WAIL, CRAZY, WHIMPER, HUG
|
||||
BLUBBER, WRONGED, HUSKY, SHHH, SMUG, ANGRY, HAMMER, SHOCKED
|
||||
TERROR, PETRIFIED, SKULL, SWEAT, SPEECHLESS, SLEEP, DROWSY, YAWN
|
||||
SICK, PUKE, BETRAYED, HEADSET, EatingFood, MeMeMe, Sigh, Typing
|
||||
Lemon, Get, LGTM, OnIt, OneSecond, VRHeadset, YouAreTheBest, SALUTE
|
||||
SHAKE, HIGHFIVE, UPPERLEFT, ThumbsDown, SLIGHT, TONGUE, EYESCLOSED, RoarForYou
|
||||
CALF, BEAR, BULL, RAINBOWPUKE, ROSE, HEART, PARTY, LIPS
|
||||
BEER, CAKE, GIFT, CUCUMBER, Drumstick, Pepper, CANDIEDHAWS, BubbleTea
|
||||
Coffee, Yes, No, OKR, CheckMark, CrossMark, MinusOne, Hundred
|
||||
AWESOMEN, Pin, Alarm, Loudspeaker, Trophy, Fire, BOMB, Music
|
||||
XmasTree, Snowman, XmasHat, FIREWORKS, 2022, REDPACKET, FORTUNE, LUCK
|
||||
FIRECRACKER, StickyRiceBalls, HEARTBROKEN, POOP, StatusFlashOfInspiration, 18X, CLEAVER, Soccer
|
||||
Basketball, GeneralDoNotDisturb, Status_PrivateMessage, GeneralInMeetingBusy, StatusReading, StatusInFlight, GeneralBusinessTrip, GeneralWorkFromHome
|
||||
StatusEnjoyLife, GeneralTravellingCar, StatusBus, GeneralSun, GeneralMoonRest, MoonRabbit, Mooncake, JubilantRabbit
|
||||
TV, Movie, Pumpkin, BeamingFace, Delighted, ColdSweat, FullMoonFace, Partying
|
||||
GoGoGo, ThanksFace, SaluteFace, Shrug, ClownFace, HappyDragon
|
||||
VC_CanNotSee, VC_NoSound, VC_LooksGood, VC_SoundsClear
|
||||
```
|
||||
|
||||
## 9 位会议号处理
|
||||
|
||||
如果用户给的是 9 位会议号并要求发送会中消息:
|
||||
|
||||
1. 先按当前身份执行 `+meeting-list-active`。
|
||||
2. 在返回结果中按 `meeting_no` 匹配该 9 位会议号。
|
||||
3. 匹配到唯一会议后取长数字 `meeting_id`。
|
||||
4. 用发现该会议时的同一身份执行 `+meeting-message-send`。
|
||||
|
||||
匹配失败时不要自动入会。只有用户明确要求“让应用机器人入会/旁听/代参会”时,才改用 `+meeting-join`。
|
||||
|
||||
## 权限和前置条件
|
||||
|
||||
- 用户身份:当前用户必须正在该会议中。
|
||||
- 应用身份:应用机器人必须正在该会议中。
|
||||
- 会议需要开启会中智能体/Agent 能力开关。
|
||||
- 需要 `vc:meeting.message:write` 权限;应用身份还需要应用已安装、数据范围已配置。
|
||||
|
||||
应用身份权限错误时,不要引导用户反复 `auth login`。按主 skill 的“应用身份权限配置检查”处理。
|
||||
|
||||
## 相关
|
||||
|
||||
- [lark-vc-agent-meeting-list-active](lark-vc-agent-meeting-list-active.md) — 发现当前进行中会议 ID
|
||||
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 读取会中事件
|
||||
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 应用机器人入会
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBaseFieldCreateDryRunArrayCompat(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+field-create",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", gjson.Get(out, "api.0.url").String(), out)
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
|
||||
require.Equal(t, "A", gjson.Get(out, "api.0.body.name").String(), out)
|
||||
require.Equal(t, "text", gjson.Get(out, "api.0.body.type").String(), out)
|
||||
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", gjson.Get(out, "api.1.url").String(), out)
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), out)
|
||||
require.Equal(t, "B", gjson.Get(out, "api.1.body.name").String(), out)
|
||||
require.Equal(t, "text", gjson.Get(out, "api.1.body.type").String(), out)
|
||||
}
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
## Metrics
|
||||
- Denominator: 78 leaf commands
|
||||
- Covered: 19
|
||||
- Coverage: 24.4%
|
||||
- Covered: 18
|
||||
- Coverage: 23.1%
|
||||
|
||||
## Summary
|
||||
- TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`.
|
||||
- TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data.
|
||||
- TestBaseFieldCreateDryRunArrayCompat: proves `+field-create` dry-run request shape for the internal JSON-array compatibility path.
|
||||
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
|
||||
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
|
||||
- Blocked area: dashboard, field, form, record, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
|
||||
@@ -39,7 +38,7 @@
|
||||
| ✕ | base +dashboard-list | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-update | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +data-query | shortcut | | none | no data-query assertions yet |
|
||||
| ✓ | base +field-create | shortcut | base_field_dryrun_test.go::TestBaseFieldCreateDryRunArrayCompat | `--base-token`; `--table-id`; `--json`; dry-run only | request shape only |
|
||||
| ✕ | base +field-create | shortcut | | none | field workflows not covered |
|
||||
| ✕ | base +field-delete | shortcut | | none | field workflows not covered |
|
||||
| ✕ | base +field-get | shortcut | | none | field workflows not covered |
|
||||
| ✕ | base +field-list | shortcut | | none | field workflows not covered |
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
|
||||
wantExtraParam: `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`,
|
||||
wantExtraParam: `{"enable_user_cite_reference_map":true}`,
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestVCMeetingMessageSendDryRun(t *testing.T) {
|
||||
setVCMeetingMessageSendDryRunEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMsgType string
|
||||
wantContent string
|
||||
wantUUID string
|
||||
}{
|
||||
{
|
||||
name: "text",
|
||||
args: []string{
|
||||
"vc", "+meeting-message-send",
|
||||
"--meeting-id", "7651377260537433044",
|
||||
"--text", "hello from dry-run",
|
||||
"--uuid", "cid-dryrun-text",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMsgType: "text",
|
||||
wantContent: "hello from dry-run",
|
||||
wantUUID: "cid-dryrun-text",
|
||||
},
|
||||
{
|
||||
name: "reaction",
|
||||
args: []string{
|
||||
"vc", "+meeting-message-send",
|
||||
"--meeting-id", "7651377260537433044",
|
||||
"--msg-type", "reaction",
|
||||
"--emoji-type", "VC_NoSound",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMsgType: "reaction",
|
||||
wantContent: "VC_NoSound",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, int64(1), gjson.Get(out, "api.#").Int(), "stdout:\n%s", out)
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/vc/v1/bots/message", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "7651377260537433044", gjson.Get(out, "api.0.body.meeting_id").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, tt.wantMsgType, gjson.Get(out, "api.0.body.msg_type").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, tt.wantContent, gjson.Get(out, "api.0.body.content").String(), "stdout:\n%s", out)
|
||||
if tt.wantUUID == "" {
|
||||
require.False(t, gjson.Get(out, "api.0.body.uuid").Exists(), "stdout:\n%s", out)
|
||||
} else {
|
||||
require.Equal(t, tt.wantUUID, gjson.Get(out, "api.0.body.uuid").String(), "stdout:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCMeetingMessageSendDryRunRejectsLongUUID(t *testing.T) {
|
||||
setVCMeetingMessageSendDryRunEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"vc", "+meeting-message-send",
|
||||
"--meeting-id", "7651377260537433044",
|
||||
"--text", "hello from dry-run",
|
||||
"--uuid", strings.Repeat("u", 129),
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Equal(t, "--uuid", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "--uuid is too long", "stderr:\n%s", result.Stderr)
|
||||
require.Empty(t, result.Stdout)
|
||||
}
|
||||
|
||||
func setVCMeetingMessageSendDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "vc_meeting_message_send_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "vc_meeting_message_send_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
Reference in New Issue
Block a user