mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
Compare commits
1 Commits
feat/7564d
...
fix/update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93754a9b5a |
@@ -9,6 +9,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -28,6 +30,22 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
func officialSkillsIndexURLForTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/.well-known/skills/index.json" {
|
||||
t.Fatalf("path = %s, want /.well-known/skills/index.json", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server.URL + "/.well-known/skills/index.json"
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
@@ -35,6 +53,8 @@ func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.OfficialSkillsIndexURL = officialSkillsIndexURLForTest(t)
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
@@ -49,6 +69,7 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.OfficialSkillsIndexURL = officialSkillsIndexURLForTest(t)
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
@@ -59,10 +80,14 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
return func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
switch strings.Join(args, " ") {
|
||||
case "-y skills add https://open.feishu.cn --list":
|
||||
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
|
||||
case "-y skills ls -g":
|
||||
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
|
||||
case "-y skills add https://open.feishu.cn -s lark-calendar lark-mail -g -y":
|
||||
// incremental install succeeds
|
||||
case "-y skills add https://open.feishu.cn -g -y":
|
||||
// full install succeeds
|
||||
case "-y skills add larksuite/cli -g -y":
|
||||
// fallback full install succeeds
|
||||
default:
|
||||
}
|
||||
return r
|
||||
|
||||
@@ -10,7 +10,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +27,8 @@ import (
|
||||
// Tests that mutate execLookPath must not call t.Parallel().
|
||||
var execLookPath = exec.LookPath
|
||||
|
||||
var officialSkillNamePattern = regexp.MustCompile(`^lark-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`)
|
||||
|
||||
// InstallMethod describes how the CLI was installed.
|
||||
type InstallMethod int
|
||||
|
||||
@@ -42,6 +47,13 @@ const (
|
||||
verifyTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
officialSkillsIndexDefaultURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
officialSkillsIndexMaxBytes = 2 << 20
|
||||
)
|
||||
|
||||
var officialSkillsIndexURL = officialSkillsIndexDefaultURL
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
type DetectResult struct {
|
||||
Method InstallMethod
|
||||
@@ -86,6 +98,7 @@ type Updater struct {
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
RestoreAvailableOverride func() bool
|
||||
OfficialSkillsIndexURL string
|
||||
|
||||
// backupCreated is set to true by PrepareSelfReplace (Windows) when the
|
||||
// running binary is successfully renamed to .old. Used by
|
||||
@@ -154,10 +167,53 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsListOfficial("larksuite/cli")
|
||||
return u.fetchOfficialSkillsIndex()
|
||||
}
|
||||
|
||||
func (u *Updater) fetchOfficialSkillsIndex() *NpmResult {
|
||||
r := &NpmResult{}
|
||||
indexURL := officialSkillsIndexURL
|
||||
if u.OfficialSkillsIndexURL != "" {
|
||||
indexURL = u.OfficialSkillsIndexURL
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, indexURL, nil)
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("create official skills index request %s: %w", indexURL, err)
|
||||
return r
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Err = fmt.Errorf("official skills index fetch timed out after %s: %s", skillsUpdateTimeout, indexURL)
|
||||
return r
|
||||
}
|
||||
r.Err = fmt.Errorf("fetch official skills index %s: %w", indexURL, err)
|
||||
return r
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
r.Err = fmt.Errorf("fetch official skills index %s: HTTP %s", indexURL, resp.Status)
|
||||
return r
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, officialSkillsIndexMaxBytes+1))
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("read official skills index %s: %w", indexURL, err)
|
||||
return r
|
||||
}
|
||||
if len(body) > officialSkillsIndexMaxBytes {
|
||||
r.Err = fmt.Errorf("official skills index %s exceeds %d bytes", indexURL, officialSkillsIndexMaxBytes)
|
||||
return r
|
||||
}
|
||||
|
||||
_, _ = r.Stdout.Write(body)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -166,6 +222,10 @@ func (u *Updater) ListGlobalSkills() *NpmResult {
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
|
||||
if err := validateOfficialSkillNames(nameList); err != nil {
|
||||
return &NpmResult{Err: err}
|
||||
}
|
||||
|
||||
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsInstall("larksuite/cli", nameList)
|
||||
@@ -173,6 +233,15 @@ func (u *Updater) InstallSkill(nameList []string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func validateOfficialSkillNames(nameList []string) error {
|
||||
for _, name := range nameList {
|
||||
if !officialSkillNamePattern.MatchString(name) {
|
||||
return fmt.Errorf("invalid official skill name %q", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Updater) InstallAllSkills() *NpmResult {
|
||||
r := u.runSkillsAdd("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
@@ -185,10 +254,6 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsListOfficial(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "--list")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsListGlobal() *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "ls", "-g")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ package selfupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -174,13 +176,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
run func(*Updater) *NpmResult
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "list official primary",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn --list",
|
||||
},
|
||||
{
|
||||
name: "list global",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
@@ -225,29 +220,90 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
called := []string{}
|
||||
updater := &Updater{
|
||||
SkillsCommandOverride: func(args ...string) *NpmResult {
|
||||
called = append(called, strings.Join(args, " "))
|
||||
r := &NpmResult{}
|
||||
if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") {
|
||||
r.Err = fmt.Errorf("primary failed")
|
||||
return r
|
||||
}
|
||||
r.Stdout.WriteString("lark-calendar\n")
|
||||
return r
|
||||
},
|
||||
}
|
||||
func TestListOfficialSkillsFetchesIndexJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/.well-known/skills/index.json" {
|
||||
t.Fatalf("path = %s, want /.well-known/skills/index.json", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"skills":[{"name":"lark-calendar"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result := updater.ListOfficialSkills()
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL + "/.well-known/skills/index.json"
|
||||
defer func() { officialSkillsIndexURL = oldURL }()
|
||||
|
||||
u := New()
|
||||
result := u.ListOfficialSkills()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if len(called) != 2 {
|
||||
t.Fatalf("called %d commands, want 2: %#v", len(called), called)
|
||||
}
|
||||
if !strings.Contains(called[1], "larksuite/cli --list") {
|
||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||
if got := result.Stdout.String(); !strings.Contains(got, `"lark-calendar"`) {
|
||||
t.Fatalf("ListOfficialSkills() stdout = %q, want index JSON", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsNon2xxFails(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusBadGateway)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL + "/.well-known/skills/index.json"
|
||||
defer func() { officialSkillsIndexURL = oldURL }()
|
||||
|
||||
u := New()
|
||||
result := u.ListOfficialSkills()
|
||||
if result.Err == nil {
|
||||
t.Fatal("ListOfficialSkills() err = nil, want error")
|
||||
}
|
||||
if !strings.Contains(result.Err.Error(), "502") {
|
||||
t.Fatalf("ListOfficialSkills() err = %v, want HTTP status", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsTooLargeFails(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(strings.Repeat("x", officialSkillsIndexMaxBytes+1)))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL + "/.well-known/skills/index.json"
|
||||
defer func() { officialSkillsIndexURL = oldURL }()
|
||||
|
||||
u := New()
|
||||
result := u.ListOfficialSkills()
|
||||
if result.Err == nil {
|
||||
t.Fatal("ListOfficialSkills() err = nil, want error")
|
||||
}
|
||||
if !strings.Contains(result.Err.Error(), "exceeds") {
|
||||
t.Fatalf("ListOfficialSkills() err = %v, want size limit error", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSkillRejectsInvalidOfficialNames(t *testing.T) {
|
||||
called := false
|
||||
u := &Updater{
|
||||
SkillsCommandOverride: func(args ...string) *NpmResult {
|
||||
called = true
|
||||
return &NpmResult{}
|
||||
},
|
||||
}
|
||||
|
||||
result := u.InstallSkill([]string{"lark-calendar", "lark-calendar@evil"})
|
||||
if result.Err == nil {
|
||||
t.Fatal("InstallSkill() err = nil, want invalid name error")
|
||||
}
|
||||
if !strings.Contains(result.Err.Error(), "invalid official skill name") {
|
||||
t.Fatalf("InstallSkill() err = %v, want invalid name error", result.Err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("InstallSkill() called skills command for invalid name")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -14,8 +15,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
|
||||
ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
|
||||
skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
|
||||
officialSkillNamePattern = regexp.MustCompile(`^lark-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`)
|
||||
ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
|
||||
)
|
||||
|
||||
type SyncInput struct {
|
||||
@@ -41,6 +43,10 @@ func stripANSI(s string) string {
|
||||
|
||||
func ParseSkillsList(text string) []string {
|
||||
text = stripANSI(text)
|
||||
if skills := parseOfficialSkillsIndex(text); skills != nil {
|
||||
return skills
|
||||
}
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
|
||||
// Detect format type
|
||||
@@ -57,6 +63,40 @@ func ParseSkillsList(text string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
type officialSkillsIndex struct {
|
||||
Skills []map[string]interface{} `json:"skills"`
|
||||
}
|
||||
|
||||
func parseOfficialSkillsIndex(text string) []string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var index officialSkillsIndex
|
||||
if err := json.Unmarshal([]byte(trimmed), &index); err != nil {
|
||||
return nil
|
||||
}
|
||||
if index.Skills == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, skill := range index.Skills {
|
||||
name, ok := skill["name"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if !officialSkillNamePattern.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
||||
func parseGlobalSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -67,6 +68,56 @@ func TestParseGlobalSkillsListWithANSI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSkillsListOfficialJSONIndex(t *testing.T) {
|
||||
input := `{
|
||||
"skills": [
|
||||
{"name": "lark-mail", "description": "Mail", "files": ["SKILL.md"]},
|
||||
{"name": "lark-calendar", "description": "Calendar", "files": ["SKILL.md"]},
|
||||
{"name": "lark-mail", "description": "Duplicate", "files": ["SKILL.md"]}
|
||||
]
|
||||
}`
|
||||
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-calendar", "lark-mail"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() JSON index = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSkillsListOfficialJSONIndexIgnoresInvalidNames(t *testing.T) {
|
||||
input := `{
|
||||
"skills": [
|
||||
{"name": " lark-calendar "},
|
||||
{"name": "custom-skill"},
|
||||
{"name": "lark-bad name"},
|
||||
{"name": "lark-../../evil"},
|
||||
{"name": "lark-calendar@evil"},
|
||||
{"name": "lark-calendar:evil"},
|
||||
{"name": "lark-calendar/evil"},
|
||||
{"name": "lark-calendar..evil"},
|
||||
{"name": "lark-calendar_evil"},
|
||||
{"name": ""},
|
||||
{"description": "missing name"},
|
||||
{"name": 42}
|
||||
]
|
||||
}`
|
||||
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-calendar"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() JSON index invalid names = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsListLegacyAvailableSkills(t *testing.T) {
|
||||
input := "Available Skills\n │ lark-calendar\n │ lark-mail\n"
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-calendar", "lark-mail"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() legacy Available Skills = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||
got := PlanSync(SyncInput{
|
||||
@@ -125,12 +176,16 @@ type fakeSkillsRunner struct {
|
||||
|
||||
func officialSkillsOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Available Skills\n")
|
||||
for _, name := range names {
|
||||
b.WriteString("│ ")
|
||||
b.WriteString(name)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(`{"skills":[`)
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
b.WriteString(`{"name":`)
|
||||
b.WriteString(strconv.Quote(name))
|
||||
b.WriteString(`,"description":"","files":["SKILL.md"]}`)
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user