mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
4 Commits
feat/plugi
...
fix/slides
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee24a6336a | ||
|
|
6e5308af01 | ||
|
|
87be09ef5f | ||
|
|
a575a8ba60 |
@@ -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.
|
||||
|
||||
|
||||
447
shortcuts/contact/contact_search_bot.go
Normal file
447
shortcuts/contact/contact_search_bot.go
Normal 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
|
||||
}
|
||||
289
shortcuts/contact/contact_search_bot_fanout.go
Normal file
289
shortcuts/contact/contact_search_bot_fanout.go
Normal 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
|
||||
}
|
||||
684
shortcuts/contact/contact_search_bot_fanout_test.go
Normal file
684
shortcuts/contact/contact_search_bot_fanout_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
724
shortcuts/contact/contact_search_bot_test.go
Normal file
724
shortcuts/contact/contact_search_bot_test.go
Normal 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>部门成员&仓库\n来自飞书多维表格", wantName: "Lark部门成员&仓库", wantDescription: "来自飞书多维表格", wantSegments: []string{"Lark"}},
|
||||
{name: "html entity in highlight", raw: "名称<h>&</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: "名称<h>工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{}},
|
||||
{name: "escaped angle brackets inside a highlight", raw: "<h>名称<h></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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ContactSearchUser,
|
||||
ContactSearchBot,
|
||||
ContactGetUser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 范围
|
||||
|
||||
|
||||
60
skills/lark-contact/references/lark-contact-search-bot.md
Normal file
60
skills/lark-contact/references/lark-contact-search-bot.md
Normal 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>` | 返回条数,1–30,默认 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` 对所有关键词生效
|
||||
@@ -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 链接
|
||||
```
|
||||
|
||||
|
||||
908
skills/lark-slides/scripts/sxsd_validator.py
Normal file
908
skills/lark-slides/scripts/sxsd_validator.py
Normal 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
|
||||
@@ -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
76
tests/cli_e2e/contact/contact_search_bot_workflow_test.go
Normal file
76
tests/cli_e2e/contact/contact_search_bot_workflow_test.go
Normal 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)
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
48
tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go
Normal file
48
tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user