mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
1 Commits
feat/7564d
...
test/vc_ol
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bdd7de807 |
@@ -279,7 +279,13 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
|
||||
"hint": "**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it." +
|
||||
"**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it." +
|
||||
"**Display order:** Output the URL first, then place the QR code image below the URL." +
|
||||
"**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation." +
|
||||
"For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" +
|
||||
"**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code <device_code>` yourself." +
|
||||
"**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.",
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
|
||||
@@ -1042,8 +1042,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
"final message of the turn",
|
||||
"return control to the user",
|
||||
"do not block on --device-code in the same turn",
|
||||
"After the user confirms authorization in a later step",
|
||||
"lark-cli auth login --device-code device-code",
|
||||
"come back and notify",
|
||||
"YOU must execute",
|
||||
"lark-cli auth login --device-code <device_code>",
|
||||
"Do NOT cache",
|
||||
"lark-cli auth login --no-wait --json",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
||||
|
||||
@@ -5,9 +5,6 @@ package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -38,11 +35,7 @@ var MailMessages = common.Shortcut{
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateBotMailboxNotMe(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
messageIDs := splitByComma(runtime.Str("message-ids"))
|
||||
return validateMessageIDs(messageIDs)
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
@@ -93,95 +86,3 @@ var MailMessages = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// messageIDPattern matches a single message ID after cleaning: non-empty,
|
||||
// no spaces, no brackets, no colons. Message IDs from the Lark mail API are
|
||||
// opaque strings (typically hex or alphanumeric), so any character that
|
||||
// suggests structural content (brackets, colons) is rejected.
|
||||
var messageIDPattern = regexp.MustCompile(`^[^\s\[\]:]+$`)
|
||||
|
||||
// commonEnglishWords are words that indicate the input is natural language
|
||||
// rather than opaque message IDs. The check is case-insensitive.
|
||||
var commonEnglishWords = []string{
|
||||
"the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
|
||||
"her", "was", "one", "our", "out", "get", "has", "how", "its", "may",
|
||||
"new", "now", "old", "see", "way", "who", "did", "let", "say",
|
||||
"she", "too", "use", "from", "with", "this", "that", "have",
|
||||
"will", "been", "they", "what", "about", "would", "could", "their",
|
||||
"which", "there", "these", "other", "should", "please", "message",
|
||||
"email", "subject", "fetch", "read", "list", "send", "reply", "forward",
|
||||
}
|
||||
|
||||
// validateMessageIDs validates each individual message ID after comma splitting.
|
||||
// It rejects IDs that are clearly illegal before they reach the batch_get API:
|
||||
// - empty or whitespace-only
|
||||
// - wrapped in literal quotes (stripped before further validation)
|
||||
// - look like a JSON array string
|
||||
// - contain colon separators
|
||||
// - contain spaces (likely natural language)
|
||||
// - match common English words (likely natural language)
|
||||
// - don't match a reasonable message ID pattern
|
||||
func validateMessageIDs(ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil // empty list is handled by the Execute function
|
||||
}
|
||||
var invalid []string
|
||||
for _, raw := range ids {
|
||||
if reason := validateSingleMessageID(raw); reason != "" {
|
||||
invalid = append(invalid, reason)
|
||||
}
|
||||
}
|
||||
if len(invalid) > 0 {
|
||||
return output.ErrValidation("invalid --message-ids: %s", strings.Join(invalid, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSingleMessageID returns an empty string if the ID is valid, or a
|
||||
// human-readable reason if it is invalid. It applies cleaning (quote
|
||||
// stripping) before validation.
|
||||
func validateSingleMessageID(raw string) string {
|
||||
id := strings.TrimSpace(raw)
|
||||
|
||||
// Strip surrounding literal quotes (both single and double).
|
||||
if len(id) >= 2 {
|
||||
if (id[0] == '"' && id[len(id)-1] == '"') || (id[0] == '\'' && id[len(id)-1] == '\'') {
|
||||
id = strings.TrimSpace(id[1 : len(id)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// Reject empty or whitespace-only after trim.
|
||||
if id == "" {
|
||||
return fmt.Sprintf("%q: empty or whitespace-only", raw)
|
||||
}
|
||||
|
||||
// Reject JSON array strings (e.g. "[\"id1\",\"id2\"]").
|
||||
if strings.HasPrefix(id, "[") && strings.HasSuffix(id, "]") {
|
||||
return fmt.Sprintf("%q: looks like a JSON array, not a single message ID", raw)
|
||||
}
|
||||
|
||||
// Reject colon-separated IDs (e.g. "id1:id2:id3").
|
||||
if strings.Contains(id, ":") {
|
||||
return fmt.Sprintf("%q: contains colon separators (multiple IDs concatenated)", raw)
|
||||
}
|
||||
|
||||
// Reject IDs with spaces — likely natural language or malformed input.
|
||||
if strings.Contains(id, " ") {
|
||||
return fmt.Sprintf("%q: contains spaces (expected opaque identifier)", raw)
|
||||
}
|
||||
|
||||
// Reject IDs that look like natural language: common English words.
|
||||
lower := strings.ToLower(id)
|
||||
for _, word := range commonEnglishWords {
|
||||
if lower == word {
|
||||
return fmt.Sprintf("%q: looks like natural language, not a message ID", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Final pattern check: non-empty, no spaces, no brackets, no colons.
|
||||
if !messageIDPattern.MatchString(id) {
|
||||
return fmt.Sprintf("%q: contains invalid characters (spaces, brackets, or colons)", raw)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateMessageIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ids []string
|
||||
wantErr bool
|
||||
wantSubstr string
|
||||
}{
|
||||
{
|
||||
name: "empty list passes",
|
||||
ids: []string{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid single ID passes",
|
||||
ids: []string{"msg_abc123"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid multiple IDs pass",
|
||||
ids: []string{"msg_abc123", "msg_def456", "msg_ghi789"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid hex ID passes",
|
||||
ids: []string{"a1b2c3d4e5f6"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid ID with underscores and dashes passes",
|
||||
ids: []string{"msg_abc-123_def"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty string rejected",
|
||||
ids: []string{""},
|
||||
wantErr: true,
|
||||
wantSubstr: "empty or whitespace-only",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only rejected",
|
||||
ids: []string{" "},
|
||||
wantErr: true,
|
||||
wantSubstr: "empty or whitespace-only",
|
||||
},
|
||||
{
|
||||
name: "natural language word rejected",
|
||||
ids: []string{"message"},
|
||||
wantErr: true,
|
||||
wantSubstr: "natural language",
|
||||
},
|
||||
{
|
||||
name: "natural language phrase rejected",
|
||||
ids: []string{"please read this email"},
|
||||
wantErr: true,
|
||||
wantSubstr: "contains spaces",
|
||||
},
|
||||
{
|
||||
name: "JSON array string rejected",
|
||||
ids: []string{`["id1","id2"]`},
|
||||
wantErr: true,
|
||||
wantSubstr: "JSON array",
|
||||
},
|
||||
{
|
||||
name: "JSON array string with spaces rejected",
|
||||
ids: []string{`[ "id1", "id2" ]`},
|
||||
wantErr: true,
|
||||
wantSubstr: "JSON array",
|
||||
},
|
||||
{
|
||||
name: "double-quoted valid ID passes after quote stripping",
|
||||
ids: []string{`"msg_abc123"`},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "single-quoted valid ID passes after quote stripping",
|
||||
ids: []string{`'msg_abc123'`},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "double-quoted natural language rejected after stripping",
|
||||
ids: []string{`"message"`},
|
||||
wantErr: true,
|
||||
wantSubstr: "natural language",
|
||||
},
|
||||
{
|
||||
name: "single-quoted natural language rejected after stripping",
|
||||
ids: []string{`'email'`},
|
||||
wantErr: true,
|
||||
wantSubstr: "natural language",
|
||||
},
|
||||
{
|
||||
name: "ID that just looks like quotes but isn't still valid",
|
||||
ids: []string{"msg_abc'123"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "colon-separated IDs rejected",
|
||||
ids: []string{"id1:id2:id3"},
|
||||
wantErr: true,
|
||||
wantSubstr: "colon separators",
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid reports invalid ones",
|
||||
ids: []string{"msg_valid123", "the", "msg_another456"},
|
||||
wantErr: true,
|
||||
wantSubstr: "natural language",
|
||||
},
|
||||
{
|
||||
name: "double-quoted empty rejected",
|
||||
ids: []string{`""`},
|
||||
wantErr: true,
|
||||
wantSubstr: "empty or whitespace-only",
|
||||
},
|
||||
{
|
||||
name: "single-quoted empty rejected",
|
||||
ids: []string{`''`},
|
||||
wantErr: true,
|
||||
wantSubstr: "empty or whitespace-only",
|
||||
},
|
||||
{
|
||||
name: "natural language word 'email' rejected",
|
||||
ids: []string{"email"},
|
||||
wantErr: true,
|
||||
wantSubstr: "natural language",
|
||||
},
|
||||
{
|
||||
name: "natural language word 'subject' rejected",
|
||||
ids: []string{"subject"},
|
||||
wantErr: true,
|
||||
wantSubstr: "natural language",
|
||||
},
|
||||
{
|
||||
name: "natural language word 'fetch' rejected",
|
||||
ids: []string{"fetch"},
|
||||
wantErr: true,
|
||||
wantSubstr: "natural language",
|
||||
},
|
||||
{
|
||||
name: "numeric ID passes",
|
||||
ids: []string{"1234567890"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ID with uppercase passes",
|
||||
ids: []string{"MSG_ABC123DEF"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "realistic Lark message ID passes",
|
||||
ids: []string{"gmxxxxxxxxxxxxxx"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple invalid IDs all reported",
|
||||
ids: []string{"the", "email", "id1:id2"},
|
||||
wantErr: true,
|
||||
wantSubstr: "natural language",
|
||||
},
|
||||
{
|
||||
name: "double-quoted whitespace-only rejected",
|
||||
ids: []string{`" "`},
|
||||
wantErr: true,
|
||||
wantSubstr: "empty or whitespace-only",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateMessageIDs(tt.ids)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateMessageIDs(%v) error = %v, wantErr %v", tt.ids, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil && tt.wantSubstr != "" {
|
||||
if !strings.Contains(err.Error(), tt.wantSubstr) {
|
||||
t.Errorf("validateMessageIDs(%v) error = %v, want substr %q", tt.ids, err, tt.wantSubstr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSingleMessageID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "valid hex ID", raw: "a1b2c3d4", wantOK: true},
|
||||
{name: "valid prefixed ID", raw: "msg_abc123", wantOK: true},
|
||||
{name: "empty string", raw: "", wantOK: false},
|
||||
{name: "whitespace only", raw: " ", wantOK: false},
|
||||
{name: "tab only", raw: "\t", wantOK: false},
|
||||
{name: "natural language phrase", raw: "please read my email", wantOK: false},
|
||||
{name: "JSON array", raw: `["id1","id2"]`, wantOK: false},
|
||||
{name: "colon separated", raw: "id1:id2:id3", wantOK: false},
|
||||
{name: "double quoted valid ID passes after strip", raw: `"msg_abc"`, wantOK: true},
|
||||
{name: "single quoted valid ID passes after strip", raw: `'msg_abc'`, wantOK: true},
|
||||
{name: "double quoted natural language rejected after strip", raw: `"message"`, wantOK: false},
|
||||
{name: "single quoted natural language rejected after strip", raw: `'email'`, wantOK: false},
|
||||
{name: "word: message", raw: "message", wantOK: false},
|
||||
{name: "word: email", raw: "email", wantOK: false},
|
||||
{name: "word: subject", raw: "subject", wantOK: false},
|
||||
{name: "word: please", raw: "please", wantOK: false},
|
||||
{name: "word: THE", raw: "THE", wantOK: false},
|
||||
{name: "numeric ID", raw: "1234567890", wantOK: true},
|
||||
{name: "ID with dash", raw: "msg-abc-123", wantOK: true},
|
||||
{name: "ID with dot", raw: "msg.abc.123", wantOK: true},
|
||||
{name: "ID with underscore", raw: "msg_abc_123", wantOK: true},
|
||||
{name: "double quoted empty", raw: `""`, wantOK: false},
|
||||
{name: "single quoted empty", raw: `''`, wantOK: false},
|
||||
{name: "double quoted whitespace", raw: `" "`, wantOK: false},
|
||||
{name: "double quoted colon-separated rejected after strip", raw: `"a:b:c"`, wantOK: false},
|
||||
{name: "double quoted JSON array rejected", raw: `"[]"`, wantOK: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reason := validateSingleMessageID(tt.raw)
|
||||
if tt.wantOK && reason != "" {
|
||||
t.Errorf("validateSingleMessageID(%q) = %q, want empty (valid)", tt.raw, reason)
|
||||
}
|
||||
if !tt.wantOK && reason == "" {
|
||||
t.Errorf("validateSingleMessageID(%q) = empty, want rejection reason", tt.raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,29 @@ lark-cli auth login --scope "calendar:calendar:readonly" --no-wait --json
|
||||
lark-cli auth login --device-code <device_code>
|
||||
```
|
||||
|
||||
**Split-Flow 完整步骤**:
|
||||
|
||||
**第一步:发起授权(当前轮)**
|
||||
|
||||
1. 执行 `lark-cli auth login --scope "xxx" --no-wait --json`(必须加 `--no-wait --json`)
|
||||
2. 从 JSON 输出中提取 `verification_url` 和 `device_code`
|
||||
3. 生成二维码:`lark-cli auth qrcode <verification_url> --output "xxx"`
|
||||
4. 将 URL 和二维码展示给用户(先 URL,后二维码)
|
||||
5. **结束本轮对话前,必须明确告知用户**:"请完成授权后,回来告诉我已授权完成,我会帮你完成后续步骤"
|
||||
|
||||
**第二步:完成授权(后续轮)**
|
||||
|
||||
1. 等待用户回复"已完成授权"
|
||||
2. **由你(AI agent)亲自执行**:`lark-cli auth login --device-code <device_code>`
|
||||
3. 此命令会轮询授权状态并完成登录
|
||||
4. 如果返回授权成功,流程结束
|
||||
|
||||
**关键规则**:
|
||||
|
||||
- **你必须亲自执行 `--device-code` 命令**,不要指示用户自行执行
|
||||
- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL
|
||||
- **禁止缓存 `verification_url` 或 `device_code`**:每次需要授权时,必须重新执行 `lark-cli auth login --no-wait --json` 生成新的链接。不要将授权链接和 device code 存入上下文供后续复用
|
||||
|
||||
## 更新检查
|
||||
|
||||
lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_notice.update` 字段(含 `message`、`command` 等)。
|
||||
|
||||
Reference in New Issue
Block a user