Compare commits

...

4 Commits

Author SHA1 Message Date
dengzilong.zero
ee24a6336a docs(slides): require full screenshot review 2026-07-30 20:17:25 +08:00
BD-ZERO
6e5308af01 feat: add SXSD schema validation to Slides lint (#2103)
- add XSD-backed SXSD validation for tags, attributes, structure, scalar values, and namespaces
- preserve supported server-filled fields and readback namespace compatibility
- isolate SXSD failures by slide so valid slides continue through layout checks
- improve actionable lint diagnostics and suppress duplicate errors
- add regression coverage for schema validation and Slides readback cases

Validated with unit tests and real Slides create/readback round trips.
2026-07-30 20:04:53 +08:00
liangshuo-1
87be09ef5f fix(contact): stop bot match segments carrying tags or empty entries (#2115) 2026-07-30 18:08:38 +08:00
sang-neo03
a575a8ba60 feat(contact): add bot search shortcut (#2083) 2026-07-30 17:03:49 +08:00
16 changed files with 4598 additions and 142 deletions

View File

@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +search-bot
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
### Skills
- lark-contact/references/lark-contact-search-bot.md
### Avoid when
- Looking for a person rather than a bot → use [[+search-user]]
- Running as a bot — this shortcut is user-only
### Tips
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
### Examples
**Find bots by keyword**
```bash
lark-cli contact +search-bot --query "会议助手" --as user
```
**Search inside one chat**
```bash
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
```
**Find bots you've chatted with**
```bash
lark-cli contact +search-bot --query "助手" --has-chatted --as user
```
**Search several bot keywords in one call**
```bash
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.

View File

@@ -0,0 +1,447 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const botSearchURL = "/open-apis/bot/v4/bot/search"
const (
maxBotSearchQueryChars = 50
maxBotSearchChatIDs = 100
maxBotSearchPageSize = 30
)
type botSearchAPIRequest struct {
Query string `json:"query,omitempty"`
Filter *botSearchAPIFilter `json:"filter,omitempty"`
}
// HasChatter uses omitempty: validation rejects =false, so a set field is always
// true and an unset field stays out of the request entirely.
type botSearchAPIFilter struct {
ChatIDs []string `json:"chat_ids,omitempty"`
HasChatter bool `json:"has_chatter,omitempty"`
}
type botSearchAPIData struct {
Items []botSearchAPIItem `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
Notice string `json:"notice"`
}
type botSearchAPIItem struct {
ID string `json:"id"`
DisplayInfo string `json:"display_info"`
MetaData botSearchAPIMeta `json:"meta_data"`
}
type botSearchAPIMeta struct {
TenantID string `json:"tenant_id"`
EnableJoinGroup bool `json:"enable_join_group"`
ChatID string `json:"chat_id"`
IsAgent bool `json:"is_agent"`
}
type searchBot struct {
OpenID string `json:"open_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
// ChatID is the caller's P2P chat with the bot.
ChatID string `json:"chat_id"`
EnableJoinGroup bool `json:"enable_join_group"`
IsAgent bool `json:"is_agent"`
TenantID string `json:"tenant_id,omitempty"`
MatchSegments []string `json:"match_segments"`
}
// PageToken is decoded from the response but deliberately not surfaced, matching
// searchUserResponse: neither search command paginates. Callers narrow the query
// instead, so handing out a token that no flag accepts would only mislead.
type searchBotResponse struct {
Bots []searchBot `json:"bots"`
HasMore bool `json:"has_more"`
Notice string `json:"notice,omitempty"`
}
var ContactSearchBot = common.Shortcut{
Service: "contact",
Command: "+search-bot",
Description: "Search bots (apps) by keyword — across the tenant, or inside specific chats (requires --as user)",
Risk: "read",
Scopes: []string{"search:bot"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (≤ 50 characters); required unless --queries is given"},
{Name: "chat-ids", Desc: "search within specific chats (CSV of chat_id; ≤ 100)"},
{Name: "has-chatted", Type: "bool", Desc: "narrow a keyword search to bots you've chatted with (omit to disable; =false rejected)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"},
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat bots[] with matched_query plus a queries[] sidecar"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBotSearch(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" {
filter, err := buildBotSearchFilter(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
api := common.NewDryRunAPI()
for _, q := range parseAndDedupQueries(raw) {
body := &botSearchAPIRequest{Query: q, Filter: filter}
api.POST(botSearchURL).
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
Body(body)
}
return api
}
body, err := buildBotSearchBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST(botSearchURL).
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
Body(body)
},
Execute: executeBotSearch,
}
// executeBotSearch dispatches to single-query or fanout mode.
func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("queries")) != "" {
return executeBotSearchFanout(ctx, runtime)
}
return executeBotSearchSingle(ctx, runtime)
}
// botSearchKeywordRequiredError names every flag that can satisfy the keyword
// requirement. Naming only --query would tell an agent that --queries is not a
// way out, which it is.
func botSearchKeywordRequiredError() error {
return common.ValidationErrorf("specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)").
WithParams(
errs.InvalidParam{Name: "--query", Reason: "required unless --queries is given"},
errs.InvalidParam{Name: "--queries", Reason: "required unless --query is given"},
)
}
// botSearchHasChattedFalseError is raised from two places — with and without a
// keyword — so the wording stays in one spot.
//
// Agents passing =false almost always mean "do not filter", but the API reads it
// as "must NOT match". A hard error prevents silent wrong results.
func botSearchHasChattedFalseError() error {
return common.ValidationErrorf("--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)").
WithParam("--has-chatted")
}
func validateBotSearch(runtime *common.RuntimeContext) error {
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
query := strings.TrimSpace(runtime.Str("query"))
explicitFalseHasChatted := runtime.Cmd.Flags().Changed("has-chatted") && !runtime.Bool("has-chatted")
if queriesRaw != "" {
if query != "" {
return common.ValidationErrorf("--query and --queries are mutually exclusive").
WithParams(
errs.InvalidParam{Name: "--query", Reason: "mutually exclusive with --queries"},
errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --query"},
)
}
queries := parseAndDedupQueries(queriesRaw)
if len(queries) == 0 {
return common.ValidationErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw).
WithParam("--queries")
}
if len(queries) > maxFanoutQueries {
return common.ValidationErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)).
WithParam("--queries")
}
for _, q := range queries {
if utf8.RuneCountInString(q) > maxBotSearchQueryChars {
return common.ValidationErrorf("--queries: entry %q exceeds %d characters", q, maxBotSearchQueryChars).
WithParam("--queries")
}
}
} else if query == "" {
// No keyword at all. An explicit =false is the more specific mistake, so
// report it instead of sending the caller off to add a keyword only to hit
// this on the next attempt. +search-user lands here too: a Changed bool
// counts as search input for its "at least one" gate, so the =false check
// is what it reaches next.
//
// Scoped to the no-keyword case on purpose. Hoisting it above the keyword
// checks would let it mask the mutual-exclusion and length errors, which
// +search-user reports first when a keyword is present.
if explicitFalseHasChatted {
return botSearchHasChattedFalseError()
}
return botSearchKeywordRequiredError()
} else if utf8.RuneCountInString(query) > maxBotSearchQueryChars {
return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxBotSearchQueryChars).
WithParam("--query")
}
if _, err := parseBotSearchChatIDs(runtime); err != nil {
return err
}
if explicitFalseHasChatted {
return botSearchHasChattedFalseError()
}
if n := runtime.Int("page-size"); n < 1 || n > maxBotSearchPageSize {
return common.ValidationErrorf("--page-size: must be between 1 and %d", maxBotSearchPageSize).
WithParam("--page-size")
}
return nil
}
func parseBotSearchChatIDs(runtime *common.RuntimeContext) ([]string, error) {
raw := strings.TrimSpace(runtime.Str("chat-ids"))
if raw == "" {
return nil, nil
}
parts := common.SplitCSV(raw)
if len(parts) == 0 {
return nil, common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw).
WithParam("--chat-ids")
}
// Normalize before deduping, then check the cap against the deduped list —
// the same order common.resolveOpenIDs uses for --user-ids. Doing it the other
// way would spend the server's 100-entry budget on duplicates, and would let
// 101 copies of one chat be rejected here while the sibling command accepts
// them. Normalization matters too: a chat URL and a bare chat_id can name the
// same chat.
seen := make(map[string]struct{}, len(parts))
chatIDs := make([]string, 0, len(parts))
for _, part := range parts {
normalized, err := common.ValidateChatIDTyped("--chat-ids", part)
if err != nil {
return nil, err
}
if _, dup := seen[normalized]; dup {
continue
}
seen[normalized] = struct{}{}
chatIDs = append(chatIDs, normalized)
}
if len(chatIDs) > maxBotSearchChatIDs {
return nil, common.ValidationErrorf("--chat-ids: must be at most %d entries", maxBotSearchChatIDs).
WithParam("--chat-ids")
}
return chatIDs, nil
}
// buildBotSearchFilter reads the scope flags shared by single and fanout search.
// A nil filter means "no scope": an empty filter object is not the same request.
func buildBotSearchFilter(runtime *common.RuntimeContext) (*botSearchAPIFilter, error) {
filter := &botSearchAPIFilter{}
hasFilter := false
chatIDs, err := parseBotSearchChatIDs(runtime)
if err != nil {
return nil, err
}
if len(chatIDs) > 0 {
filter.ChatIDs = chatIDs
hasFilter = true
}
if runtime.Cmd.Flags().Changed("has-chatted") && runtime.Bool("has-chatted") {
filter.HasChatter = true
hasFilter = true
}
if !hasFilter {
return nil, nil
}
return filter, nil
}
func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, error) {
filter, err := buildBotSearchFilter(runtime)
if err != nil {
return nil, err
}
return &botSearchAPIRequest{
Query: strings.TrimSpace(runtime.Str("query")),
Filter: filter,
}, nil
}
// botSearchStdoutCarriesEnvelope reports whether the chosen format puts the
// response envelope — notice, has_more, and in fanout mode queries[] — into
// stdout. Only json does; pretty, table, csv and ndjson render rows only, so
// every piece of "this result is not the whole answer" metadata would vanish and
// the caller would read a truncated result as a complete one. For those formats
// the metadata goes to stderr, which keeps stdout pipe-clean. A --jq expression
// can still project it away, but that is the caller's explicit choice.
func botSearchStdoutCarriesEnvelope(format string) bool {
return format == "json" || format == ""
}
func executeBotSearchSingle(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildBotSearchBody(runtime)
if err != nil {
return err
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: botSearchURL,
Body: body,
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
})
if err != nil {
return err
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return err
}
respData, err := decodeBotSearchAPIData(data)
if err != nil {
return err
}
bots := projectBots(respData)
out := searchBotResponse{
Bots: bots,
HasMore: respData.HasMore,
Notice: respData.Notice,
}
runtime.OutFormat(out, &output.Meta{Count: len(bots)}, func(w io.Writer) {
if len(bots) == 0 {
fmt.Fprintln(w, "No bots found.")
return
}
output.PrintTable(w, prettyBotRows(bots))
})
if respData.Notice != "" && !botSearchStdoutCarriesEnvelope(runtime.Format) {
fmt.Fprintf(runtime.IO().ErrOut, "\nnotice: %s\n", respData.Notice)
}
if respData.HasMore && !botSearchStdoutCarriesEnvelope(runtime.Format) {
fmt.Fprintln(runtime.IO().ErrOut,
"\nhint: more matches exist; narrow with --has-chatted or a more specific --query")
}
return nil
}
func decodeBotSearchAPIData(data map[string]interface{}) (*botSearchAPIData, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, contactInvalidResponseError("marshal bot search response data failed").WithCause(err)
}
var out botSearchAPIData
if err := json.Unmarshal(raw, &out); err != nil {
return nil, contactInvalidResponseError("decode bot search response data failed").WithCause(err)
}
return &out, nil
}
func projectBots(data *botSearchAPIData) []searchBot {
if data == nil {
return []searchBot{}
}
bots := make([]searchBot, 0, len(data.Items))
for i := range data.Items {
item := &data.Items[i]
name, description, segments := parseBotDisplayInfo(item.DisplayInfo)
bots = append(bots, searchBot{
OpenID: item.ID,
Name: name,
Description: description,
ChatID: item.MetaData.ChatID,
EnableJoinGroup: item.MetaData.EnableJoinGroup,
IsAgent: item.MetaData.IsAgent,
TenantID: item.MetaData.TenantID,
MatchSegments: segments,
})
}
return bots
}
func stripHighlightTags(value string) string {
value = strings.ReplaceAll(value, "<h>", "")
return strings.ReplaceAll(value, "</h>", "")
}
func parseBotDisplayInfo(raw string) (name, description string, matchSegments []string) {
matchSegments = make([]string, 0)
for _, match := range displayInfoHighlightRE.FindAllStringSubmatch(raw, -1) {
// The capture can still carry a tag: the non-greedy pattern pairs a
// stray `<h>` with the next `</h>`. Strip it so a segment reads like the
// name and description it came from, and drop a highlight with no text.
segment := html.UnescapeString(stripHighlightTags(match[1]))
if strings.TrimSpace(segment) == "" {
continue
}
matchSegments = append(matchSegments, segment)
}
lines := strings.Split(raw, "\n")
stripTags := func(value string) string {
return strings.TrimSpace(html.UnescapeString(stripHighlightTags(value)))
}
// nameLine records which line the name came from, so the description is read
// from the line after it. Reading lines[1] unconditionally echoes the name
// back as its own description whenever line 0 is blank, and drops the real
// description with it.
nameLine := -1
if len(lines) > 0 {
if candidate := stripTags(lines[0]); candidate != "" {
name = candidate
nameLine = 0
}
}
if name == "" {
for i, line := range lines {
if candidate := stripTags(line); candidate != "" {
name = candidate
nameLine = i
break
}
}
}
if nameLine >= 0 && nameLine+1 < len(lines) {
description = stripTags(lines[nameLine+1])
}
return name, description, matchSegments
}
// map[] shape is required by output.PrintTable.
func prettyBotRows(bots []searchBot) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(bots))
for _, bot := range bots {
rows = append(rows, map[string]interface{}{
"name": bot.Name,
"description": common.TruncateStr(bot.Description, 50),
"is_agent": bot.IsAgent,
"enable_join_group": bot.EnableJoinGroup,
"open_id": bot.OpenID,
})
}
return rows
}

View File

@@ -0,0 +1,289 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// Bot fanout reuses the user fanout's query parsing, concurrency limit and
// response summary types.
type botFanoutResult struct {
Index int
Query string
Bots []searchBot
HasMore bool
Notice string
ErrMsg string // empty = success
Err error // original failure, kept for typed propagation
}
// runOneBotQuery converts one fanout request into either bots or an error summary.
func runOneBotQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
filter *botSearchAPIFilter) botFanoutResult {
// Pre-check ctx so queued workers see cancellation before issuing a request;
// in-flight workers continue until DoAPI returns.
if err := ctx.Err(); err != nil {
return botFanoutErrorResult(index, query, err)
}
body := &botSearchAPIRequest{Query: query}
if filter != nil {
body.Filter = filter
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: botSearchURL,
Body: body,
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
})
if err != nil {
return botFanoutErrorResult(index, query, err)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return botFanoutErrorResult(index, query, err)
}
respData, err := decodeBotSearchAPIData(data)
if err != nil {
return botFanoutErrorResult(index, query, err)
}
return botFanoutResult{
Index: index,
Query: query,
Bots: projectBots(respData),
HasMore: respData.HasMore,
Notice: respData.Notice,
}
}
// botFanoutErrorResult records a failed fanout query without stopping other workers.
func botFanoutErrorResult(index int, query string, err error) botFanoutResult {
if err == nil {
return botFanoutResult{Index: index, Query: query}
}
return botFanoutResult{Index: index, Query: query, ErrMsg: contactFanoutErrorSummary(err), Err: err}
}
func botFanoutContextError(err error) error {
subtype := errs.SubtypeNetworkTransport
message := "bot search fanout cancelled"
if errors.Is(err, context.DeadlineExceeded) {
subtype = errs.SubtypeNetworkTimeout
message = "bot search fanout deadline exceeded"
}
return errs.NewNetworkError(subtype, "%s", message).WithCause(err)
}
func botFanoutPanicError(query string, recovered any) error {
err := errs.NewInternalError(errs.SubtypeUnknown,
"bot search query %q panicked: %v", query, recovered)
if cause, ok := recovered.(error); ok {
return err.WithCause(cause)
}
return err
}
// Terminal failures invalidate the batch; API and network failures remain
// eligible for partial-success reporting.
func botFanoutTerminalError(results []botFanoutResult) error {
for _, result := range results {
if result.Err == nil {
continue
}
if errors.Is(result.Err, context.Canceled) || errors.Is(result.Err, context.DeadlineExceeded) {
return botFanoutContextError(result.Err)
}
problem, ok := errs.ProblemOf(result.Err)
if !ok {
return errs.NewInternalError(errs.SubtypeUnknown,
"bot search query %q failed with an unclassified error: %v", result.Query, result.Err).
WithCause(result.Err)
}
if problem.Category != errs.CategoryAPI && problem.Category != errs.CategoryNetwork {
return result.Err
}
}
return nil
}
type fanoutBot struct {
searchBot
MatchedQuery string `json:"matched_query"`
}
type botFanoutResponse struct {
Bots []fanoutBot `json:"bots"`
Queries []querySummary `json:"queries"`
Notice string `json:"notice,omitempty"`
}
// buildBotFanoutResponse flattens recoverable results in query order. Terminal
// errors fail the batch even when another query succeeded.
func buildBotFanoutResponse(queries []string, results []botFanoutResult) (*botFanoutResponse, error) {
if err := botFanoutTerminalError(results); err != nil {
return nil, err
}
indexed := make([]botFanoutResult, len(queries))
for _, r := range results {
indexed[r.Index] = r
}
out := &botFanoutResponse{
Bots: make([]fanoutBot, 0),
Queries: make([]querySummary, 0, len(queries)),
}
failed := 0
var firstErrMsg, firstErrQuery string
var firstErr error
for i, r := range indexed {
out.Queries = append(out.Queries, querySummary{
Query: queries[i],
Error: r.ErrMsg,
HasMore: r.HasMore,
Notice: r.Notice,
})
if r.ErrMsg != "" {
failed++
if firstErrMsg == "" {
firstErrMsg = r.ErrMsg
firstErrQuery = queries[i]
firstErr = r.Err
}
continue
}
if out.Notice == "" {
out.Notice = r.Notice
}
for _, b := range r.Bots {
out.Bots = append(out.Bots, fanoutBot{searchBot: b, MatchedQuery: queries[i]})
}
}
if failed == len(queries) && len(queries) > 0 {
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
len(queries), firstErrMsg, firstErrQuery)
return nil, contactFanoutAllFailedError(firstErr, msg)
}
return out, nil
}
func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) error {
queries := parseAndDedupQueries(runtime.Str("queries"))
filter, err := buildBotSearchFilter(runtime)
if err != nil {
return err
}
results := make([]botFanoutResult, len(queries))
var wg sync.WaitGroup
sem := make(chan struct{}, fanoutConcurrency)
schedule:
for i, q := range queries {
select {
case sem <- struct{}{}:
case <-ctx.Done():
for j := i; j < len(queries); j++ {
results[j] = botFanoutErrorResult(j, queries[j], ctx.Err())
}
break schedule
}
wg.Add(1)
go func(i int, q string) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
err := botFanoutPanicError(q, r)
results[i] = botFanoutResult{
Index: i,
Query: q,
ErrMsg: contactFanoutErrorSummary(err),
Err: err,
}
}
}()
results[i] = runOneBotQuery(ctx, runtime, i, q, filter)
}(i, q)
}
wg.Wait()
resp, err := buildBotFanoutResponse(queries, results)
if err != nil {
return err
}
failed, hasMoreCount := 0, 0
for _, qs := range resp.Queries {
if qs.Error != "" {
failed++
}
if qs.HasMore {
hasMoreCount++
}
}
runtime.OutFormat(resp, &output.Meta{Count: len(resp.Bots)}, func(w io.Writer) {
if len(resp.Bots) == 0 {
fmt.Fprintln(w, "No bots found.")
return
}
output.PrintTable(w, prettyBotFanoutRows(resp.Bots))
})
if isFanoutSummaryFormat(runtime.Format) {
fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total matches; %d failed, %d with has_more\n",
len(queries), len(resp.Bots), failed, hasMoreCount)
}
// The counts above say how many queries failed but not which, and only the
// json envelope carries queries[].error / queries[].notice. Without this an
// agent reading csv or a table sees "1 failed" with no way to learn the
// keyword or the reason, and a notice disappears entirely.
if !botSearchStdoutCarriesEnvelope(runtime.Format) {
for _, qs := range resp.Queries {
if qs.Error != "" {
fmt.Fprintf(runtime.IO().ErrOut, "failed: %q — %s\n", qs.Query, qs.Error)
}
if qs.Notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %q — %s\n", qs.Query, qs.Notice)
}
if qs.HasMore {
fmt.Fprintf(runtime.IO().ErrOut, "has_more: %q — more matches exist; narrow this keyword\n", qs.Query)
}
}
}
return nil
}
func prettyBotFanoutRows(bots []fanoutBot) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(bots))
for _, bot := range bots {
rows = append(rows, map[string]interface{}{
"matched_query": bot.MatchedQuery,
"name": bot.Name,
"description": common.TruncateStr(bot.Description, 50),
"is_agent": bot.IsAgent,
"enable_join_group": bot.EnableJoinGroup,
"open_id": bot.OpenID,
})
}
return rows
}

View File

@@ -0,0 +1,684 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBotFanoutErrorResultNilErrorIsSuccess(t *testing.T) {
r := botFanoutErrorResult(3, "会议助手", nil)
if r.ErrMsg != "" || r.Err != nil {
t.Fatalf("nil error must stay a success result: %+v", r)
}
if r.Index != 3 || r.Query != "会议助手" {
t.Fatalf("index/query must survive: %+v", r)
}
}
func TestBotFanoutAssembleOrderAndShape(t *testing.T) {
results := []botFanoutResult{
{Index: 1, Query: "日报", Bots: []searchBot{{OpenID: "ou_b"}}, HasMore: true},
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}},
{Index: 2, Query: "审批", ErrMsg: "API 1: nope"},
}
resp, err := buildBotFanoutResponse([]string{"会议", "日报", "审批"}, results)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Results are emitted in query order even though the workers finished out of
// order, and a failed query contributes no rows.
wantRows := []struct {
openID, matched string
}{{"ou_a1", "会议"}, {"ou_a2", "会议"}, {"ou_b", "日报"}}
if len(resp.Bots) != len(wantRows) {
t.Fatalf("bots length: got %d, want %d", len(resp.Bots), len(wantRows))
}
for i, w := range wantRows {
if resp.Bots[i].OpenID != w.openID || resp.Bots[i].MatchedQuery != w.matched {
t.Errorf("bots[%d]: got %+v, want %s/%s", i, resp.Bots[i], w.openID, w.matched)
}
}
want := []querySummary{
{Query: "会议"},
{Query: "日报", HasMore: true},
{Query: "审批", Error: "API 1: nope"},
}
if len(resp.Queries) != len(want) {
t.Fatalf("queries length: got %d, want %d (every query is enumerated)", len(resp.Queries), len(want))
}
for i, w := range want {
if resp.Queries[i] != w {
t.Errorf("queries[%d]: got %+v, want %+v", i, resp.Queries[i], w)
}
}
}
func TestBotFanoutAssembleAllFailedReturnsTypedError(t *testing.T) {
results := []botFanoutResult{
{Index: 0, Query: "会议", ErrMsg: "API 99991663: rate limit", Err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").WithCode(99991663)},
{Index: 1, Query: "日报", ErrMsg: "HTTP 500 Internal Server Error"},
}
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err == nil {
t.Fatal("expected an error when every query fails")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed problem, got %T: %v", err, err)
}
// The first failure's classification must survive, so the caller can tell a
// rate limit apart from a transport fault.
if problem.Code != 99991663 || problem.Subtype != errs.SubtypeRateLimit {
t.Errorf("problem: got %d/%s, want 99991663/%s", problem.Code, problem.Subtype, errs.SubtypeRateLimit)
}
// Agents grep the count and the first failure out of this message.
for _, want := range []string{"all 2 queries failed", "rate limit"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("message must contain %q; got %v", want, err)
}
}
}
func TestBotFanoutAssemblePartialFailureSucceeds(t *testing.T) {
results := []botFanoutResult{
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}},
{Index: 1, Query: "日报", ErrMsg: "API 1: nope"},
}
resp, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err != nil {
t.Fatalf("one failure out of two must not fail the call: %v", err)
}
if len(resp.Bots) != 1 || resp.Queries[1].Error == "" {
t.Fatalf("partial failure shape: %+v", resp)
}
}
func TestBotFanoutTerminalContextOverridesPartialSuccess(t *testing.T) {
tests := []struct {
name string
err error
wantSubtype errs.Subtype
}{
{name: "cancelled", err: context.Canceled, wantSubtype: errs.SubtypeNetworkTransport},
{name: "deadline", err: context.DeadlineExceeded, wantSubtype: errs.SubtypeNetworkTimeout},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := []botFanoutResult{
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}},
botFanoutErrorResult(1, "日报", tt.err),
}
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err == nil {
t.Fatal("terminal context error must fail the batch after a partial success")
}
if !errors.Is(err, tt.err) {
t.Fatalf("error must preserve %v as its cause: %v", tt.err, err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != tt.wantSubtype {
t.Fatalf("problem: got %+v, want network/%s", problem, tt.wantSubtype)
}
})
}
}
func TestBotFanoutResponseHasNoTopLevelHasMore(t *testing.T) {
resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议", HasMore: true}})
if err != nil {
t.Fatalf("build: %v", err)
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// has_more is per query in the sidecar; a single top-level flag would hide
// which keyword was truncated.
if _, ok := envelope["has_more"]; ok {
t.Fatalf("fanout must not surface a top-level has_more: %s", raw)
}
if !envelope["queries"].([]interface{})[0].(map[string]interface{})["has_more"].(bool) {
t.Fatalf("per-query has_more lost: %s", raw)
}
}
func TestBotFanoutEmptyBotsSerializesAsArray(t *testing.T) {
resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议"}})
if err != nil {
t.Fatalf("build: %v", err)
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(raw), `"bots":[]`) {
t.Fatalf("empty bots must serialize as [], not null: %s", raw)
}
}
func TestPrettyBotFanoutRowsLeadWithMatchedQuery(t *testing.T) {
rows := prettyBotFanoutRows([]fanoutBot{{
searchBot: searchBot{OpenID: "ou_a", Name: "会议助手", Description: strings.Repeat("长", 80)},
MatchedQuery: "会议",
}})
if len(rows) != 1 {
t.Fatalf("rows: %d", len(rows))
}
if rows[0]["matched_query"] != "会议" {
t.Errorf("matched_query missing: %+v", rows[0])
}
if got := rows[0]["description"].(string); len([]rune(got)) > 51 {
t.Errorf("description must be truncated like the single-search table: %d runes", len([]rune(got)))
}
}
func TestBotFanoutValidationRejectsQueryAndQueriesTogether(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "query", "会议")
setBotSearchFlag(t, cmd, "queries", "会议,日报")
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
err := validateBotSearch(runtime)
if err == nil {
t.Fatal("expected mutual-exclusion error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem: %+v ok=%v", problem, ok)
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("message: %v", err)
}
}
func TestBotFanoutValidationLimits(t *testing.T) {
tests := []struct {
name string
queries string
wantParam string
}{
{name: "nothing parses", queries: " , , ", wantParam: "--queries"},
{name: "over the entry cap", queries: strings.TrimSuffix(strings.Repeat("q%d,", maxFanoutQueries+1), ","), wantParam: "--queries"},
{name: "entry too long", queries: strings.Repeat("会", maxBotSearchQueryChars+1), wantParam: "--queries"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
queries := tt.queries
if strings.Contains(queries, "%d") {
parts := make([]string, 0, maxFanoutQueries+1)
for i := 0; i <= maxFanoutQueries; i++ {
parts = append(parts, fmt.Sprintf("q%d", i))
}
queries = strings.Join(parts, ",")
}
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "queries", queries)
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
assertBotSearchValidationProblem(t, validateBotSearch(runtime), tt.wantParam)
})
}
}
// --queries alone is enough: the single-search "--query is required" rule must not
// leak into fanout mode.
func TestBotFanoutValidationQueriesAloneIsValid(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "queries", "会议助手,日报助手")
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
if err := validateBotSearch(runtime); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestBotFanoutFilterAppliedToEveryQuery(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL+"?page_size=20", "")
stub.Reusable = true
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--has-chatted", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(stub.CapturedBodies) != 2 {
t.Fatalf("expected one request per query, got %d", len(stub.CapturedBodies))
}
seen := make(map[string]bool, len(stub.CapturedBodies))
for i, raw := range stub.CapturedBodies {
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal req %d: %v", i, err)
}
seen[fmt.Sprint(body["query"])] = true
filter, ok := body["filter"].(map[string]interface{})
if !ok || filter["has_chatter"] != true {
t.Fatalf("filter must ride along with every query: %#v", body)
}
}
for _, q := range []string{"会议", "日报"} {
if !seen[q] {
t.Fatalf("query %q never issued; saw %v", q, seen)
}
}
}
func TestBotFanoutMatchedQueryFidelityAndDedup(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
dedupStub := botSearchStub(botSearchURL+"?page_size=20", "")
dedupStub.Reusable = true
registry.Register(dedupStub)
// " 会议 " and "会议" collapse to one query; the duplicate must not double the
// requests or the rows.
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", " 会议 ,会议", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envelope struct {
Data botFanoutResponse `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
}
if len(envelope.Data.Queries) != 1 || envelope.Data.Queries[0].Query != "会议" {
t.Fatalf("dedup failed: %+v", envelope.Data.Queries)
}
for _, bot := range envelope.Data.Bots {
if bot.MatchedQuery != "会议" {
t.Fatalf("matched_query fidelity: %+v", bot)
}
}
}
func TestBotFanoutConcurrencyCap(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
var inFlight, peak int32
stub := botSearchStub(botSearchURL+"?page_size=20", "")
stub.Reusable = true
stub.OnMatch = func(req *http.Request) {
cur := atomic.AddInt32(&inFlight, 1)
defer atomic.AddInt32(&inFlight, -1)
for {
p := atomic.LoadInt32(&peak)
if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) {
break
}
}
time.Sleep(50 * time.Millisecond)
}
registry.Register(stub)
queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if peak > fanoutConcurrency {
t.Errorf("concurrency peak = %d, want <= %d", peak, fanoutConcurrency)
}
if peak < 2 {
t.Errorf("concurrency peak = %d, want >= 2 so the test actually observes parallelism", peak)
}
}
func TestBotFanoutPanicFailsBatch(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
panicCause := errors.New("synthetic test panic")
boom := botSearchStub(botSearchURL, "")
boom.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"boom"`) }
boom.OnMatch = func(req *http.Request) { panic(panicCause) }
registry.Register(boom)
okStub := botSearchStub(botSearchURL, "")
okStub.Reusable = true
registry.Register(okStub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "ok,boom,fine", "--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("a panicking query must fail the batch")
}
if !errors.Is(err, panicCause) {
t.Fatalf("panic cause must be preserved: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("problem: got %+v, want internal/%s", problem, errs.SubtypeUnknown)
}
if stdout.Len() != 0 {
t.Fatalf("terminal failure must not write a success envelope: %s", stdout.String())
}
for _, marker := range []string{"goroutine ", ".go:", "runtime."} {
if strings.Contains(stderr.String(), marker) {
t.Errorf("stderr leaked stack-trace marker %q: %s", marker, stderr.String())
}
}
}
func TestBotFanoutAllQueriesFailingExitsNonZero(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(&httpmock.Stub{
Method: "POST",
URL: botSearchURL,
Reusable: true,
Status: 500,
Body: map[string]interface{}{"reason": "boom"},
})
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("every query failing must surface as a command error")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("expected a typed problem, got %T: %v", err, err)
}
// The first failure's upstream status and the all-failed mode must both survive,
// so a caller can classify instead of seeing a generic internal error.
for _, want := range []string{"500", "all 2 queries failed"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("message must contain %q; got %v", want, err)
}
}
}
func TestBotFanoutPartialFailureKeepsNoticeAndSucceeds(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
broken := botSearchStub(botSearchURL, "")
broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) }
broken.Status = 500
broken.Body = map[string]interface{}{"reason": "boom"}
registry.Register(broken)
okStub := botSearchStub(botSearchURL, "")
okStub.Reusable = true
registry.Register(okStub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("one failing query must not fail the batch: %v", err)
}
var envelope struct {
Data botFanoutResponse `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
}
const wantNotice = "The query is too long and has been truncated to the first 50 characters for search."
// Assert the notice itself, not just that some row survived: the surviving
// query's server remark has to reach the caller both at the top level and in
// its own sidecar entry.
if envelope.Data.Notice != wantNotice {
t.Errorf("top-level notice: got %q, want %q", envelope.Data.Notice, wantNotice)
}
if len(envelope.Data.Queries) != 2 {
t.Fatalf("both queries must be enumerated: %+v", envelope.Data.Queries)
}
if envelope.Data.Queries[0].Notice != wantNotice {
t.Errorf("surviving query notice: got %q, want %q", envelope.Data.Queries[0].Notice, wantNotice)
}
if envelope.Data.Queries[0].Error != "" {
t.Errorf("surviving query must carry no error: %q", envelope.Data.Queries[0].Error)
}
if !strings.Contains(envelope.Data.Queries[1].Error, "500") {
t.Errorf("failed query must carry the upstream status: %q", envelope.Data.Queries[1].Error)
}
// Only the surviving query contributes rows.
if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].MatchedQuery != "会议" {
t.Fatalf("bots: %+v", envelope.Data.Bots)
}
}
func TestBotFanoutCSVCarriesMatchedQueryAndSummary(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL, "")
stub.Reusable = true
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "csv", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "matched_query") {
t.Errorf("csv must expose matched_query so rows can be traced to a keyword: %s", stdout.String())
}
// csv is in the summary format set, so the batch counters belong on stderr.
if !strings.Contains(stderr.String(), "2 queries, 2 total matches") || !strings.Contains(stderr.String(), "0 failed") {
t.Errorf("stderr summary must report the batch counters: %s", stderr.String())
}
if strings.Contains(stderr.String(), "total bots") {
t.Errorf("summary must count matches rather than imply unique bots: %s", stderr.String())
}
}
func TestBotFanoutNDJSONKeepsStdoutClean(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL, "")
stub.Reusable = true
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "ndjson", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
// ndjson is a machine format outside the summary set: every stdout line must
// parse, and the counters must not be mixed in.
for i, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
if line == "" {
continue
}
var row map[string]interface{}
if err := json.Unmarshal([]byte(line), &row); err != nil {
t.Fatalf("stdout line %d is not JSON: %q", i, line)
}
}
if strings.Contains(stderr.String(), "queries,") {
t.Errorf("ndjson must not emit the summary line: %s", stderr.String())
}
}
// TestBotFanoutCancelledSchedulingFailsQueuedQueries drives the real command so
// the scheduler inside executeBotSearchFanout — not just runOneBotQuery — sees
// the cancellation. Queueing more keywords than fanoutConcurrency while every
// worker is parked keeps all semaphore slots held, so the queued keywords can
// only leave the loop through its ctx.Done() branch.
func TestBotFanoutCancelledSchedulingFailsQueuedQueries(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started := make(chan struct{})
var once sync.Once
stub := botSearchStub(botSearchURL+"?page_size=20", "")
stub.Reusable = true
stub.OnMatch = func(*http.Request) {
once.Do(func() { close(started) })
<-ctx.Done() // hold the slot so later keywords must queue on the semaphore
}
registry.Register(stub)
go func() {
select {
case <-started:
case <-time.After(5 * time.Second): // never leave the workers parked
}
cancel()
}()
queries := make([]string, 0, fanoutConcurrency+3)
for i := 0; i < fanoutConcurrency+3; i++ {
queries = append(queries, fmt.Sprintf("q%d", i))
}
err := mountAndRunContext(t, ctx, ContactSearchBot, []string{
"+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("a cancelled batch must surface as a command error")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation cause must be preserved: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport)
}
}
// TestBotFanoutCancelledContextShortCircuitsBeforeRequest pins the other half:
// a queued worker must fail on the pre-check instead of issuing its request.
func TestBotFanoutCancelledContextShortCircuitsBeforeRequest(t *testing.T) {
results := make([]botFanoutResult, 0, 2)
ctx, cancel := context.WithCancel(context.Background())
cancel()
for i, q := range []string{"会议", "日报"} {
results = append(results, runOneBotQuery(ctx, nil, i, q, nil))
}
for _, r := range results {
if r.ErrMsg == "" {
t.Fatalf("a cancelled context must short-circuit before the request: %+v", r)
}
}
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err == nil {
t.Fatal("all queries cancelled must surface as an error")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation cause must be preserved: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport)
}
}
func TestBotFanoutDryRunPreviewsOneRequestPerKeyword(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "queries", "会议, 日报 ,会议")
setBotSearchFlag(t, cmd, "chat-ids", "oc_a")
setBotSearchFlag(t, cmd, "has-chatted", "true")
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
raw, err := json.Marshal(ContactSearchBot.DryRun(context.Background(), runtime))
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
var preview struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body struct {
Query string `json:"query"`
Filter *struct {
ChatIDs []string `json:"chat_ids"`
HasChatter bool `json:"has_chatter"`
} `json:"filter"`
} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(raw, &preview); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, raw)
}
// Deduped, so the repeated keyword previews once — the preview has to match
// the requests Execute would actually issue.
if len(preview.API) != 2 {
t.Fatalf("expected one previewed request per deduped keyword, got %d: %s", len(preview.API), raw)
}
seen := make([]string, 0, len(preview.API))
for i, call := range preview.API {
if call.Method != "POST" || call.URL != botSearchURL {
t.Errorf("api[%d]: got %s %s", i, call.Method, call.URL)
}
if call.Params["page_size"] != float64(20) {
t.Errorf("api[%d] page_size: %v", i, call.Params["page_size"])
}
if _, ok := call.Params["page_token"]; ok {
t.Errorf("api[%d] must not preview a page_token: %v", i, call.Params)
}
// The filter rides along with every keyword, not just the first.
if call.Body.Filter == nil || !call.Body.Filter.HasChatter ||
len(call.Body.Filter.ChatIDs) != 1 || call.Body.Filter.ChatIDs[0] != "oc_a" {
t.Errorf("api[%d] filter: %+v", i, call.Body.Filter)
}
seen = append(seen, call.Body.Query)
}
if fmt.Sprint(seen) != fmt.Sprint([]string{"会议", "日报"}) {
t.Errorf("previewed keywords: got %v, want [会议 日报]", seen)
}
}
// The summary counts how many queries failed but never says which or why, and
// only json carries queries[].error. Without a per-query line on stderr an agent
// reading csv sees "1 failed" and cannot recover the keyword or the reason.
func TestBotFanoutFailedQueryIsNamedOnStderr(t *testing.T) {
for _, format := range []string{"csv", "table", "pretty", "ndjson"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
broken := botSearchStub(botSearchURL, "")
broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) }
broken.Status = 500
broken.Body = map[string]interface{}{"reason": "boom"}
registry.Register(broken)
okStub := botSearchStub(botSearchURL, "")
okStub.Reusable = true
registry.Register(okStub)
if err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", format, "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("one failing query must not fail the batch: %v", err)
}
for _, want := range []string{"日报", "500"} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("%s: stderr must name the failed query and its reason (missing %q)\nstderr:\n%s",
format, want, stderr.String())
}
}
})
}
}

View File

@@ -0,0 +1,724 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func newBotSearchTestCommand() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("chat-ids", "", "")
cmd.Flags().Bool("has-chatted", false, "")
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().String("queries", "", "")
return cmd
}
func botSearchDefaultConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
UserOpenId: "ou_self",
}
}
func setBotSearchFlag(t *testing.T, cmd *cobra.Command, name, value string) {
t.Helper()
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s=%q: %v", name, value, err)
}
}
func assertBotSearchValidationProblem(t *testing.T, err error, wantParam string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem: got %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if validationErr.Param != wantParam {
t.Fatalf("param: got %q, want %q", validationErr.Param, wantParam)
}
}
// assertBotSearchValidationParams covers the errors that name several flags via
// WithParams; those leave the single Param empty on purpose, so an agent reading
// the envelope sees every flag that could satisfy the requirement.
func assertBotSearchValidationParams(t *testing.T, err error, wantParams []string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem: %+v ok=%v", problem, ok)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
got := make([]string, 0, len(validationErr.Params))
for _, p := range validationErr.Params {
if p.Reason == "" {
t.Errorf("param %q has no reason; agents read it to pick a recovery", p.Name)
}
got = append(got, p.Name)
}
if fmt.Sprint(got) != fmt.Sprint(wantParams) {
t.Fatalf("params: got %v, want %v", got, wantParams)
}
}
func TestValidateBotSearchErrors(t *testing.T) {
chatIDs := make([]string, 101)
for i := range chatIDs {
chatIDs[i] = fmt.Sprintf("oc_%03d", i)
}
tests := []struct {
name string
flags map[string]string
wantParam string
wantParams []string // set instead of wantParam when the error names several flags
wantMessage string
}{
{
name: "keyword missing",
wantParams: []string{"--query", "--queries"},
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
},
{
name: "query over 50 characters",
flags: map[string]string{"query": strings.Repeat("中", 51)},
wantParam: "--query",
wantMessage: "--query: length must be between 1 and 50 characters",
},
{
name: "chat ids parse empty",
flags: map[string]string{"query": "x", "chat-ids": " , , "},
wantParam: "--chat-ids",
wantMessage: "--chat-ids: no valid chat_id parsed from \", ,\" (separate entries with ',')",
},
{
name: "over 100 chat ids",
flags: map[string]string{"query": "x", "chat-ids": strings.Join(chatIDs, ",")},
wantParam: "--chat-ids",
wantMessage: "--chat-ids: must be at most 100 entries",
},
{
name: "invalid chat id",
flags: map[string]string{"query": "x", "chat-ids": "bad"},
wantParam: "--chat-ids",
wantMessage: "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)",
},
{
// With a keyword present the keyword errors win, exactly as +search-user
// orders them; the =false check must not be hoisted above these.
name: "mutually exclusive keywords outrank has chatted false",
flags: map[string]string{"query": "x", "queries": "y", "has-chatted": "false"},
wantParams: []string{"--query", "--queries"},
wantMessage: "--query and --queries are mutually exclusive",
},
{
name: "query length outranks has chatted false",
flags: map[string]string{"query": strings.Repeat("中", 51), "has-chatted": "false"},
wantParam: "--query",
wantMessage: "--query: length must be between 1 and 50 characters",
},
{
// With no keyword at all the explicit =false is the more specific mistake,
// so it wins over the missing-keyword error rather than costing a second
// round trip. Matches which error +search-user reports first.
name: "has chatted false without a keyword",
flags: map[string]string{"has-chatted": "false"},
wantParam: "--has-chatted",
wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
},
{
name: "has chatted false",
flags: map[string]string{"query": "x", "has-chatted": "false"},
wantParam: "--has-chatted",
wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
},
{
name: "page size below one",
flags: map[string]string{"query": "x", "page-size": "0"},
wantParam: "--page-size",
wantMessage: "--page-size: must be between 1 and 30",
},
{
name: "page size over 30",
flags: map[string]string{"query": "x", "page-size": "31"},
wantParam: "--page-size",
wantMessage: "--page-size: must be between 1 and 30",
},
{
name: "chat ids without a keyword",
flags: map[string]string{"chat-ids": "oc_a"},
wantParams: []string{"--query", "--queries"},
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
},
{
name: "has chatted without a keyword",
flags: map[string]string{"has-chatted": "true"},
wantParams: []string{"--query", "--queries"},
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
for name, value := range tt.flags {
setBotSearchFlag(t, cmd, name, value)
}
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
err := validateBotSearch(runtime)
if len(tt.wantParams) > 0 {
assertBotSearchValidationParams(t, err, tt.wantParams)
} else {
assertBotSearchValidationProblem(t, err, tt.wantParam)
}
if err.Error() != tt.wantMessage {
t.Fatalf("message: got %q, want %q", err.Error(), tt.wantMessage)
}
})
}
}
func TestValidateBotSearchPassingCases(t *testing.T) {
tests := []struct {
name string
flags map[string]string
}{
{name: "query only", flags: map[string]string{"query": "x"}},
{name: "query and chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}},
{name: "query and has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}},
{name: "all filters", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}},
{name: "page size upper boundary", flags: map[string]string{"query": "x", "page-size": "30"}},
// An explicitly blank string flag reads as "no filter", matching how
// +search-user treats --user-ids / --queries. Only a non-blank value that
// parses to zero entries is an error.
{name: "blank chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": ""}},
{name: "whitespace chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": " "}},
// Duplicates collapse before the cap is checked, so 101 copies of one chat
// is one entry — matching how --user-ids is resolved for +search-user.
{name: "duplicate chat ids collapse under the cap", flags: map[string]string{
"query": "x", "chat-ids": strings.TrimSuffix(strings.Repeat("oc_a,", 101), ","),
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
for name, value := range tt.flags {
setBotSearchFlag(t, cmd, name, value)
}
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
if err := validateBotSearch(runtime); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestValidateBotSearchQueryRuneBoundary(t *testing.T) {
for _, tt := range []struct {
name string
query string
wantError bool
}{
{name: "50 CJK characters", query: strings.Repeat("中", 50)},
{name: "51 CJK characters", query: strings.Repeat("中", 51), wantError: true},
} {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "query", tt.query)
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
err := validateBotSearch(runtime)
if tt.wantError {
assertBotSearchValidationProblem(t, err, "--query")
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestBuildBotSearchBody(t *testing.T) {
tests := []struct {
name string
flags map[string]string
wantJSON string
}{
{name: "query only", flags: map[string]string{"query": "x"}, wantJSON: `{"query":"x"}`},
{name: "chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
{name: "chat id URL normalized", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
{name: "has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"has_chatter":true}}`},
{name: "all fields", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"],"has_chatter":true}}`},
// A blank --chat-ids must not materialize an empty filter object.
{name: "blank chat ids omit filter", flags: map[string]string{"query": "x", "chat-ids": " "}, wantJSON: `{"query":"x"}`},
// Deduped after normalization, so a repeated id and a URL naming the same
// chat both collapse into one entry instead of burning the server's quota.
{name: "duplicate chat ids deduped", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
{name: "URL and bare id dedupe to one", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_a"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a"]}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
for name, value := range tt.flags {
setBotSearchFlag(t, cmd, name, value)
}
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
body, err := buildBotSearchBody(runtime)
if err != nil {
t.Fatalf("build body: %v", err)
}
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
if string(raw) != tt.wantJSON {
t.Fatalf("body: got %s, want %s", raw, tt.wantJSON)
}
})
}
}
func TestParseBotDisplayInfo(t *testing.T) {
tests := []struct {
name string
raw string
wantName string
wantDescription string
wantSegments []string
}{
// Whole name highlighted, description on line two.
{name: "whole name highlighted", raw: "<h>甲乙丙</h>\n一句话简介", wantName: "甲乙丙", wantDescription: "一句话简介", wantSegments: []string{"甲乙丙"}},
// Two highlighted runs split by a plain character: stripping tags has to
// rejoin them into one name.
{name: "two highlighted runs", raw: "<h>甲乙</h>丁<h>丙</h>\n另一句简介", wantName: "甲乙丁丙", wantDescription: "另一句简介", wantSegments: []string{"甲乙", "丙"}},
// Highlight at the end plus a trailing newline: line two exists but is empty.
{name: "trailing newline empty description", raw: "戊己的<h>庚辛</h>\n", wantName: "戊己的庚辛", wantSegments: []string{"庚辛"}},
// Single highlighted character in the middle of the name.
{name: "mid-name highlight", raw: "壬癸<h>子</h>丑\n第二行简介", wantName: "壬癸子丑", wantDescription: "第二行简介", wantSegments: []string{"子"}},
{name: "no newline", raw: "寅卯", wantName: "寅卯", wantSegments: []string{}},
{name: "html entities", raw: "<h>Lark</h>部门成员&amp;仓库\n来自飞书&#22810;维表格", wantName: "Lark部门成员&仓库", wantDescription: "来自飞书多维表格", wantSegments: []string{"Lark"}},
{name: "html entity in highlight", raw: "名称<h>&amp;</h>工具", wantName: "名称&工具", wantSegments: []string{"&"}},
{name: "empty", raw: "", wantSegments: []string{}},
{name: "first non-empty line", raw: "\n\n真名", wantName: "真名", wantSegments: []string{}},
// A blank first line must not make the description echo the name back and
// swallow the real description on the line after it.
{name: "blank first line keeps description", raw: "\n真名\n简介", wantName: "真名", wantDescription: "简介", wantSegments: []string{}},
{name: "blank first line without description", raw: "\n真名", wantName: "真名", wantSegments: []string{}},
// A highlight with no text carries nothing; an empty match segment is junk
// in the envelope. Which line the name comes from is left unchanged.
{name: "empty highlight yields no segment", raw: "<h></h>\n简介", wantName: "简介", wantSegments: []string{}},
// The non-greedy pattern pairs a stray `<h>` with the next `</h>`, so the
// capture can carry a tag the name and description already dropped.
{name: "nested highlight", raw: "<h>甲<h>乙</h></h>\n简介", wantName: "甲乙", wantDescription: "简介", wantSegments: []string{"甲乙"}},
{name: "dangling open tag", raw: "<h><h>甲</h>\n简介", wantName: "甲", wantDescription: "简介", wantSegments: []string{"甲"}},
{name: "unclosed highlight", raw: "<h>甲乙\n简介", wantName: "甲乙", wantDescription: "简介", wantSegments: []string{}},
// A literal `<h>` in a name arrives escaped, so it must survive: tags are
// stripped before unescaping. Swapping that order eats the name's own text.
{name: "escaped angle brackets are name text", raw: "名称&lt;h&gt;工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{}},
{name: "escaped angle brackets inside a highlight", raw: "<h>名称&lt;h&gt;</h>工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{"名称<h>"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, description, segments := parseBotDisplayInfo(tt.raw)
if name != tt.wantName || description != tt.wantDescription {
t.Fatalf("name/description: got %q/%q, want %q/%q", name, description, tt.wantName, tt.wantDescription)
}
if segments == nil {
t.Fatal("match segments must be an empty slice, not nil")
}
if fmt.Sprint(segments) != fmt.Sprint(tt.wantSegments) {
t.Fatalf("match segments: got %v, want %v", segments, tt.wantSegments)
}
})
}
}
func TestProjectBotsMapsEveryField(t *testing.T) {
data := &botSearchAPIData{Items: []botSearchAPIItem{
{
ID: "ou_with_chat",
DisplayInfo: "<h>甲乙丙</h>\n一句话简介",
MetaData: botSearchAPIMeta{
TenantID: "1", EnableJoinGroup: true, ChatID: "oc_p2p", IsAgent: true,
},
},
{
ID: "ou_without_chat",
DisplayInfo: "",
MetaData: botSearchAPIMeta{TenantID: "1"},
},
}}
bots := projectBots(data)
if len(bots) != 2 {
t.Fatalf("bots: got %d, want 2", len(bots))
}
first := bots[0]
if first.OpenID != "ou_with_chat" || first.Name != "甲乙丙" || first.Description != "一句话简介" ||
first.ChatID != "oc_p2p" || !first.EnableJoinGroup || !first.IsAgent || first.TenantID != "1" ||
fmt.Sprint(first.MatchSegments) != "[甲乙丙]" {
t.Fatalf("first bot mapping: %+v", first)
}
second := bots[1]
if second.Name != "" || second.ChatID != "" {
t.Fatalf("empty source fields must stay empty: %+v", second)
}
raw, err := json.Marshal(searchBotResponse{Bots: bots})
if err != nil {
t.Fatalf("marshal response: %v", err)
}
if !strings.Contains(string(raw), `"chat_id":""`) {
t.Fatalf("empty chat_id must still be emitted: %s", raw)
}
if !strings.Contains(string(raw), `"name":""`) {
t.Fatalf("empty name must not fall back to open_id: %s", raw)
}
if strings.Contains(string(raw), `"has_chatted"`) {
t.Fatalf("chat_id presence must not be exposed as a has_chatted signal: %s", raw)
}
}
func TestProjectBotsEmptySerializesAsArray(t *testing.T) {
bots := projectBots(&botSearchAPIData{Items: []botSearchAPIItem{}})
if bots == nil {
t.Fatal("bots must be an empty slice, not nil")
}
raw, err := json.Marshal(searchBotResponse{Bots: bots})
if err != nil {
t.Fatalf("marshal response: %v", err)
}
if string(raw) != `{"bots":[],"has_more":false}` {
t.Fatalf("response: got %s", raw)
}
}
func botSearchStub(url string, pageToken string) *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: url,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"has_more": true,
"page_token": pageToken,
"items": []interface{}{
map[string]interface{}{
"id": "ou_bot",
"display_info": "<h>甲乙丙</h>\n一句话简介",
"meta_data": map[string]interface{}{
"tenant_id": "1", "enable_join_group": true, "chat_id": "oc_p2p", "is_agent": false,
},
},
},
},
},
}
}
func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL+"?page_size=25", "cursor_out")
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--chat-ids", "oc_a,oc_b", "--has-chatted",
"--page-size", "25", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil {
t.Fatalf("request body: %v", err)
}
if requestBody["query"] != "甲乙" {
t.Fatalf("request query: got %v", requestBody["query"])
}
filter, ok := requestBody["filter"].(map[string]interface{})
if !ok || filter["has_chatter"] != true || fmt.Sprint(filter["chat_ids"]) != "[oc_a oc_b]" {
t.Fatalf("request filter: %#v", requestBody["filter"])
}
var envelope struct {
Data searchBotResponse `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
}
if envelope.Data.Notice != "The query is too long and has been truncated to the first 50 characters for search." || !envelope.Data.HasMore {
t.Fatalf("response pass-through: %+v", envelope.Data)
}
if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].OpenID != "ou_bot" || envelope.Data.Bots[0].ChatID != "oc_p2p" {
t.Fatalf("bots: %+v", envelope.Data.Bots)
}
registry.Verify(t)
}
func TestBotSearchIntegrationNeverSurfacesPageToken(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
// The stub returns a token; the envelope must still not carry one, matching
// +search-user, which decodes page_token and drops it.
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "json", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v", err)
}
data := envelope["data"].(map[string]interface{})
if _, ok := data["page_token"]; ok {
t.Fatalf("page_token must never be surfaced: %v", data)
}
}
func TestBotSearchPrettyOutputAndPaginationHint(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "pretty", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, column := range []string{"name", "description", "is_agent", "enable_join_group", "open_id"} {
if !strings.Contains(stdout.String(), column) {
t.Errorf("pretty output missing %q: %s", column, stdout.String())
}
}
for _, genericField := range []string{"bots", "has_more", "notice", "tenant_id", "chat_id", "match_segments"} {
if strings.Contains(stdout.String(), genericField) {
t.Errorf("pretty output exposed %q: %s", genericField, stdout.String())
}
}
// pretty stdout carries rows only, so stderr has to carry both the server
// notice and the pagination hint.
for _, want := range []string{
"notice: The query is too long and has been truncated to the first 50 characters for search.",
"hint: more matches exist; narrow with --has-chatted or a more specific --query",
} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("pretty stderr missing %q: %q", want, stderr.String())
}
}
}
func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "table", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} {
if !strings.Contains(stdout.String(), field) {
t.Errorf("table output missing %q: %s", field, stdout.String())
}
}
// table stdout carries rows only, so stderr has to carry both the server
// notice and the pagination hint.
for _, want := range []string{
"notice: The query is too long and has been truncated to the first 50 characters for search.",
"hint: more matches exist; narrow with --has-chatted or a more specific --query",
} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("table stderr missing %q: %q", want, stderr.String())
}
}
}
// The old name and assertion here pinned a bug: csv and ndjson were the two
// formats that carried neither has_more in stdout nor a hint on stderr, so a
// machine caller read a truncated result as the whole answer. stdout stays
// data-only; the truncation signal belongs on stderr for every format whose
// stdout has no envelope.
func TestBotSearchCSVAndNDJSONCarryFullFieldsAndSignalTruncation(t *testing.T) {
for _, format := range []string{"csv", "ndjson"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", format, "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} {
if !strings.Contains(stdout.String(), field) {
t.Errorf("%s output missing %q: %s", format, field, stdout.String())
}
}
// stdout must stay data-only, so both the notice and the truncation
// signal have to arrive on stderr.
for _, want := range []string{"notice: The query is too long", "hint: more matches exist"} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("%s dropped %q from stderr: %q", format, want, stderr.String())
}
}
if strings.Contains(stdout.String(), "more matches exist") {
t.Fatalf("%s stdout must stay data-only: %s", format, stdout.String())
}
})
}
}
func TestBotSearchPrettyEmptyResult(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(&httpmock.Stub{
Method: "POST",
URL: botSearchURL + "?page_size=20",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
},
})
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "none", "--format", "pretty", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "No bots found.") {
t.Fatalf("pretty output: %q", stdout.String())
}
}
func TestBotSearchDryRunMirrorsRequest(t *testing.T) {
factory, stdout, _, _ := cmdutil.TestFactory(t, botSearchDefaultConfig())
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--chat-ids", "oc_a", "--has-chatted",
"--page-size", "25", "--dry-run", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envelope struct {
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body botSearchAPIRequest `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("dry-run JSON: %v", err)
}
if len(envelope.Data.API) != 1 {
t.Fatalf("api calls: got %d, want 1", len(envelope.Data.API))
}
call := envelope.Data.API[0]
if call.Method != "POST" || call.URL != botSearchURL || call.Params["page_size"] != float64(25) {
t.Fatalf("dry-run call: %+v", call)
}
if call.Body.Query != "甲乙" || call.Body.Filter == nil || fmt.Sprint(call.Body.Filter.ChatIDs) != "[oc_a]" || !call.Body.Filter.HasChatter {
t.Fatalf("dry-run body: %+v", call.Body)
}
}
func TestDecodeBotSearchAPIDataMarshalFailureTyped(t *testing.T) {
_, err := decodeBotSearchAPIData(map[string]interface{}{"bad": func() {}})
if err == nil {
t.Fatal("expected marshal failure")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem: %+v, ok=%v", problem, ok)
}
}
// Only the json envelope carries data.notice. If the other formats dropped it
// silently, a caller would read a truncated or incomplete result as a complete
// one, so every non-json format has to surface it on stderr instead.
func TestBotSearchNoticeReachesCallerInEveryFormat(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", ""))
if err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--format", format, "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
if strings.Contains(stdout.String(), notice) {
if format != "json" {
t.Fatalf("%s should not carry the notice in stdout: %s", format, stdout.String())
}
return
}
if !strings.Contains(stderr.String(), notice) {
t.Fatalf("%s dropped the notice entirely\nstdout:\n%s\nstderr:\n%s",
format, stdout.String(), stderr.String())
}
// stdout stays pipe-clean: the notice must not be mixed into the rows.
if format == "csv" && strings.Contains(stdout.String(), "notice") {
t.Fatalf("csv stdout must stay data-only: %s", stdout.String())
}
})
}
}
// has_more is the server saying "this is not the whole answer". Only the json
// envelope carries it, so every other format has to say so on stderr or a machine
// caller silently treats a truncated result as complete.
func TestBotSearchTruncationReachesCallerInEveryFormat(t *testing.T) {
for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor"))
if err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--format", format, "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
if format == "json" {
if !strings.Contains(stdout.String(), `"has_more": true`) {
t.Fatalf("json must carry has_more in the envelope: %s", stdout.String())
}
return
}
if !strings.Contains(stderr.String(), "more matches exist") {
t.Fatalf("%s left the caller unable to learn the result was truncated\nstdout:\n%s\nstderr:\n%s",
format, stdout.String(), stderr.String())
}
})
}
}

View File

@@ -550,6 +550,13 @@ func TestDecodeSearchUserAPIData_MarshalFailureTyped(t *testing.T) {
// mountAndRun mounts the shortcut under a parent cobra command and runs it
// with the given args. Mirrors the pattern used in other shortcut packages.
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
return mountAndRunContext(t, context.Background(), s, args, f, stdout)
}
// mountAndRunContext is mountAndRun with a caller-supplied context, so a test
// can cancel the run the shortcut actually sees (runShortcut reads cmd.Context).
func mountAndRunContext(t *testing.T, ctx context.Context, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "contact"}
s.Mount(parent, f)
@@ -559,7 +566,7 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
return parent.ExecuteContext(ctx)
}
// searchUserStub returns a representative user search response with a notice.

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
ContactSearchUser,
ContactSearchBot,
ContactGetUser,
}
}

View File

@@ -1,7 +1,7 @@
---
name: lark-contact
version: 1.0.0
description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名。当用户提到某人姓名要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。"
description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名,以及按关键词搜索当前用户可见的机器人 / 智能体(agent)。当用户提到一个名字要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。"
metadata:
requires:
bins: ["lark-cli"]
@@ -15,12 +15,19 @@ metadata:
| 想做什么 | user 身份 | bot 身份 |
|---|---|---|
| 按姓名 / 邮箱搜员工拿 open_id | [`+search-user`](references/lark-contact-search-user.md) | 不支持 |
| 按关键词搜索当前用户可见的机器人 / 智能体 | [`+search-bot`](references/lark-contact-search-bot.md) | 不支持 |
| 已知 open_id 取他人资料 | `+search-user --user-ids <id>` | [`+get-user --user-id <id>`](references/lark-contact-get-user.md) |
| 查看自己 | `+get-user``+search-user --user-ids me` | 不支持 |
| 查同事的个人状态 / 签名 | `user_profiles batch_query` | 不支持 |
已知 open_id 只是想发消息 / 排日程,不必经过 contact —— 直接 [`lark-im`](../lark-im/SKILL.md) / [`lark-calendar`](../lark-calendar/SKILL.md)。
### 名字没说清是人还是机器人 / 智能体
用户给的名字常常不表明类型。例如「和 reviewDuck 约个会」里的 reviewDuck 可能是同事昵称,也可能是机器人。
- 名字含 bot / agent / AI / 助手 / 机器人 / 智能体 / assistant 等明显特征时,反过来先搜机器人更快
- 不确定的话两边都搜一下
## 典型场景
找张三给他发消息:先搜,确认 open_id,再发:
@@ -42,11 +49,20 @@ lark-cli contact user_profiles batch_query \
搜索命中多条且后续操作有副作用(发消息、邀请会议等),把候选列给用户挑;不要擅自选第一条。
## 搜索机器人 / 智能体
`+search-bot` 使用 user 身份按关键词搜索当前用户可见的机器人,返回 `ou_` 开头的机器人 open_id。参数细节等见 [`lark-contact-search-bot.md`](references/lark-contact-search-bot.md)。
```bash
lark-cli contact +search-bot --query '会议助手' --as user
lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user
```
## 注意事项
- **41050 / Permission denied** 受当前身份的可见范围限制(条命令都可能遇到)。换 bot 身份或让管理员调整可见范围,细节见 [`lark-shared`](../lark-shared/SKILL.md)。
- **41050 / Permission denied** 受当前身份的可见范围限制(条命令都可能遇到)。细节见 [`lark-shared`](../lark-shared/SKILL.md)。
- **跨租户用户**(`is_cross_tenant=true`)多数业务字段为空字符串,这是飞书可见性规则,下游做空值兜底。
- **ID 类型**:默认 `open_id``+get-user` `--user-id-type union_id|user_id`;`+search-user` 只接受 `open_id`
- **ID 类型**:`+get-user`通过 `--user-id-type` 使用 `open_id``union_id``user_id`;`+search-user` 使用用户 open_id;`+search-bot` 不支持按 ID 查询,它按关键词搜索并返回机器人 open_id
## 不在本 skill 范围

View File

@@ -0,0 +1,60 @@
# +search-bot
按关键词搜索当前用户可见的机器人。仅支持 user 身份,需要 `search:bot` 权限。
- ✅ 用关键词搜索机器人并获取 open_id
- ✅ 一次搜索多个关键词(`--queries`)
- ✅ 在指定群范围内搜索机器人(`--chat-ids`)
## 参数
必须传 `--query``--queries``--chat-ids` 指定搜索范围,`--has-chatted` 筛选已聊过的机器人;两者都不能单独使用。
| Flag | 说明 |
|---|---|
| `--query <text>` | 搜索一个关键词,最多 50 个字符 |
| `--queries <csv>` | 并行搜索多个关键词,最多 20 个;每个最多 50 个字符。不能和 `--query` 一起使用 |
| `--chat-ids <csv>` | 只在指定群内搜索,最多 100 个群;支持群 ID 或群链接 |
| `--has-chatted` | 只返回聊过天的机器人;不需要时不要传此参数 |
| `--page-size <n>` | 返回条数,130,默认 20 |
```bash
lark-cli contact +search-bot --query '会议助手' --as user
lark-cli contact +search-bot --query '助手' --has-chatted --as user
lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user
```
## 输出
| 字段 | 类型 | 说明 | 空值时 |
|---|---|---|---|
| `open_id` | string | 机器人 ID | 始终非空 |
| `name` | string | 机器人名称 | 空字符串 |
| `description` | string | 机器人简介 | 字段省略 |
| `chat_id` | string | 与机器人的单聊 ID | 空字符串 |
| `enable_join_group` | bool | 是否允许加入群聊 | — |
| `is_agent` | bool | 是否是智能体 | — |
| `tenant_id` | string | 租户标识 | 字段省略 |
| `match_segments` | string[] | 命中的文本片段 | 无命中时为 `[]` |
### 没有分页
不支持分页。`has_more=true` 时改用更具体的关键词,或调整搜索范围。
### 多条命中怎么选
命中多个机器人时,结合 `description``is_agent` 判断。后续要发消息或拉群时,让用户确认目标,不要直接选择第一条。
```bash
lark-cli contact +search-bot --query '会议助手' \
--jq '.data.bots[] | select((.description // "") | contains("<功能关键词>"))' --as user
```
## fanout(`--queries`)
输出为 `{bots[], queries[], notice?}``has_more` 只出现在每个关键词的结果中。
- `bots[].matched_query`:该结果对应的关键词
- `queries[]`:每个关键词的执行结果,格式为 `{query, error?, has_more, notice?}`
- 部分关键词失败时保留其他结果;全部失败时命令报错
- `--chat-ids``--has-chatted` 对所有关键词生效

View File

@@ -107,6 +107,8 @@ metadata:
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素并使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py) 统一检查 XML、越界、重叠、空白页和内容稀疏风险。**
**CRITICAL — 创建或编辑后MUST 对当前演示文稿的全部页面做渲染视觉验收:从最新 `slides +xml-get` 回读结果取得完整 `slide_id` 清单,再用 `slides +screenshot` 每批最多 10 页覆盖全部页面,并实际查看每张截图。只抽查关键页、只生成截图未查看、或仅通过静态 lint 均不算完成;任一页面未截图、未查看或仍需修复时不得交付。**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
@@ -213,7 +215,9 @@ Step 3: 按 slide_plan.json 生成 XML → 创建
Step 4: 审查 & 交付
- 创建完成后,必须用 `slides +xml-get --presentation <xml_presentation_id>` 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
- 从最新回读结果取得完整 `slide_id` 清单,用 `slides +screenshot` 每批最多 10 页截图,直到覆盖全部页面;截图数量必须等于当前页面数量,并实际查看每张截图
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正,修正后重新截图并复验该页
- 任一页面未截图、未查看或仍需修复时不得交付,也不得声称已完成视觉验收
- 没问题 → 交付:使用 NotifyHuman 工具交付 PPT 链接
```

View File

@@ -0,0 +1,908 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Internal XSD model and constraint validation for the Slides lint entrypoint."""
from __future__ import annotations
import math
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from functools import lru_cache
from pathlib import Path
from typing import Any
XS_NS = "{http://www.w3.org/2001/XMLSchema}"
SML_NAMESPACE = "http://www.larkoffice.com/sml/2.0"
SML_READBACK_NAMESPACE = "/sml/2.0"
SML_HTTPS_READBACK_NAMESPACE = "https://www.larkoffice.com/sml/2.0"
ACCEPTED_SML_NAMESPACES = frozenset(
(SML_NAMESPACE, SML_READBACK_NAMESPACE, SML_HTTPS_READBACK_NAMESPACE)
)
def local_name(value: str) -> str:
if value.startswith("{"):
return value.rsplit("}", 1)[-1]
return value.rsplit(":", 1)[-1]
def direct_children(element: ET.Element, name: str) -> list[ET.Element]:
return [child for child in element if child.tag == f"{XS_NS}{name}"]
def first_direct_child(element: ET.Element, *names: str) -> ET.Element | None:
wanted = {f"{XS_NS}{name}" for name in names}
return next((child for child in element if child.tag in wanted), None)
def occurs_value(raw: str | None, default: int) -> int | None:
if raw == "unbounded":
return None
return int(raw) if raw is not None else default
@dataclass(frozen=True)
class SimpleTypeRule:
name: str
base: str | None = None
enums: tuple[str, ...] = ()
patterns: tuple[str, ...] = ()
bounds: tuple[tuple[str, Decimal], ...] = ()
length_bounds: tuple[tuple[str, int], ...] = ()
union_members: tuple[str, ...] = ()
@dataclass(frozen=True)
class AttributeRule:
name: str
type_name: str
required: bool
@dataclass(frozen=True)
class ElementRule:
name: str
type_name: str | None
inline_complex_type: ET.Element | None
ref_name: str | None
@dataclass(frozen=True)
class ChildRule:
element: ElementRule
min_occurs: int
max_occurs: int | None
order: int | None
@dataclass(frozen=True)
class ChoiceRequirement:
names: tuple[str, ...]
min_occurs: int
max_occurs: int | None
@dataclass(frozen=True)
class SchemaModel:
simple_types: dict[str, SimpleTypeRule]
complex_types: dict[str, ET.Element]
element_candidates: dict[str, tuple[ElementRule, ...]]
global_elements: dict[str, ElementRule]
def parse_simple_type(
element: ET.Element,
fallback_name: str,
simple_types: dict[str, SimpleTypeRule] | None = None,
) -> SimpleTypeRule:
name = element.attrib.get("name", fallback_name)
restriction = first_direct_child(element, "restriction")
union = first_direct_child(element, "union")
if union is not None:
union_members = [
local_name(member) for member in union.attrib.get("memberTypes", "").split()
]
for index, inline_simple in enumerate(direct_children(union, "simpleType"), start=1):
inline_name = f"__inline_union_member_{name}_{index}"
union_members.append(inline_name)
if simple_types is not None:
simple_types[inline_name] = parse_simple_type(
inline_simple,
inline_name,
simple_types,
)
return SimpleTypeRule(
name=name,
union_members=tuple(union_members),
)
if restriction is None:
return SimpleTypeRule(name=name)
facet_names = {
"minInclusive",
"minExclusive",
"maxInclusive",
"maxExclusive",
}
bounds: list[tuple[str, Decimal]] = []
length_bounds: list[tuple[str, int]] = []
for child in restriction:
facet = local_name(child.tag)
if "value" not in child.attrib:
continue
if facet in facet_names:
bounds.append((facet, Decimal(child.attrib["value"])))
elif facet in {"minLength", "maxLength"}:
length_bounds.append((facet, int(child.attrib["value"])))
return SimpleTypeRule(
name=name,
base=local_name(restriction.attrib.get("base", "string")),
enums=tuple(child.attrib["value"] for child in direct_children(restriction, "enumeration")),
patterns=tuple(child.attrib["value"] for child in direct_children(restriction, "pattern")),
bounds=tuple(bounds),
length_bounds=tuple(length_bounds),
)
def parse_element_rule(element: ET.Element) -> ElementRule | None:
raw_ref = element.attrib.get("ref")
name = element.attrib.get("name")
if name is None and raw_ref:
name = local_name(raw_ref)
if not name:
return None
return ElementRule(
name=name,
type_name=local_name(element.attrib["type"]) if element.attrib.get("type") else None,
inline_complex_type=first_direct_child(element, "complexType"),
ref_name=local_name(raw_ref) if raw_ref else None,
)
@lru_cache(maxsize=4)
def load_schema_model(schema_path: str) -> SchemaModel:
root = ET.parse(schema_path).getroot()
simple_types: dict[str, SimpleTypeRule] = {}
for element in direct_children(root, "simpleType"):
name = element.attrib.get("name")
if not name:
continue
simple_types[name] = parse_simple_type(element, name, simple_types)
for attribute in root.iter(f"{XS_NS}attribute"):
inline_simple = first_direct_child(attribute, "simpleType")
if inline_simple is None:
continue
inline_name = f"__inline_attribute_{attribute.attrib.get('name', 'anonymous')}_{id(attribute)}"
simple_types[inline_name] = parse_simple_type(inline_simple, inline_name, simple_types)
complex_types = {
element.attrib["name"]: element
for element in direct_children(root, "complexType")
if element.attrib.get("name")
}
candidates: dict[str, list[ElementRule]] = {}
for element in root.iter(f"{XS_NS}element"):
rule = parse_element_rule(element)
if rule is not None:
candidates.setdefault(rule.name, []).append(rule)
global_elements = {
rule.name: rule
for element in direct_children(root, "element")
if (rule := parse_element_rule(element)) is not None
}
return SchemaModel(
simple_types=simple_types,
complex_types=complex_types,
element_candidates={name: tuple(rules) for name, rules in candidates.items()},
global_elements=global_elements,
)
def attributes_for_complex_type(
complex_type: ET.Element,
model: SchemaModel,
resolving: set[str] | None = None,
) -> dict[str, AttributeRule]:
resolving = resolving or set()
attributes: dict[str, AttributeRule] = {}
for content_name in ("simpleContent", "complexContent"):
content = first_direct_child(complex_type, content_name)
if content is None:
continue
extension = first_direct_child(content, "extension")
if extension is None:
continue
base_name = local_name(extension.attrib.get("base", ""))
if base_name in model.complex_types and base_name not in resolving:
resolving.add(base_name)
attributes.update(attributes_for_complex_type(model.complex_types[base_name], model, resolving))
resolving.remove(base_name)
attributes.update(direct_attribute_rules(extension))
attributes.update(direct_attribute_rules(complex_type))
return attributes
def direct_attribute_rules(element: ET.Element) -> dict[str, AttributeRule]:
rules: dict[str, AttributeRule] = {}
for attribute in direct_children(element, "attribute"):
name = attribute.attrib.get("name")
if not name:
continue
type_name = local_name(attribute.attrib.get("type", "string"))
inline_simple = first_direct_child(attribute, "simpleType")
if inline_simple is not None:
type_name = f"__inline_attribute_{name}_{id(attribute)}"
rules[name] = AttributeRule(
name=name,
type_name=type_name,
required=attribute.attrib.get("use") == "required",
)
return rules
def attributes_for_element(rule: ElementRule, model: SchemaModel) -> dict[str, AttributeRule]:
complex_type = rule.inline_complex_type
if complex_type is None and rule.type_name in model.complex_types:
complex_type = model.complex_types[rule.type_name]
if complex_type is None:
return {}
return attributes_for_complex_type(complex_type, model)
def best_element_rule(element_name: str, model: SchemaModel) -> ElementRule | None:
candidates = model.element_candidates.get(element_name, ())
if not candidates:
return None
return max(
candidates,
key=lambda candidate: (
candidate.type_name is not None or candidate.inline_complex_type is not None,
len(attributes_for_element(candidate, model)),
),
)
def concrete_element_rule(rule: ElementRule, model: SchemaModel) -> ElementRule:
if rule.ref_name is not None:
return model.global_elements.get(rule.ref_name, rule)
if rule.type_name is not None or rule.inline_complex_type is not None:
return rule
candidate = best_element_rule(rule.name, model)
return candidate or rule
def complex_type_for_element(rule: ElementRule, model: SchemaModel) -> ET.Element | None:
rule = concrete_element_rule(rule, model)
if rule.inline_complex_type is not None:
return rule.inline_complex_type
if rule.type_name is not None:
return model.complex_types.get(rule.type_name)
return None
def particle_for_complex_type(complex_type: ET.Element) -> ET.Element | None:
particle = first_direct_child(complex_type, "sequence", "all", "choice")
if particle is not None:
return particle
for content_name in ("simpleContent", "complexContent"):
content = first_direct_child(complex_type, content_name)
if content is None:
continue
extension = first_direct_child(content, "extension")
if extension is not None:
return first_direct_child(extension, "sequence", "all", "choice")
return None
def multiplied_max(left: int | None, right: int | None) -> int | None:
if left is None or right is None:
return None
return left * right
def child_rules_for_complex_type(
complex_type: ET.Element,
) -> tuple[list[ChildRule], list[ChoiceRequirement]]:
particle = particle_for_complex_type(complex_type)
if particle is None:
return [], []
rules: list[ChildRule] = []
requirements: list[ChoiceRequirement] = []
next_order = 0
def add_element(
element: ET.Element,
*,
order: int | None,
optional_by_choice: bool,
max_multiplier: int | None,
) -> None:
element_rule = parse_element_rule(element)
if element_rule is None:
return
minimum = occurs_value(element.attrib.get("minOccurs"), 1) or 0
maximum = occurs_value(element.attrib.get("maxOccurs"), 1)
rules.append(
ChildRule(
element=element_rule,
min_occurs=0 if optional_by_choice else minimum,
max_occurs=multiplied_max(maximum, max_multiplier),
order=order,
)
)
def walk_group(
group: ET.Element,
*,
ordered: bool,
fixed_order: int | None = None,
optional_by_choice: bool = False,
max_multiplier: int | None = 1,
) -> None:
nonlocal next_order
kind = local_name(group.tag)
group_min = occurs_value(group.attrib.get("minOccurs"), 1) or 0
group_max = occurs_value(group.attrib.get("maxOccurs"), 1)
effective_max = multiplied_max(max_multiplier, group_max)
if kind == "choice":
choice_order = fixed_order
if choice_order is None and ordered:
choice_order = next_order
next_order += 1
names: list[str] = []
for child in group:
child_kind = local_name(child.tag)
if child_kind == "element":
parsed = parse_element_rule(child)
if parsed is not None:
names.append(parsed.name)
add_element(
child,
order=choice_order,
optional_by_choice=True,
max_multiplier=effective_max,
)
elif child_kind in {"sequence", "all", "choice"}:
walk_group(
child,
ordered=ordered,
fixed_order=choice_order,
optional_by_choice=True,
max_multiplier=effective_max,
)
if names and (group_min > 0 or effective_max is not None):
requirements.append(ChoiceRequirement(tuple(names), group_min, effective_max))
return
group_ordered = kind == "sequence"
for child in group:
child_kind = local_name(child.tag)
if child_kind == "element":
child_order = fixed_order
if child_order is None and ordered and group_ordered:
child_order = next_order
next_order += 1
add_element(
child,
order=child_order,
optional_by_choice=optional_by_choice or group_min == 0,
max_multiplier=effective_max,
)
elif child_kind in {"sequence", "all", "choice"}:
walk_group(
child,
ordered=ordered and group_ordered,
fixed_order=fixed_order,
optional_by_choice=optional_by_choice or group_min == 0,
max_multiplier=effective_max,
)
walk_group(particle, ordered=local_name(particle.tag) == "sequence")
return rules, requirements
def issue(
code: str,
path: str,
tag: str,
*,
attr: str | None,
expected: str,
actual: Any,
message: str,
hint: str,
) -> dict[str, Any]:
result: dict[str, Any] = {
"level": "error",
"code": code,
"path": path,
"tag": tag,
"expected": expected,
"actual": actual,
"message": message,
"hint": hint,
}
if attr is not None:
result["attr"] = attr
return result
def builtin_scalar_value(type_name: str, value: str) -> Decimal | str | bool:
if type_name in {"string", "anyURI"}:
return value
if type_name == "boolean":
if value not in {"true", "false", "1", "0"}:
raise ValueError("expected boolean")
return value in {"true", "1"}
if type_name in {"integer", "positiveInteger", "nonNegativeInteger"}:
if re.fullmatch(r"[+-]?\d+", value) is None:
raise ValueError("expected integer")
number = Decimal(value)
if type_name == "positiveInteger" and number <= 0:
raise ArithmeticError("expected positive integer")
if type_name == "nonNegativeInteger" and number < 0:
raise ArithmeticError("expected non-negative integer")
return number
if type_name in {"double", "decimal"}:
lexical_value = value.strip(" \t\n\r")
decimal_pattern = r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)"
double_pattern = decimal_pattern + r"(?:[eE][+-]?[0-9]+)?"
expected_pattern = double_pattern if type_name == "double" else decimal_pattern
if re.fullmatch(expected_pattern, lexical_value) is None:
raise ValueError(f"expected {type_name}")
try:
number = Decimal(lexical_value)
except InvalidOperation as error:
raise ValueError(f"expected {type_name}") from error
if not math.isfinite(float(number)):
raise ValueError(f"expected finite {type_name}")
return number
return value
def scalar_value_for_type(
type_name: str,
value: str,
model: SchemaModel,
resolving: set[str] | None = None,
) -> Decimal | str | bool:
resolving = resolving or set()
if type_name in resolving:
return value
rule = model.simple_types.get(type_name)
if rule is None or rule.base is None:
return builtin_scalar_value(type_name, value)
resolving.add(type_name)
try:
return scalar_value_for_type(rule.base, value, model, resolving)
finally:
resolving.remove(type_name)
@lru_cache(maxsize=32)
def python_pattern_for_xsd(pattern: str) -> str:
translated: list[str] = []
in_character_class = False
index = 0
while index < len(pattern):
char = pattern[index]
if char == "\\" and index + 1 < len(pattern):
escaped = pattern[index + 1]
if escaped in {"s", "S"}:
body = r" \t\n\r"
if in_character_class:
if escaped == "S":
raise ValueError(
f"unsupported complemented XSD character class \\{escaped} inside []"
)
translated.append(body)
else:
prefix = "^" if escaped == "S" else ""
translated.append(f"[{prefix}{body}]")
index += 2
continue
translated.extend((char, escaped))
index += 2
continue
if char == "[":
in_character_class = True
elif char == "]":
in_character_class = False
elif char == "." and not in_character_class:
translated.append(r"[^\n\r]")
index += 1
continue
elif char in "^$" and not in_character_class:
translated.append(f"\\{char}")
index += 1
continue
translated.append(char)
index += 1
return "".join(translated)
def xsd_pattern_matches(pattern: str, value: str) -> bool:
if pattern == r"[\w.-]+[.:]\S*":
if any(character in " \t\n\r" for character in value):
return False
for index, character in enumerate(value):
if index > 0 and character in ".:":
return True
if not (character == "_" or character.isalnum() or character in ".-"):
return False
return False
return re.fullmatch(python_pattern_for_xsd(pattern), value) is not None
def value_error_for_type(
type_name: str,
value: str,
model: SchemaModel,
resolving: set[str] | None = None,
) -> tuple[str, str] | None:
resolving = resolving or set()
if type_name in resolving:
return None
rule = model.simple_types.get(type_name)
if rule is None:
try:
builtin_scalar_value(type_name, value)
except ValueError:
return "sxsd_invalid_scalar", f"value valid for {type_name}"
except ArithmeticError:
return "sxsd_value_out_of_range", f"value in the range allowed by {type_name}"
return None
resolving.add(type_name)
try:
if rule.union_members:
member_errors = [value_error_for_type(member, value, model, resolving) for member in rule.union_members]
if any(error is None for error in member_errors):
return None
unsupported_error = next(
(error for error in member_errors if error and error[0] == "sxsd_unsupported_pattern"),
None,
)
if unsupported_error is not None:
return unsupported_error
if any(error and error[0] == "sxsd_pattern_mismatch" for error in member_errors):
return "sxsd_pattern_mismatch", f"value matching one member of {type_name}"
return member_errors[0]
if rule.enums and value not in rule.enums:
return "sxsd_invalid_enum", "one of: " + ", ".join(rule.enums)
if rule.patterns:
unsupported_patterns: list[str] = []
for pattern in rule.patterns:
try:
if xsd_pattern_matches(pattern, value):
break
except (ValueError, re.error) as error:
unsupported_patterns.append(f"{pattern!r}: {error}")
else:
if unsupported_patterns:
return (
"sxsd_unsupported_pattern",
"lint support for XSD pattern " + "; ".join(unsupported_patterns),
)
return "sxsd_pattern_mismatch", "value matching pattern " + " or ".join(rule.patterns)
base_name = rule.base or "string"
base_error = value_error_for_type(base_name, value, model, resolving)
if base_error is not None:
return base_error
for facet, bound in rule.length_bounds:
allowed = len(value) >= bound if facet == "minLength" else len(value) <= bound
if not allowed:
return "sxsd_value_out_of_range", f"{facet} {bound}"
scalar = scalar_value_for_type(base_name, value, model)
if isinstance(scalar, Decimal):
for facet, bound in rule.bounds:
allowed = {
"minInclusive": scalar >= bound,
"minExclusive": scalar > bound,
"maxInclusive": scalar <= bound,
"maxExclusive": scalar < bound,
}[facet]
if not allowed:
return "sxsd_value_out_of_range", f"{facet} {bound}"
return None
finally:
resolving.remove(type_name)
def validate_element_attributes(
element: ET.Element,
path: str,
model: SchemaModel,
element_rule: ElementRule | None = None,
) -> list[dict[str, Any]]:
tag = local_name(element.tag)
element_rule = element_rule or best_element_rule(tag, model)
if element_rule is None:
return []
attribute_rules = attributes_for_element(element_rule, model)
issues: list[dict[str, Any]] = []
for attr_rule in attribute_rules.values():
if attr_rule.required and attr_rule.name not in element.attrib:
issues.append(
issue(
"sxsd_missing_required_attr",
path,
tag,
attr=attr_rule.name,
expected=f"required attribute of type {attr_rule.type_name}",
actual=None,
message=f'missing required SXSD attribute "{attr_rule.name}" on <{tag}> at {path}',
hint=f'Add attribute "{attr_rule.name}" with a value valid for {attr_rule.type_name}.',
)
)
for raw_name, value in element.attrib.items():
attr_name = local_name(raw_name)
attr_rule = attribute_rules.get(attr_name)
if attr_rule is None:
continue
validation_error = value_error_for_type(attr_rule.type_name, value, model)
if validation_error is None:
continue
code, expected = validation_error
if code == "sxsd_unsupported_pattern":
message = (
f'unsupported SXSD pattern for attribute "{attr_name}" on <{tag}> at {path}'
)
hint = (
f"Extend the SXSD pattern interpreter for {attr_rule.type_name}; "
"do not treat this attribute value as validated."
)
else:
message = (
f'invalid SXSD value {value!r} for attribute "{attr_name}" on <{tag}> at {path}'
)
hint = f'Set attribute "{attr_name}" to a value valid for {attr_rule.type_name}.'
issues.append(
issue(
code,
path,
tag,
attr=attr_name,
expected=expected,
actual=value,
message=message,
hint=hint,
)
)
return issues
def element_namespace(tag: str) -> str | None:
if not tag.startswith("{"):
return None
return tag[1:].split("}", 1)[0]
def validate_element_children(
element: ET.Element,
path: str,
element_rule: ElementRule,
model: SchemaModel,
) -> tuple[list[dict[str, Any]], dict[int, ElementRule]]:
tag = local_name(element.tag)
complex_type = complex_type_for_element(element_rule, model)
child_rules, choice_requirements = (
child_rules_for_complex_type(complex_type) if complex_type is not None else ([], [])
)
rules_by_name: dict[str, list[ChildRule]] = {}
for child_rule in child_rules:
rules_by_name.setdefault(child_rule.element.name, []).append(child_rule)
issues: list[dict[str, Any]] = []
matched: dict[int, ElementRule] = {}
counts: dict[str, int] = {}
latest_order = -1
for child in element:
child_name = local_name(child.tag)
child_path = f"{path}/{child_name}"
candidates = rules_by_name.get(child_name, [])
if not candidates:
issues.append(
issue(
"sxsd_unexpected_child",
child_path,
child_name,
attr=None,
expected="one of: " + ", ".join(sorted(rules_by_name)) if rules_by_name else "no child elements",
actual=child_name,
message=f"unexpected SXSD child <{child_name}> under <{tag}> at {child_path}",
hint=f"Move or remove <{child_name}> so <{tag}> follows the SXSD child structure.",
)
)
continue
child_rule = candidates[0]
if child_rule.order is not None:
if child_rule.order < latest_order:
issues.append(
issue(
"sxsd_invalid_child_order",
child_path,
child_name,
attr=None,
expected="children in xs:sequence order",
actual=child_name,
message=f"SXSD child <{child_name}> is out of order under <{tag}> at {child_path}",
hint=f"Reorder <{child_name}> according to the SXSD sequence for <{tag}>.",
)
)
latest_order = max(latest_order, child_rule.order)
counts[child_name] = counts.get(child_name, 0) + 1
if child_rule.max_occurs is not None and counts[child_name] > child_rule.max_occurs:
issues.append(
issue(
"sxsd_too_many_children",
child_path,
child_name,
attr=None,
expected=f"at most {child_rule.max_occurs}",
actual=counts[child_name],
message=f"too many SXSD <{child_name}> children under <{tag}> at {path}",
hint=f"Keep at most {child_rule.max_occurs} <{child_name}> children under <{tag}>.",
)
)
matched[id(child)] = concrete_element_rule(child_rule.element, model)
for child_rule in child_rules:
child_name = child_rule.element.name
actual_count = counts.get(child_name, 0)
if child_rule.min_occurs <= actual_count:
continue
issues.append(
issue(
"sxsd_missing_required_child",
path,
tag,
attr=None,
expected=f"{child_name} (at least {child_rule.min_occurs})",
actual=actual_count,
message=f"missing required SXSD child <{child_name}> under <{tag}> at {path}",
hint=f"Add at least {child_rule.min_occurs} <{child_name}> child under <{tag}>.",
)
)
for requirement in choice_requirements:
actual_count = sum(counts.get(name, 0) for name in requirement.names)
expected_names = ", ".join(requirement.names)
if actual_count < requirement.min_occurs:
issues.append(
issue(
"sxsd_missing_required_child",
path,
tag,
attr=None,
expected=f"one of: {expected_names} (at least {requirement.min_occurs})",
actual=actual_count,
message=f"missing required SXSD choice child under <{tag}> at {path}",
hint=f"Add at least {requirement.min_occurs} child from: {expected_names}.",
)
)
if requirement.max_occurs is not None and actual_count > requirement.max_occurs:
issues.append(
issue(
"sxsd_too_many_children",
path,
tag,
attr=None,
expected=f"at most {requirement.max_occurs} child from: {expected_names}",
actual=actual_count,
message=f"too many SXSD choice children under <{tag}> at {path}",
hint=f"Keep at most {requirement.max_occurs} child from: {expected_names}.",
)
)
return issues, matched
def validate_sxsd(root: ET.Element, schema_path: Path) -> list[dict[str, Any]]:
model = load_schema_model(str(schema_path.resolve()))
issues: list[dict[str, Any]] = []
root_name = local_name(root.tag)
document_namespace = element_namespace(root.tag)
is_bare_slide_fragment = root_name == "slide" and document_namespace is None
has_valid_document_namespace = (
document_namespace in ACCEPTED_SML_NAMESPACES or is_bare_slide_fragment
)
def visit(element: ET.Element, parent_path: str, element_rule: ElementRule) -> None:
tag = local_name(element.tag)
path = f"{parent_path}/{tag}" if parent_path else tag
namespace = element_namespace(element.tag)
invalid_root_namespace = (
not parent_path
and namespace not in ACCEPTED_SML_NAMESPACES
and not is_bare_slide_fragment
)
invalid_descendant_namespace = (
bool(parent_path)
and has_valid_document_namespace
and namespace != document_namespace
)
if invalid_root_namespace or invalid_descendant_namespace:
expected_namespace = document_namespace if parent_path else SML_NAMESPACE
namespace_hint = (
"Keep SXSD descendants without xmlns in a bare <slide> readback fragment."
if expected_namespace is None
else f'Use xmlns="{expected_namespace}" for SXSD elements.'
)
issues.append(
issue(
"sxsd_invalid_namespace",
path,
tag,
attr=None,
expected=expected_namespace,
actual=namespace,
message=f"invalid SXSD namespace on <{tag}> at {path}",
hint=namespace_hint,
)
)
issues.extend(validate_element_attributes(element, path, model, element_rule))
child_issues, matched = validate_element_children(element, path, element_rule, model)
issues.extend(child_issues)
for child in element:
child_rule = matched.get(id(child))
if child_rule is not None:
visit(child, path, child_rule)
if root_name not in {"presentation", "slide"}:
issues.append(
issue(
"sxsd_unexpected_root",
root_name,
root_name,
attr=None,
expected="presentation or slide",
actual=root_name,
message=f"unsupported SXSD root <{root_name}>",
hint="Use a <presentation> or <slide> root.",
)
)
return issues
if root_name == "presentation":
root_rule = model.global_elements.get("presentation")
elif "SlideType" in model.complex_types:
root_rule = ElementRule("slide", "SlideType", None, None)
else:
root_rule = None
if root_rule is None:
issues.append(
issue(
"sxsd_unexpected_root",
root_name,
root_name,
attr=None,
expected="presentation or slide",
actual=root_name,
message=f"unsupported SXSD root <{root_name}>",
hint="Use a <presentation> or <slide> root.",
)
)
return issues
visit(root, "", root_rule)
return issues
def load_tag_attributes(schema_path: Path) -> dict[str, set[str]]:
model = load_schema_model(str(schema_path.resolve()))
tag_attributes: dict[str, set[str]] = {}
for tag_name, candidates in model.element_candidates.items():
attrs = tag_attributes.setdefault(tag_name, set())
for candidate in candidates:
attrs.update(attributes_for_element(concrete_element_rule(candidate, model), model))
return tag_attributes

View File

@@ -5,6 +5,7 @@
from __future__ import annotations
import copy
import json
import math
import re
@@ -16,6 +17,8 @@ from difflib import SequenceMatcher, get_close_matches
from pathlib import Path
from typing import Any
import sxsd_validator
XS_NS = "{http://www.w3.org/2001/XMLSchema}"
XML_NS = "{http://www.w3.org/XML/1998/namespace}"
@@ -44,9 +47,8 @@ ROUNDTRIP_SXSD_ATTRS = {
("chartData", "isStaticData"),
}
# Slides readback echoes each chartField's CSV text as per-value <chartParsedValues> children;
# it's server-emitted, absent from the write schema, and appears on virtually every chart-bearing
# deck, so treating it as an unsupported tag would block per-slide linting document-wide.
ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
# it is server-emitted and absent from the write schema, so it must not block page linting.
ROUNDTRIP_SXSD_TAGS = {("chartField", "chartParsedValues")}
DEFAULT_TABLE_COLUMN_WIDTH = 110
DEFAULT_TABLE_ROW_HEIGHT = 37
DEFAULT_TEXT_LINE_SPACING_MULTIPLE = 1.5
@@ -310,77 +312,13 @@ def xml_namespace(tag: str) -> str | None:
return tag.split("}", 1)[0] + "}" if tag.startswith("{") else None
def strip_xsd_prefix(value: str | None) -> str | None:
if value is None:
return None
return value.rsplit(":", 1)[-1]
def iter_direct_xsd_children(element: ET.Element, local_name: str) -> list[ET.Element]:
return [child for child in element if child.tag == f"{XS_NS}{local_name}"]
def load_sxsd_tag_attributes() -> dict[str, set[str]]:
global _SXSD_TAG_ATTRIBUTES_CACHE
if _SXSD_TAG_ATTRIBUTES_CACHE is not None:
return _SXSD_TAG_ATTRIBUTES_CACHE
schema_root = ET.parse(SXSD_SCHEMA_PATH).getroot()
named_complex_types = {
complex_type.attrib["name"]: complex_type
for complex_type in schema_root.findall(f"{XS_NS}complexType")
if complex_type.attrib.get("name")
}
resolving: set[str] = set()
def attributes_for_complex_type(complex_type: ET.Element) -> set[str]:
attrs: set[str] = {
attribute.attrib["name"]
for attribute in iter_direct_xsd_children(complex_type, "attribute")
if attribute.attrib.get("name")
}
for content_name in ("simpleContent", "complexContent"):
for complex_content in iter_direct_xsd_children(complex_type, content_name):
for extension in iter_direct_xsd_children(complex_content, "extension"):
base_type = strip_xsd_prefix(extension.attrib.get("base"))
if base_type:
attrs.update(attributes_for_type(base_type))
attrs.update(
attribute.attrib["name"]
for attribute in iter_direct_xsd_children(extension, "attribute")
if attribute.attrib.get("name")
)
return attrs
def attributes_for_type(type_name: str) -> set[str]:
if type_name in resolving:
return set()
complex_type = named_complex_types.get(type_name)
if complex_type is None:
return set()
resolving.add(type_name)
try:
return attributes_for_complex_type(complex_type)
finally:
resolving.remove(type_name)
tag_attributes: dict[str, set[str]] = {}
for element in schema_root.iter(f"{XS_NS}element"):
tag_name = element.attrib.get("name")
if not tag_name:
continue
attrs: set[str] = set()
type_name = strip_xsd_prefix(element.attrib.get("type"))
if type_name:
attrs.update(attributes_for_type(type_name))
for complex_type in iter_direct_xsd_children(element, "complexType"):
attrs.update(attributes_for_complex_type(complex_type))
tag_attributes.setdefault(tag_name, set()).update(attrs)
_SXSD_TAG_ATTRIBUTES_CACHE = tag_attributes
return tag_attributes
_SXSD_TAG_ATTRIBUTES_CACHE = sxsd_validator.load_tag_attributes(SXSD_SCHEMA_PATH)
return _SXSD_TAG_ATTRIBUTES_CACHE
def load_iconpark_icon_types() -> set[str]:
@@ -417,13 +355,19 @@ def build_sxsd_tag_hint(tag_name: str, supported_tags: set[str]) -> str:
return "Unsupported SXSD tag. Use only tags defined in slides_xml_schema_definition.xml."
def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str]) -> str:
def suggest_sxsd_attrs(attr_name: str, allowed_attrs: set[str]) -> list[str]:
alias = SXSD_ATTR_ALIASES.get(attr_name)
if alias and alias in allowed_attrs:
return f'Use "{alias}" on <{tag_name}> instead of "{attr_name}".'
close_matches = get_close_matches(attr_name, sorted(allowed_attrs), n=3, cutoff=0.68)
if close_matches:
return "Unsupported SXSD attribute. Did you mean " + ", ".join(f'"{match}"' for match in close_matches) + "?"
return [alias]
return get_close_matches(attr_name, sorted(allowed_attrs), n=3, cutoff=0.68)
def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str]) -> str:
suggestions = suggest_sxsd_attrs(attr_name, allowed_attrs)
if suggestions:
if SXSD_ATTR_ALIASES.get(attr_name) == suggestions[0]:
return f'Use "{suggestions[0]}" on <{tag_name}> instead of "{attr_name}".'
return "Unsupported SXSD attribute. Did you mean " + ", ".join(f'"{match}"' for match in suggestions) + "?"
allowed_summary = ", ".join(sorted(allowed_attrs)[:8])
if len(allowed_attrs) > 8:
allowed_summary += ", ..."
@@ -438,10 +382,33 @@ def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool:
return attr_name in SERVER_FILLED_SXSD_ATTRS or (tag_name, attr_name) in ROUNDTRIP_SXSD_ATTRS
def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
def should_skip_sxsd_tag(parent_name: str | None, tag_name: str) -> bool:
return (parent_name, tag_name) in ROUNDTRIP_SXSD_TAGS
def without_server_filled_sxsd_fields(root: ET.Element) -> ET.Element:
sanitized_root = copy.deepcopy(root)
def sanitize(element: ET.Element) -> None:
tag_name = xml_local_name(element.tag)
for raw_attr_name in list(element.attrib):
if should_skip_sxsd_attribute(tag_name, xml_local_name(raw_attr_name)):
del element.attrib[raw_attr_name]
for child in list(element):
if should_skip_sxsd_tag(tag_name, xml_local_name(child.tag)):
element.remove(child)
continue
sanitize(child)
sanitize(sanitized_root)
return sanitized_root
def validate_sxsd_document(xml: str, root: ET.Element) -> list[dict[str, Any]]:
tag_attributes = load_sxsd_tag_attributes()
supported_tags = set(tag_attributes)
issues: list[dict[str, Any]] = []
suggested_attr_candidates: dict[tuple[str, str], list[set[str]]] = {}
def visit(element: ET.Element, ancestors: list[str], path: str) -> None:
if should_skip_sxsd_subtree(element, ancestors):
@@ -449,7 +416,8 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
tag_name = xml_local_name(element.tag)
current_path = f"{path}/{tag_name}" if path else tag_name
if tag_name in ROUNDTRIP_SXSD_TAGS:
parent_name = ancestors[-1] if ancestors else None
if should_skip_sxsd_tag(parent_name, tag_name):
return
if tag_name not in supported_tags:
issues.append(
@@ -473,6 +441,11 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
continue
if attr_name in allowed_attrs:
continue
suggestions = suggest_sxsd_attrs(attr_name, allowed_attrs)
if suggestions:
suggested_attr_candidates.setdefault((current_path, tag_name), []).append(
set(suggestions)
)
issues.append(
{
"level": "error",
@@ -489,6 +462,76 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
visit(child, [*ancestors, tag_name], current_path)
visit(root, [], "")
existing = {
(issue.get("code"), issue.get("path"), issue.get("tag"), issue.get("attr"))
for issue in issues
}
unsupported_tag_locations = {
(issue.get("path"), issue.get("tag"))
for issue in issues
if issue.get("code") == "sxsd_unsupported_tag"
}
schema_issues = _validate_sxsd_schema_constraints(xml, root)
missing_attrs_by_location: dict[tuple[str, str], set[str]] = {}
for schema_issue in schema_issues:
if schema_issue.get("code") != "sxsd_missing_required_attr":
continue
location = (schema_issue.get("path"), schema_issue.get("tag"))
missing_attrs_by_location.setdefault(location, set()).add(schema_issue.get("attr"))
suggested_attrs: set[tuple[str, str, str]] = set()
for location, candidate_groups in suggested_attr_candidates.items():
missing_attrs = missing_attrs_by_location.get(location, set())
for candidates in candidate_groups:
matching_missing_attrs = candidates & missing_attrs
if len(matching_missing_attrs) == 1:
suggested_attrs.add((*location, next(iter(matching_missing_attrs))))
for schema_issue in schema_issues:
if schema_issue.get("code") == "sxsd_unexpected_child" and (
schema_issue.get("path"),
schema_issue.get("tag"),
) in unsupported_tag_locations:
continue
if schema_issue.get("code") == "sxsd_missing_required_attr" and (
schema_issue.get("path"),
schema_issue.get("tag"),
schema_issue.get("attr"),
) in suggested_attrs:
continue
key = (
schema_issue.get("code"),
schema_issue.get("path"),
schema_issue.get("tag"),
schema_issue.get("attr"),
)
if key not in existing:
issues.append(schema_issue)
return issues
def _validate_sxsd_schema_constraints(xml: str, root: ET.Element) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
if re.match(r"^\s*<\?xml\b", xml):
issues.append(
{
"level": "error",
"code": "sxsd_unsupported_declaration",
"path": xml_local_name(root.tag),
"tag": xml_local_name(root.tag),
"expected": "SXSD document without an XML declaration",
"actual": "<?xml ...?>",
"message": "XML declarations are not supported by the Slides SXSD write format",
"hint": "Remove the <?xml ...?> declaration and keep the SXSD root element.",
}
)
issues.extend(
sxsd_validator.validate_sxsd(
without_server_filled_sxsd_fields(root),
SXSD_SCHEMA_PATH,
)
)
return issues
@@ -692,18 +735,39 @@ def validate_xml_well_formed(xml: str) -> dict[str, Any] | None:
return xml_error
def parse_presentation(xml: str) -> dict[str, Any]:
presentation_match = re.search(r"<presentation\b([^>]*)>", xml)
if presentation_match:
return {
"width": int(float(extract_attribute(presentation_match.group(1), "width") or 960)),
"height": int(float(extract_attribute(presentation_match.group(1), "height") or 540)),
"slides": re.findall(r"<slide\b[\s\S]*?</slide>", xml),
def serialize_slide_for_layout(slide_root: ET.Element) -> str:
slide_copy = copy.deepcopy(slide_root)
for element in slide_copy.iter():
if not isinstance(element.tag, str):
continue
element.tag = xml_local_name(element.tag)
attributes = {
xml_local_name(attribute_name): value
for attribute_name, value in element.attrib.items()
}
slide_match = re.findall(r"<slide\b[\s\S]*?</slide>", xml)
if slide_match:
return {"width": 960, "height": 540, "slides": slide_match}
fail("input must contain a <presentation> or <slide> root")
element.attrib.clear()
element.attrib.update(attributes)
return ET.tostring(slide_copy, encoding="unicode")
def parse_presentation(root: ET.Element) -> dict[str, Any]:
root_name = xml_local_name(root.tag)
if root_name == "slide":
slide_roots = [root]
width = 960
height = 540
elif root_name == "presentation":
slide_roots = [child for child in root if xml_local_name(child.tag) == "slide"]
width = int(float(root.attrib.get("width", 960)))
height = int(float(root.attrib.get("height", 540)))
else:
fail("input must contain a <presentation> or <slide> root")
return {
"width": width,
"height": height,
"slides": [serialize_slide_for_layout(slide_root) for slide_root in slide_roots],
"slide_roots": slide_roots,
}
def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
@@ -2417,6 +2481,21 @@ def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) -
return "passed"
def is_slide_scoped_sxsd_issue(issue: dict[str, Any], root_name: str) -> bool:
if issue.get("code") == "sxsd_unsupported_declaration":
return False
if root_name == "slide":
return True
path = issue.get("path")
if not isinstance(path, str):
return False
if path.startswith("presentation/slide/"):
return True
return path == "presentation/slide" and (
issue.get("attr") is not None or issue.get("code") == "sxsd_invalid_namespace"
)
def build_result(
source_path: str | None,
slide_size: dict[str, int | float],
@@ -2472,11 +2551,20 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
raise AssertionError("parse_xml_root must return a root or error")
namespace_issues = validate_sml_tag_prefixes(xml)
sxsd_issues = validate_sxsd_tag_attributes(root)
root_name = xml_local_name(root.tag)
sxsd_issues = validate_sxsd_document(xml, root)
iconpark_issues = validate_iconpark_icon_types(root)
top_level_issues = [
normalize_issue(issue, None, {})
for issue in [*namespace_issues, *sxsd_issues, *iconpark_issues]
for issue in [
*namespace_issues,
*[
issue
for issue in sxsd_issues
if not is_slide_scoped_sxsd_issue(issue, root_name)
],
*iconpark_issues,
]
]
if any(issue["level"] == "error" for issue in top_level_issues):
return build_result(
@@ -2486,10 +2574,36 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
[],
)
presentation = parse_presentation(xml)
presentation = parse_presentation(root)
slide_roots = presentation["slide_roots"]
slides: list[dict[str, Any]] = []
for index, slide_xml in enumerate(presentation["slides"]):
slide_number = index + 1
slide_root = slide_roots[index]
slide_sxsd_issues = [
normalize_issue(issue, slide_number, {})
for issue in validate_sxsd_document(slide_xml, slide_root)
]
slide_sxsd_errors = [
issue for issue in slide_sxsd_issues if issue["level"] == "error"
]
if slide_sxsd_errors:
slide_sxsd_warnings = [
issue for issue in slide_sxsd_issues if issue["level"] == "warning"
]
slides.append(
{
"slide_number": slide_number,
"status": slide_status(slide_sxsd_errors, slide_sxsd_warnings),
"element_count": 0,
"errors": slide_sxsd_errors,
"warnings": slide_sxsd_warnings,
"infos": [],
"issues": slide_sxsd_issues,
}
)
continue
geometry = lint_slide(
slide_xml,
slide_number,
@@ -2535,8 +2649,11 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
),
]
issues = [
normalize_issue(issue, slide_number, elements_by_id)
for issue in raw_issues
*slide_sxsd_issues,
*[
normalize_issue(issue, slide_number, elements_by_id)
for issue in raw_issues
],
]
errors = [issue for issue in issues if issue["level"] == "error"]
warnings = [issue for issue in issues if issue["level"] == "warning"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestContactSearchBotWorkflowAsUser proves the live round-trip without assuming
// anything about the tenant's bot inventory. An earlier version required at least
// one match for a hard-coded keyword, which is the tenant dependency that kept
// +search-user out of live coverage (see coverage.md): a tenant with no bot
// matching that word would fail the suite for no reason of ours.
//
// What is tenant-independent and still worth pinning: the command authenticates,
// the server accepts the request, and the envelope keeps its shape. The field
// assertions run over whatever rows came back, so zero rows is a pass.
func TestContactSearchBotWorkflowAsUser(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+search-bot", "--query", "助", "--format", "json"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
bots := gjson.Get(result.Stdout, "data.bots")
require.True(t, bots.IsArray(), "data.bots must be an array even when empty; stdout:\n%s", result.Stdout)
require.True(t, gjson.Get(result.Stdout, "data.has_more").Exists(), "data.has_more must be present; stdout:\n%s", result.Stdout)
for _, bot := range bots.Array() {
openID := bot.Get("open_id").String()
require.NotEmpty(t, openID, "every bot must carry open_id; stdout:\n%s", result.Stdout)
require.True(t, strings.HasPrefix(openID, "ou_"),
"bot ids are open_ids; stdout:\n%s", result.Stdout)
require.True(t, bot.Get("chat_id").Exists(),
"chat_id must be present even when empty; stdout:\n%s", result.Stdout)
require.True(t, bot.Get("match_segments").IsArray(),
"match_segments must be an array, never null; stdout:\n%s", result.Stdout)
}
}
// A filter without a keyword is rejected locally, so this costs no API call and
// holds in any tenant: it pins the contract that neither filter can enumerate.
func TestContactSearchBotRejectsFilterOnlyAsUser(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+search-bot", "--has-chatted", "--format", "json"},
DefaultAs: "user",
})
require.NoError(t, err)
require.NotEqual(t, 0, result.ExitCode, "a filter-only request must not succeed; stderr:\n%s", result.Stderr)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
var named []string
for _, p := range gjson.Get(result.Stderr, "error.params").Array() {
named = append(named, p.Get("name").String())
}
require.ElementsMatch(t, []string{"--query", "--queries"}, named,
"the error must name both ways to supply a keyword; stderr:\n%s", result.Stderr)
}

View File

@@ -1,13 +1,15 @@
# Contact CLI E2E Coverage
## Metrics
- Denominator: 2 leaf commands
- Covered: 1
- Coverage: 50.0%
- Denominator: 3 leaf commands
- Covered: 2
- Coverage: 66.7%
## Summary
- TestContact_LookupWorkflowAsUser: proves the user lookup workflow through `get self as user` and `get self by open id as user`; reads the current user first and round-trips the returned `open_id` back into `+get-user`.
- TestContact_LookupWorkflowAsBot: proves bot lookup through `discover user via api as bot` and `get user by open id as bot`; the raw API discovery step is fixture setup only and does not affect the domain denominator.
- TestContactSearchBotWorkflowAsUser: proves live bot search as user; validates the envelope shape (`bots[]` is an array, `has_more` present) and, for whatever rows the tenant returns, that `open_id` is an `ou_` id, the P2P `chat_id` is present even when empty, and `match_segments` is never null. Deliberately does not require a minimum row count: the assertions must hold in a tenant with no matching bot.
- TestContactSearchBotRejectsFilterOnlyAsUser: pins that `--has-chatted` without a keyword is rejected as a typed validation error naming both `--query` and `--queries`. Rejected locally, so it needs no tenant data and issues no API call.
- Blocked area: `contact +search-user` did not reliably return the current user in UAT even when queried with self-derived identifiers, so it remains uncovered rather than being counted from a flaky tenant-dependent assertion.
## Command Table
@@ -15,4 +17,5 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | contact +get-user | shortcut | contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsUser/get self as user; contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsUser/get self by open id as user; contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsBot/get user by open id as bot | self lookup; `--user-id <open_id>` | |
| ✓ | contact +search-bot | shortcut | contact_search_bot_workflow_test.go::TestContactSearchBotWorkflowAsUser; contact_search_bot_workflow_test.go::TestContactSearchBotRejectsFilterOnlyAsUser | `--query <keyword>`; `--has-chatted` alone (rejected); `--format json`; user identity | tenant-independent: no minimum row count asserted |
| ✕ | contact +search-user | shortcut | | none | UAT did not reliably return the current user for self-derived queries, so stable write-after-read style proof is not available |

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package dryrun
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestContactSearchBotDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "contact_search_bot_dryrun")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "contact_search_bot_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"contact", "+search-bot",
"--query", "助手",
"--chat-ids", "oc_a,oc_b",
"--has-chatted",
"--page-size", "25",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/bot/v4/bot/search", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, int64(25), clie2e.DryRunGet(out, "api.0.params.page_size").Int(), "stdout:\n%s", out)
require.Equal(t, "助手", clie2e.DryRunGet(out, "api.0.body.query").String(), "stdout:\n%s", out)
require.Equal(t, []string{"oc_a", "oc_b"}, []string{
clie2e.DryRunGet(out, "api.0.body.filter.chat_ids.0").String(),
clie2e.DryRunGet(out, "api.0.body.filter.chat_ids.1").String(),
}, "stdout:\n%s", out)
require.True(t, clie2e.DryRunGet(out, "api.0.body.filter.has_chatter").Bool(), "stdout:\n%s", out)
}