339 lines
13 KiB
Go
339 lines
13 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
package wiki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
|
)
|
|
|
|
const wikiCompositeReason = "Reviewed Wiki Shortcut composite: the executable CLI owns strict response validation, pagination projection, optional multi-step orchestration, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
|
|
|
|
const wikiSpaceSearchCompositeReason = "Reviewed Wiki space-search compatibility adapter: the published workflow properties query/limit remain stable while execution translates them to search_wikiSpaces.keyword/pageSize; redirecting an existing non-empty property requires a versioned Schema migration."
|
|
|
|
func wikiContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl {
|
|
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
|
cliPath := "wiki " + command
|
|
return corecmd.ContractDecl{
|
|
Description: description, Parameters: params, Result: result, Pagination: pagination,
|
|
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: wikiCompositeReason},
|
|
Selection: contract.SelectionSpec{AgentSummary: description, UseWhen: []string{useWhen}, AvoidWhen: avoidWhen, Examples: examples},
|
|
Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: name, CanonicalPath: "wiki." + name, CLIPath: cliPath, PrimaryCLIPath: cliPath},
|
|
}
|
|
}
|
|
|
|
func wikiWithInterfaceReason(declared shortcut.Shortcut, reason string) shortcut.Shortcut {
|
|
declared.Contract.Interface.Reason = reason
|
|
return declared
|
|
}
|
|
|
|
func wikiObjectResult(description string) *contract.ResultSpec {
|
|
return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"additionalProperties":true}`, description))}
|
|
}
|
|
|
|
func wikiCollectionResult(collection, description string) *contract.ResultSpec {
|
|
return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf(
|
|
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"有效结果数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Wiki 业务条目","additionalProperties":true}},"nextCursor":{"type":"string","description":"下一页游标"},"hasMore":{"type":"boolean","description":"服务端是否仍有下一页"}},"required":["count",%q],"additionalProperties":true}`,
|
|
description, collection, description, collection))}
|
|
}
|
|
|
|
func wikiCursorPagination() *contract.PaginationSpec {
|
|
return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor", MetaPath: contract.PaginationMetaPath, EndpointExhaustedPath: contract.PaginationExhaustedPath, NextTokenPath: contract.PaginationNextTokenPath}
|
|
}
|
|
|
|
func requireWikiResponse(data map[string]any, operation string) (map[string]any, error) {
|
|
if len(data) == 0 {
|
|
return nil, wikiResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功")
|
|
}
|
|
if value, present := data["success"]; present {
|
|
success, ok := value.(bool)
|
|
if !ok {
|
|
return nil, wikiResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
|
|
}
|
|
if !success {
|
|
message := firstWikiString(data, "errorMsg", "message", "error")
|
|
if message == "" {
|
|
message = "服务明确返回 success=false"
|
|
}
|
|
return nil, wikiResponseError(operation, "remote_failure", message)
|
|
}
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func requireWikiWrite(data map[string]any, operation string) (map[string]any, error) {
|
|
data, err := requireWikiResponse(data, operation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if success, ok := data["success"].(bool); !ok || !success {
|
|
return nil, wikiResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func requireWikiObject(data map[string]any, operation string) (map[string]any, error) {
|
|
data, err := requireWikiResponse(data, operation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, wrapper := range []string{"result", "data"} {
|
|
if value, present := data[wrapper]; present {
|
|
object, ok := value.(map[string]any)
|
|
if !ok || len(object) == 0 {
|
|
return nil, wikiResponseError(operation, "malformed_object", "响应业务对象缺失或畸形")
|
|
}
|
|
return object, nil
|
|
}
|
|
}
|
|
if len(data) == 1 {
|
|
return nil, wikiResponseError(operation, "missing_business_result", "响应没有可验证的业务对象")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func requireWikiCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) {
|
|
data, err := requireWikiResponse(data, operation)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
containers := []map[string]any{data}
|
|
for _, wrapper := range []string{"result", "data"} {
|
|
if value, present := data[wrapper]; present {
|
|
inner, ok := value.(map[string]any)
|
|
if !ok {
|
|
return nil, nil, wikiResponseError(operation, "malformed_envelope", fmt.Sprintf("响应 %s 字段不是对象", wrapper))
|
|
}
|
|
containers = append(containers, inner)
|
|
}
|
|
}
|
|
for _, container := range containers {
|
|
for _, key := range keys {
|
|
value, present := container[key]
|
|
if !present {
|
|
continue
|
|
}
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return nil, nil, wikiResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
|
|
}
|
|
for index, item := range items {
|
|
if _, ok := item.(map[string]any); !ok {
|
|
return nil, nil, wikiResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index))
|
|
}
|
|
}
|
|
return items, container, nil
|
|
}
|
|
}
|
|
return nil, nil, wikiResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段或内部错误投影成空结果")
|
|
}
|
|
|
|
func projectWikiRows(items []any, aliases map[string][]string) []map[string]any {
|
|
rows := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
source := item.(map[string]any)
|
|
row := make(map[string]any)
|
|
for canonical, candidates := range aliases {
|
|
for _, candidate := range candidates {
|
|
if value, ok := source[candidate]; ok && value != nil {
|
|
row[canonical] = value
|
|
break
|
|
}
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func projectWikiRowsPreservingSource(items []any, aliases map[string][]string) []map[string]any {
|
|
rows := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
source := item.(map[string]any)
|
|
row := make(map[string]any, len(source)+len(aliases))
|
|
for key, value := range source {
|
|
row[key] = value
|
|
}
|
|
for canonical, candidates := range aliases {
|
|
if value, ok := row[canonical]; ok && value != nil {
|
|
continue
|
|
}
|
|
for _, candidate := range candidates {
|
|
if value, ok := source[candidate]; ok && value != nil {
|
|
row[canonical] = value
|
|
break
|
|
}
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func addWikiPagination(out, page map[string]any) {
|
|
for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"pageToken", "nextCursor"}, {"hasMore", "hasMore"}, {"truncated", "truncated"}, {"totalCount", "totalCount"}, {"autoPageComplete", "autoPageComplete"}, {"autoPageStopReason", "autoPageStopReason"}, {"pagesFetched", "pagesFetched"}} {
|
|
if value, ok := page[pair[0]]; ok && value != nil {
|
|
out[pair[1]] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
func firstWikiString(data map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func nestedWikiString(data map[string]any, keys ...string) string {
|
|
if value := firstWikiString(data, keys...); value != "" {
|
|
return value
|
|
}
|
|
for _, wrapper := range []string{"result", "data"} {
|
|
if inner, ok := data[wrapper].(map[string]any); ok {
|
|
if value := firstWikiString(inner, keys...); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func wikiStringInt(rt *shortcut.RuntimeContext, name string, fallback, min, max int) (int, error) {
|
|
raw := rt.Str(name)
|
|
if raw == "" {
|
|
return fallback, nil
|
|
}
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < min || value > max {
|
|
return 0, fmt.Errorf("--%s 必须是 %d-%d 之间的整数", name, min, max)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func wikiStringSliceFirst(rt *shortcut.RuntimeContext, primary string, aliases ...string) []string {
|
|
if rt.Changed(primary) {
|
|
return rt.StrSlice(primary)
|
|
}
|
|
for _, alias := range aliases {
|
|
if rt.Changed(alias) {
|
|
values, _ := rt.Command().Flags().GetStringSlice(alias)
|
|
return values
|
|
}
|
|
}
|
|
return rt.StrSlice(primary)
|
|
}
|
|
|
|
func wikiResponseError(operation, reason, message string) error {
|
|
return apperrors.NewAPI(message, apperrors.WithOperation(operation), apperrors.WithOrigin("mcp"), apperrors.WithFailureStage("response_validation"), apperrors.WithRetryable(false), apperrors.WithReason(reason))
|
|
}
|
|
|
|
func wikiReadSafety() contract.SafetySpec {
|
|
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
|
}
|
|
func wikiWriteSafety(confirm bool) contract.SafetySpec {
|
|
confirmation := "not_required"
|
|
if confirm {
|
|
confirmation = "user_required"
|
|
}
|
|
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: confirmation, Idempotency: "unknown"}
|
|
}
|
|
func wikiDeleteSafety() contract.SafetySpec {
|
|
return contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"}
|
|
}
|
|
|
|
func wikiAutoPageFlags() []shortcut.Flag {
|
|
const evidence = "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"
|
|
return append([]shortcut.Flag{{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动沿游标取完所有页。" + evidence}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "自动翻页最多请求页数。" + evidence}}, shortcut.AutoPageControlFlags()...)
|
|
}
|
|
|
|
func enableWikiAutoPage(item *shortcut.Shortcut) {
|
|
item.Flags = append(item.Flags, wikiAutoPageFlags()...)
|
|
item.Constraints = append(item.Constraints, shortcut.AutoPageControlConstraints()...)
|
|
item.Validate = func(rt *shortcut.RuntimeContext) error {
|
|
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
|
return err
|
|
}
|
|
if rt.Bool("page-all") && rt.Int("page-limit") < 1 {
|
|
return fmt.Errorf("--page-limit 必须大于 0")
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
type wikiPageFetcher func(cursor string, pageSize int) (map[string]any, error)
|
|
|
|
func collectWikiPages(rt *shortcut.RuntimeContext, operation string, pageSize int, keys []string, fetch wikiPageFetcher) ([]any, map[string]any, error) {
|
|
if !rt.Bool("page-all") {
|
|
data, err := fetch(rt.StrFirst("cursor", "page-token"), pageSize)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return requireWikiCollection(data, operation, keys...)
|
|
}
|
|
all := make([]any, 0)
|
|
cursor := rt.StrFirst("cursor", "page-token")
|
|
lastPage := map[string]any{}
|
|
for pageNumber := 1; pageNumber <= rt.Int("page-limit"); pageNumber++ {
|
|
requestSize := shortcut.AutoPageRequestSize(rt, pageSize, len(all))
|
|
data, err := fetch(cursor, requestSize)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
items, page, err := requireWikiCollection(data, operation, keys...)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
maxItems := rt.Int("max-items")
|
|
if maxItems > 0 {
|
|
remaining := maxItems - len(all)
|
|
if len(items) > remaining {
|
|
items = items[:remaining]
|
|
}
|
|
}
|
|
all = append(all, items...)
|
|
lastPage = page
|
|
if maxItems > 0 && len(all) >= maxItems {
|
|
lastPage["autoPageComplete"] = false
|
|
lastPage["autoPageStopReason"] = "max_items"
|
|
lastPage["pagesFetched"] = pageNumber
|
|
return all, lastPage, nil
|
|
}
|
|
hasMore, present := page["hasMore"]
|
|
if !present {
|
|
return nil, nil, wikiResponseError(operation, "missing_has_more", "--page-all 要求每页响应提供 hasMore 布尔值")
|
|
}
|
|
more, ok := hasMore.(bool)
|
|
if !ok {
|
|
return nil, nil, wikiResponseError(operation, "malformed_has_more", "分页响应 hasMore 不是布尔值")
|
|
}
|
|
if !more {
|
|
lastPage["autoPageComplete"] = true
|
|
lastPage["pagesFetched"] = pageNumber
|
|
return all, lastPage, nil
|
|
}
|
|
next := firstWikiString(page, "nextCursor", "nextToken", "nextPageToken", "pageToken")
|
|
if next == "" {
|
|
return nil, nil, wikiResponseError(operation, "missing_next_cursor", "hasMore=true 但响应缺少下一页游标")
|
|
}
|
|
if next == cursor {
|
|
return nil, nil, wikiResponseError(operation, "stalled_cursor", "下一页游标未变化,已停止以避免死循环")
|
|
}
|
|
cursor = next
|
|
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
return nil, nil, wikiResponseError(operation, "page_limit_reached", "达到 --page-limit 时服务端仍有下一页;提高页数上限或使用返回游标续传")
|
|
}
|