Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/output/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ func ErrAPI(larkCode int, msg string, detail any) *ExitError {
if errType == "permission" {
msg = fmt.Sprintf("Permission denied [%d]", larkCode)
}
if larkCode == LarkErrOwnershipMismatch {
msg = buildOwnershipRecoveryMessage(msg)
}
return &ExitError{
Code: exitCode,
Detail: &ErrDetail{
Expand Down
6 changes: 6 additions & 0 deletions internal/output/lark_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ const (
// caller already holds the requested permission, or the target type does
// not accept apply operations).
LarkErrDrivePermApplyNotApplicable = 1063007

// IM resource ownership mismatch.
LarkErrOwnershipMismatch = 231205
)

// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
Expand Down Expand Up @@ -98,6 +101,9 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
case LarkErrDrivePermApplyNotApplicable:
return ExitAPI, "invalid_params",
"this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly"

case LarkErrOwnershipMismatch:
return ExitAPI, "ownership_mismatch", buildOwnershipRecoveryHint()
}

return ExitAPI, "api_error", ""
Expand Down
7 changes: 7 additions & 0 deletions internal/output/lark_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
wantType: "invalid_params",
wantHint: "does not accept a permission-apply request",
},
{
name: "ownership mismatch",
code: LarkErrOwnershipMismatch,
wantExitCode: ExitAPI,
wantType: "ownership_mismatch",
wantHint: "messages-resources-download",
},
}

for _, tt := range tests {
Expand Down
22 changes: 22 additions & 0 deletions internal/output/ownership_recovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package output

import "strings"

const ownershipRecoveryMessagePrefix = "message send/reply failed because one or more referenced resources belong to another user"

func buildOwnershipRecoveryMessage(apiMessage string) string {
apiMessage = strings.TrimSpace(apiMessage)
if apiMessage == "" {
return ownershipRecoveryMessagePrefix
}
return ownershipRecoveryMessagePrefix + ": " + apiMessage
}

func buildOwnershipRecoveryHint() string {
return "Step 1: download each original resource only with `lark-cli im +messages-resources-download --message-id <message_id> --file-key <resource_key> --type <image|file> --output <local_path>`; use `--type image` for images and `--type file` for file/audio/video resources. `message_id` is mandatory for this download. Do not guess it, do not switch to any other download command or raw API, and do not keep retrying alternative download methods. If `message_id` is unavailable, stop and ask the user for it, or recover it from the original command context before continuing.\n" +
"Step 2: if the original command used direct media flags (`--image`, `--file`, `--audio`, `--video`, `--video-cover`), retry with the downloaded local path instead of the old resource key; `im +messages-send` and `im +messages-reply` will upload the local file automatically under the current identity.\n" +
"Step 3: if the original command used structured JSON (`--msg-type post`, `--msg-type interactive`, or `--content`), the downloaded output path cannot be embedded directly in JSON. Upload each resource first under the current identity (image -> `POST /open-apis/im/v1/images`, file/audio/video -> `POST /open-apis/im/v1/files`), replace the old `image_key` / `file_key` in JSON with the new keys, then retry the original send/reply. Repeat this for every referenced resource."
}
88 changes: 88 additions & 0 deletions internal/output/ownership_recovery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package output

import (
"strings"
"testing"
)

func checkOwnershipRecoveryHint(t *testing.T, hint string) {
t.Helper()

for _, part := range []string{
"Step 1",
"Step 2",
"Step 3",
"only with",
"im +messages-resources-download",
"--message-id <message_id>",
"--file-key <resource_key>",
"--type <image|file>",
"`--type image`",
"`--type file`",
"`message_id` is mandatory",
"Do not guess it",
"do not switch to any other download command or raw API",
"do not keep retrying alternative download methods",
"stop and ask the user for it",
"recover it from the original command context",
"--image",
"--file",
"--audio",
"--video",
"--video-cover",
"--msg-type post",
"--msg-type interactive",
"--content",
"POST /open-apis/im/v1/images",
"POST /open-apis/im/v1/files",
"image_key",
"file_key",
"im +messages-send",
"im +messages-reply",
} {
if !strings.Contains(hint, part) {
t.Fatalf("hint %q missing %q", hint, part)
}
}
}

func TestBuildOwnershipRecoveryMessage(t *testing.T) {
upstreamMessage := "Bot or User is NOT the owner of the uat resource."
got := buildOwnershipRecoveryMessage(upstreamMessage)

if !strings.Contains(got, ownershipRecoveryMessagePrefix) {
t.Fatalf("message %q missing prefix %q", got, ownershipRecoveryMessagePrefix)
}
if !strings.Contains(got, upstreamMessage) {
t.Fatalf("message %q missing upstream message %q", got, upstreamMessage)
}
}

func TestBuildOwnershipRecoveryHint(t *testing.T) {
checkOwnershipRecoveryHint(t, buildOwnershipRecoveryHint())
}

func TestErrAPI_OwnershipMismatch(t *testing.T) {
upstreamMessage := "Bot or User is NOT the owner of the uat resource."
err := ErrAPI(LarkErrOwnershipMismatch, upstreamMessage, map[string]any{"log_id": "test-log"})

if err.Code != ExitAPI {
t.Fatalf("exit code = %d, want %d", err.Code, ExitAPI)
}
if err.Detail == nil {
t.Fatal("expected detail")
}
if err.Detail.Type != "ownership_mismatch" {
t.Fatalf("type = %q, want %q", err.Detail.Type, "ownership_mismatch")
}
if got, want := err.Detail.Message, buildOwnershipRecoveryMessage(upstreamMessage); got != want {
t.Fatalf("message = %q, want %q", got, want)
}
checkOwnershipRecoveryHint(t, err.Detail.Hint)
if err.Detail.Detail == nil {
t.Fatal("expected upstream detail to be preserved")
}
}
Loading